diff --git a/dev/README-hunspell-dictionaries.md b/dev/README-hunspell-dictionaries.md index 0adda22501..8723a4164a 100644 --- a/dev/README-hunspell-dictionaries.md +++ b/dev/README-hunspell-dictionaries.md @@ -17,15 +17,21 @@ # Hunspell dictionaries for the affix stemmer -The Hunspell stemmer (`opennlp.tools.stemmer.hunspell`) implements the documented Hunspell dictionary format: a `.dic` word list plus its `.aff` affix companion, both supplied by the user. Apache OpenNLP bundles no dictionary data; whichever dictionary you download, its license is stated in the readme shipped alongside it. +The Hunspell stemmer (`opennlp.tools.stemmer.hunspell`) reads a user-supplied +`.dic` word list and its `.aff` affix file. Apache OpenNLP bundles no dictionary +data. The dictionary's readme states its license. ## Where dictionaries come from -The LibreOffice project maintains a large collection of Hunspell dictionaries, one directory per language, at `github.com/LibreOffice/dictionaries`. Licenses differ per dictionary, which is why nothing is bundled: for example, the `en_US` dictionary derives from SCOWL and states its terms in `README_en_US.txt` in the same directory. Many other sources work too; the engine only cares that the pair follows the Hunspell format. +The LibreOffice project maintains Hunspell dictionaries by language at +`github.com/LibreOffice/dictionaries`. Each dictionary has a separate license. +For example, SCOWL is the source for the `en_US` dictionary, with terms in +`README_en_US.txt`. Other sources can be used when the `.aff` and `.dic` files +follow the Hunspell format. OpenNLP does not ship a URL catalog. Applications that manage downloads can keep a properties file with an entry id followed by `.url`, `.sha512`, and optionally -`.filename` keys. Pin each URL to a stable release or commit. +`.filename` keys. Use a URL for a stable release or commit. ## Option A: application catalog @@ -54,7 +60,7 @@ The download test uses local file URLs to exercise this flow without network acc ## Option B: your own files Fetch `.aff` / `.dic` (and the license readme) with any tool, or with -`DownloadUtil.download(uri, path, sha512)`, then load them: +`ResourceInstaller.install(uri, directory, sha512)`, then load them: ```java import java.nio.file.Path; @@ -71,9 +77,13 @@ Stemmer stemmer = factory.newStemmer(); CharSequence stem = stemmer.stem("workers"); ``` -What `stem` evaluates to is decided by the dictionary you loaded, and this project ships no dictionary data, so no result is claimed here for `en_US`. The same load-and-stem flow is pinned by `HunspellManualExampleTest` (miniature in-memory dictionary, asserted stems for `workers` and `worker`) and by `HunspellStemmerFactoryTest#testEndToEndUsageFromFiles` (the same pair written to disk). The developer manual chapter `stemmer.xml` cites `HunspellManualExampleTest`. +The result depends on the loaded dictionary. The in-tree manual example uses a +small dictionary and checks that `workers` stems to `worker`. -The dictionary is immutable and safe to share between threads; the factory hands out a fresh stemmer per call, so each thread takes its own from `newStemmer()`. A dictionary that declares a non-UTF-8 encoding through the `SET` directive in its `.aff` file is decoded accordingly; nothing needs converting beforehand. +The dictionary is immutable and safe to share between threads. The factory creates a +new stemmer for each call, so each thread can use its own instance. A dictionary that +declares a non-UTF-8 encoding through the `SET` directive in its `.aff` file is decoded +accordingly; no conversion is required. ## Testing against real dictionaries @@ -86,4 +96,8 @@ The in-tree tests run against project-authored fixtures only. An opt-in test cla ## What the engine supports -Supported affix features: `PFX` and `SFX` rules with strip strings, character-class conditions, cross-product combination of one prefix with one suffix, twofold suffixes through continuation classes, `FLAG` modes `char`, `UTF-8`, `long`, and `num`, the `AF` flag alias table, the `SET` encoding declaration, compound decomposition under `COMPOUNDFLAG`, the positional `COMPOUNDBEGIN`/`COMPOUNDMIDDLE`/`COMPOUNDEND` flags, `COMPOUNDMIN`, `COMPOUNDWORDMAX`, `COMPOUNDPERMITFLAG`, `COMPOUNDFORBIDFLAG`, and the `CHECKCOMPOUNDDUP`/`CHECKCOMPOUNDCASE`/`CHECKCOMPOUNDTRIPLE` declarations (compound parts stand on their entries alone or on an entry plus one affix, the zero and dash suffixes dictionaries position linking forms with included), the blocking flags `NEEDAFFIX` (alias `PSEUDOROOT`), `ONLYINCOMPOUND`, and `FORBIDDENWORD`, which keep virtual stems, compound-only parts, and forbidden words out of the reported analyses, and `CIRCUMFIX`, which binds marked prefix and suffix halves to one another as in the German `ge...t` participle, and the `FULLSTRIP` declaration, without which a rule that strips a whole stem is not applied, matching Hunspell. Directives that would change stems when ignored (`ICONV`, `OCONV`, `COMPLEXPREFIXES`, `COMPOUNDRULE`, `IGNORE`, `KEEPCASE`) fail at load time. Cosmetic tables such as `REP`, `MAP`, and `KEY` are skipped, so analyses that would need them are missed rather than invented. A malformed `.aff` file fails loudly at load time with the offending line number in the message. Each affix or dictionary stream is rejected when it exceeds `HunspellDictionary.MAX_STREAM_BYTES` (64 MiB). +Supported affix features include `PFX` and `SFX` rules, continuation classes, +compound flags, blocking flags, `CIRCUMFIX`, and `FULLSTRIP`. The parser rejects +directives that would change stems if ignored. It skips cosmetic tables that do not +affect stemming. Malformed files report the relevant line number. Each affix or +dictionary stream is limited to 64 MiB. diff --git a/dev/README-mecab-dictionaries.md b/dev/README-mecab-dictionaries.md index f6067c7fd8..66aa183927 100644 --- a/dev/README-mecab-dictionaries.md +++ b/dev/README-mecab-dictionaries.md @@ -17,53 +17,75 @@ # CJK dictionaries for the lattice tokenizer -The lattice tokenizer (`opennlp.tools.tokenize.lattice`) segments Japanese and Korean over a MeCab-format dictionary, and the unigram segmenter handles Chinese over a plain word-frequency lexicon. Apache OpenNLP bundles no dictionary data. Download a dictionary from its project and read the license file inside the archive before use. +The lattice tokenizer (`opennlp.tools.tokenize.lattice`) segments Japanese and Korean over a MeCab-format dictionary, and the unigram segmenter handles Chinese over a plain word-frequency lexicon. Apache OpenNLP bundles no dictionary data: you download a dictionary from the project of your choice, and each dictionary contains its own license. Read the license file inside the archive before use. ## Known MeCab-format dictionary projects -| Dictionary | Language | Encoding | -|---|---|---| -| IPADIC 2.7.0 | Japanese | EUC-JP | -| mecab-ko-dic 2.1.1 | Korean | UTF-8 | +| Catalog id | Dictionary | Language | Encoding | +|---|---|---|---| +| `mecab.ipadic` | IPADIC 2.7.0 | Japanese | EUC-JP | +| `mecab.ko-dic` | mecab-ko-dic 2.1.1 | Korean | UTF-8 | -Download a release archive directly from the dictionary project. The installer reads -gzip-compressed ustar archives. +Example download URLs and SHA-512 digests for those ids live in the test resource +`opennlp-core/opennlp-runtime/src/test/resources/opennlp/tools/util/dictionary-catalog.properties`. Both archives are +gzip-compressed tars; `MecabDictionaryInstaller` reads the ustar, pax, and GNU +formats through `ResourceInstaller`. The installer extracts only the dictionary payload: the `*.csv` and `*.def` files a `MecabDictionary` reads, plus the `dicrc` configuration file the distributions ship -alongside them. It flattens the entries into the target directory, and by the same -flattening makes it impossible for an archive path to escape that directory. The -returned count is the number of dictionary files extracted. Tar headers are -checksum-validated, and files are staged on the target filesystem before publication. -The installer does not replace files already present in the target directory. +alongside them. `ResourceInstaller` rejects paths outside the staging directory, +then `MecabDictionaryInstaller` flattens the selected files into the target. The +returned value is the number of dictionary files installed. -## Install a local archive +## Option A: opt-in catalog install + +Applications supply the catalog. Catalog URLs are inactive until you set +`-Dopennlp.download.remote=true` or the equivalent system property in code. + +```java +import java.nio.file.Path; +import opennlp.tools.tokenize.lattice.MecabDictionaryInstaller; +import opennlp.tools.util.DictionaryCatalog; + +// JVM flag: -Dopennlp.download.remote=true +DictionaryCatalog catalog = DictionaryCatalog.load(catalogProperties); +int files = MecabDictionaryInstaller.installFromCatalog( + catalog, "mecab.ipadic", Path.of("ipadic")); +``` + +## Option B: your own URL and digest ```java +import java.net.URI; import java.nio.file.Path; import opennlp.tools.tokenize.lattice.MecabDictionaryInstaller; -Path localArchive = Path.of("mecab-ipadic-2.7.0-20070801.tar.gz"); -int files = MecabDictionaryInstaller.install(localArchive.toUri(), Path.of("ipadic")); +String expectedSha512 = "..."; // the 128-hex SHA-512 of the archive +int files = MecabDictionaryInstaller.install( + URI.create("https://example.example/dict.tar.gz"), + Path.of("dict"), + expectedSha512); ``` -`MecabDictionaryInstaller.install` accepts trusted local `file:` URIs. Remote download -and verification are outside this API. +A local `file:` URI may omit the digest: +`MecabDictionaryInstaller.install(localArchive.toUri(), targetDirectory)`. +HTTP and HTTPS sources require a digest. Other URI schemes are rejected. ## Size budgets for larger dictionaries -Extraction is bounded so a crafted archive cannot fill the disk. By default one -extracted tar entry is limited to 512 MiB and the total extracted payload to 2 GiB. -IPADIC and mecab-ko-dic fit within these limits. For larger dictionaries, such as -UniDic, raise the limits at JVM startup: +Fetching and unpacking go through `ResourceInstaller` and are bounded so a crafted +archive cannot fill the disk: by default one download is capped at 1 GiB, the +unpacked payload at 4 GiB, and the archive at 100000 entries. IPADIC and +mecab-ko-dic fit comfortably. For larger dictionaries, such as UniDic, raise the +limits at JVM startup: ```bash --Dopennlp.install.max.entry.bytes=4294967296 \ --Dopennlp.install.max.total.bytes=8589934592 +-Dopennlp.download.max.bytes=4294967296 \ +-Dopennlp.install.max.total.bytes=8589934592 \ +-Dopennlp.install.max.entries=200000 ``` -Values must be positive byte counts; anything absent or invalid falls back to the -default. +Missing, invalid, and nonpositive property values use the default limits. ## Load and tokenize @@ -101,4 +123,4 @@ UnigramSegmenter segmenter = UnigramSegmenter.load(Path.of("words.txt")); String[] tokens = segmenter.tokenize("\u6211\u6765\u5230\u5317\u4EAC\u5929\u5B89\u95E8"); ``` -As with the dictionaries, the lexicon has its own license; no lexicon data is bundled. +The lexicon archive includes its license; OpenNLP bundles no data. diff --git a/opennlp-api/src/main/java/opennlp/tools/util/ResourceLimits.java b/opennlp-api/src/main/java/opennlp/tools/util/ResourceLimits.java index 4003a0657f..0f6f4d846d 100644 --- a/opennlp-api/src/main/java/opennlp/tools/util/ResourceLimits.java +++ b/opennlp-api/src/main/java/opennlp/tools/util/ResourceLimits.java @@ -58,22 +58,6 @@ public final class ResourceLimits { public static final int MAX_MATRIX_CELLS = initLimit(MAX_MATRIX_CELLS_PROPERTY, 134_217_728); - /** System property for the maximum size of one extracted archive entry. */ - public static final String MAX_ARCHIVE_ENTRY_BYTES_PROPERTY = - "opennlp.install.max.entry.bytes"; - - /** Maximum size of one extracted archive entry, 512 MiB by default. */ - public static final long MAX_ARCHIVE_ENTRY_BYTES = - initLimit(MAX_ARCHIVE_ENTRY_BYTES_PROPERTY, 512L * 1024 * 1024); - - /** System property for the maximum total size extracted from one archive. */ - public static final String MAX_ARCHIVE_TOTAL_BYTES_PROPERTY = - "opennlp.install.max.total.bytes"; - - /** Maximum total size extracted from one archive, 2 GiB by default. */ - public static final long MAX_ARCHIVE_TOTAL_BYTES = - initLimit(MAX_ARCHIVE_TOTAL_BYTES_PROPERTY, 2L * 1024 * 1024 * 1024); - private ResourceLimits() { } diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/hunspell/HunspellDictionaryDownload.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/hunspell/HunspellDictionaryDownload.java index 3775df4ac6..95f8c62fba 100644 --- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/hunspell/HunspellDictionaryDownload.java +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/hunspell/HunspellDictionaryDownload.java @@ -32,12 +32,15 @@ */ public final class HunspellDictionaryDownload { + /** Prevents construction of this utility class. */ private HunspellDictionaryDownload() { } /** * Downloads the cataloged {@code .aff}, {@code .dic}, and readme files for - * {@code dictionaryId} into {@code targetDirectory}. + * {@code dictionaryId} into {@code targetDirectory}. Each file uses its configured + * name or source name, for example {@code en_US.aff}. Existing target files are not + * replaced, so they must be removed before refreshing a dictionary. * * @param catalog The application-supplied catalog. Must not be {@code null}. * @param dictionaryId The catalog dictionary name, for example {@code en_US}. @@ -45,7 +48,8 @@ private HunspellDictionaryDownload() { * @param targetDirectory The directory to write into; created when absent. Must not * be {@code null}. * @throws IOException Thrown if remote downloads are disabled, a catalog entry is - * missing, or verification fails. + * missing, verification fails, or the target already contains one of the + * files. * @throws IllegalArgumentException Thrown if a parameter is {@code null}. */ public static void downloadFromCatalog(DictionaryCatalog catalog, String dictionaryId, @@ -60,34 +64,11 @@ public static void downloadFromCatalog(DictionaryCatalog catalog, String diction throw new IllegalArgumentException("targetDirectory must not be null"); } final String prefix = "hunspell." + dictionaryId; - download(catalog, prefix + HunspellDictionary.AFFIX_FILE_SUFFIX, targetDirectory); - download(catalog, prefix + HunspellDictionary.DICTIONARY_FILE_SUFFIX, targetDirectory); + catalog.install(prefix + HunspellDictionary.AFFIX_FILE_SUFFIX, targetDirectory); + catalog.install(prefix + HunspellDictionary.DICTIONARY_FILE_SUFFIX, targetDirectory); final String readmeId = prefix + ".readme"; if (catalog.ids().contains(readmeId)) { - download(catalog, readmeId, targetDirectory); + catalog.install(readmeId, targetDirectory); } } - - /** - * Downloads one catalog entry into {@code targetDirectory}, named by the entry's - * preferred file name or, when absent, by the last segment of its URI path. - * - * @param catalog The catalog holding {@code id}. - * @param id The catalog entry id. - * @param targetDirectory The directory to write into. - * @throws IOException Thrown if remote downloads are disabled, the entry is missing, - * or the download fails verification. - */ - private static void download(DictionaryCatalog catalog, String id, Path targetDirectory) - throws IOException { - final DictionaryCatalog.Entry entry = catalog.get(id); - final String filename; - if (entry.filename() != null) { - filename = entry.filename(); - } else { - final String path = entry.uri().getPath(); - filename = path.substring(path.lastIndexOf('/') + 1); - } - catalog.download(id, targetDirectory.resolve(filename)); - } } diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/lattice/MecabDictionaryInstaller.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/lattice/MecabDictionaryInstaller.java index 5d30e41c14..bfd94e34fb 100644 --- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/lattice/MecabDictionaryInstaller.java +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/lattice/MecabDictionaryInstaller.java @@ -17,576 +17,255 @@ package opennlp.tools.tokenize.lattice; -import java.io.FilterInputStream; import java.io.IOException; -import java.io.InputStream; import java.net.URI; -import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; -import java.util.zip.GZIPInputStream; +import java.util.Set; +import java.util.stream.Stream; -import opennlp.tools.util.ResourceLimits; -import opennlp.tools.util.model.UncloseableInputStream; +import opennlp.tools.util.DictionaryCatalog; +import opennlp.tools.util.ResourceInstaller; /** - * Unpacks a local MeCab-format dictionary archive into a directory. No dictionary data - * is bundled with this library. + * Fetches and unpacks a MeCab-format dictionary archive into a local directory, so the + * dictionary is acquired by the user at install time and never ships with this library. + * No dictionary data is bundled. Fetching, verification, and unpacking are done by + * {@link ResourceInstaller} under {@link ResourceInstaller.Limits#DEFAULT}, including + * startup property overrides. An {@code http} or {@code https} archive requires an + * expected checksum, and the ustar, pax, and GNU tar formats are all read. Catalog + * installs are opt-in via + * {@link #installFromCatalog(DictionaryCatalog, String, Path)}. * - *

The installer reads gzip-compressed - * - * ustar archives (POSIX.1-1988), the format the common distributions use. GNU - * long-name ({@code L}) and PAX ({@code x}/{@code g}) headers are not supported; entry - * names must fit the 100-byte ustar name field. It - * extracts only the dictionary payload: the {@code *.csv} lexicon files and + *

Only the dictionary payload is installed: the {@code *.csv} lexicon files and * {@code *.def} definition files that a {@link MecabDictionary} reads, plus the * {@code dicrc} configuration file distributions ship alongside them, taken from the * archive root only (at most one leading directory deep). Deeper entries are skipped: - * mecab-ko-dic, for example, nests {@code user-dic} templates with empty numeric fields - * because they are input for {@code mecab-dict-index}, not loadable lexicon data. - * Extracted entries are flattened to their base names, which also means no - * archive path can escape the target directory.

- * - *

Extraction is bounded: each entry's declared size, the total bytes written, the - * number of extracted dictionary files, and the gzip expansion ratio each have an - * explicit limit so a crafted archive cannot fill the disk. The byte limits can be - * raised at JVM startup via {@link #MAX_ENTRY_BYTES_PROPERTY} and - * {@link #MAX_TOTAL_EXTRACTED_BYTES_PROPERTY} for dictionaries larger than the - * defaults, such as UniDic.

+ * mecab-ko-dic, for example, nests {@code user-dic} templates whose numeric fields are + * empty because they are input for {@code mecab-dict-index}, not loadable lexicon + * data. Installed files are flattened to their base names. A file whose base name + * already exists in the target is not replaced, so it must be removed before refreshing + * a dictionary. The archive unpacks into a hidden scratch directory beneath the target, + * on the target's filesystem, which is removed when the installation ends.

* * @since 3.0.0 */ public final class MecabDictionaryInstaller { - private static final int TAR_BLOCK = 512; - private static final int TAR_NAME_LENGTH = 100; - private static final int TAR_SIZE_OFFSET = 124; - private static final int TAR_SIZE_LENGTH = 12; - private static final int TAR_CHECKSUM_OFFSET = 148; - private static final int TAR_CHECKSUM_LENGTH = 8; - private static final int TAR_TYPE_OFFSET = 156; - private static final byte TAR_CHECKSUM_SPACE = ' '; - private static final String STAGING_PREFIX = ".mecab-staging-"; - - /** - * System property for overriding {@link #MAX_ENTRY_BYTES}. Set at JVM startup, - * e.g. {@code -Dopennlp.install.max.entry.bytes=2147483648} for dictionaries whose - * lexicon files exceed the default limit. Falls back to the default if absent, - * non-numeric, or not positive. - */ - public static final String MAX_ENTRY_BYTES_PROPERTY = - ResourceLimits.MAX_ARCHIVE_ENTRY_BYTES_PROPERTY; - - /** - * System property for overriding {@link #MAX_TOTAL_EXTRACTED_BYTES}. Set at JVM - * startup, e.g. {@code -Dopennlp.install.max.total.bytes=8589934592}. Falls back to - * the default if absent, non-numeric, or not positive. - */ - public static final String MAX_TOTAL_EXTRACTED_BYTES_PROPERTY = - ResourceLimits.MAX_ARCHIVE_TOTAL_BYTES_PROPERTY; - - /** - * Inclusive limit on one tar entry's declared size, in bytes: 512 MiB unless - * overridden via {@link #MAX_ENTRY_BYTES_PROPERTY}. - */ - static final long MAX_ENTRY_BYTES = ResourceLimits.MAX_ARCHIVE_ENTRY_BYTES; - - /** - * Inclusive limit on the sum of extracted dictionary file sizes, in bytes: 2 GiB - * unless overridden via {@link #MAX_TOTAL_EXTRACTED_BYTES_PROPERTY}. - */ - static final long MAX_TOTAL_EXTRACTED_BYTES = ResourceLimits.MAX_ARCHIVE_TOTAL_BYTES; + /** The deepest entry path, relative to the archive root, that holds payload. */ + private static final int MAX_PAYLOAD_DEPTH = 2; - /** Inclusive limit on the number of dictionary files extracted from one archive. */ - static final int MAX_EXTRACTED_ENTRIES = 10_000; - - /** - * Inclusive limit on decompressed bytes per compressed byte while reading the - * gzip wrapper; higher expansion fails before the payload is written. - */ - static final int MAX_GZIP_EXPANSION_RATIO = 100; + /** The hidden scratch directory beneath the target that the archive unpacks into. */ + private static final String SCRATCH_PREFIX = ".mecab-dict-"; + /** Prevents construction of this utility class. */ private MecabDictionaryInstaller() { - // This class exposes only static methods and cannot be instantiated. } /** - * Unpacks a trusted local {@code file:} archive URI. + * Unpacks a local {@code file:} archive URI. Any other scheme requires + * {@link #install(URI, Path, String)} with an expected checksum. * - * @param archive The archive location, a gzip-compressed ustar tar. Must not be + * @param archive The archive location, a gzip-compressed tar. Must not be * {@code null}. * @param targetDirectory The directory to unpack into; created when absent. Must not * be {@code null}. - * @return The number of dictionary files extracted. + * @return The number of dictionary files installed. * @throws IOException Thrown if reading or writing fails, the archive contains no - * dictionary file, an extraction budget is exceeded, or the target already - * contains a dictionary file with the same name. - * @throws IllegalArgumentException Thrown if a parameter is {@code null}, - * {@code archive} is not an absolute URI, or {@code archive} is not a - * {@code file:} URI. + * dictionary file, an installation limit is exceeded, or the target already + * contains one of the files. + * @throws IllegalArgumentException Thrown if a parameter is {@code null} or + * {@code archive} does not use the {@code file} scheme. */ public static int install(URI archive, Path targetDirectory) throws IOException { - if (archive == null) { - throw new IllegalArgumentException("archive must not be null"); - } - if (targetDirectory == null) { - throw new IllegalArgumentException("targetDirectory must not be null"); - } - if (!archive.isAbsolute()) { - throw new IllegalArgumentException("archive must be an absolute URI"); - } - if (!"file".equalsIgnoreCase(archive.getScheme())) { - throw new IllegalArgumentException("archive must use the file scheme"); - } - try (InputStream in = Files.newInputStream(Path.of(archive))) { - return extract(in, targetDirectory); - } + return install(archive, targetDirectory, null); } /** - * Unpacks a dictionary archive stream under the production extraction budgets. + * Downloads a dictionary archive when needed, verifies its checksum, and unpacks it + * through {@link ResourceInstaller#install(URI, Path, String)}. A {@code file:} URI + * may omit the checksum. * - * @param archiveStream The gzip-compressed ustar tar content. Must not be - * {@code null}. Not closed. + * @param archive The archive location, a gzip-compressed tar. Must not be + * {@code null}. * @param targetDirectory The directory to unpack into; created when absent. Must not * be {@code null}. - * @return The number of dictionary files extracted. - * @throws IOException Thrown if reading or writing fails, the archive contains no - * dictionary file, or an extraction budget is exceeded. - * @throws IllegalArgumentException Thrown if a parameter is {@code null}. + * @param expectedChecksum The expected digest of the archive bytes as a hex string, + * 64 characters for SHA-256 or 128 for SHA-512. Required for + * an http or https source; pass {@code null} to skip + * verification for a file source. + * @return The number of dictionary files installed. + * @throws IOException Thrown if fetching, verification, reading, or writing fails, + * the archive contains no dictionary file, an installation limit is + * exceeded, or the target already contains one of the files. + * @throws IllegalArgumentException Thrown if a parameter is {@code null}, the URI is + * not supported by {@link ResourceInstaller}, or an http or https source + * has no checksum. */ - public static int extract(InputStream archiveStream, Path targetDirectory) + public static int install(URI archive, Path targetDirectory, String expectedChecksum) throws IOException { - return extract(archiveStream, targetDirectory, MAX_ENTRY_BYTES, - MAX_TOTAL_EXTRACTED_BYTES, MAX_EXTRACTED_ENTRIES, MAX_GZIP_EXPANSION_RATIO); - } - - /** - * Unpacks a dictionary archive stream under caller-supplied budgets. - * - * @param archiveStream The gzip-compressed ustar tar content. Must not be - * {@code null}. Not closed. - * @param targetDirectory The directory to unpack into; created when absent. Must not - * be {@code null}. - * @param maxEntryBytes Inclusive limit on one entry's declared size. - * @param maxTotalBytes Inclusive limit on total extracted bytes. - * @param maxEntries Inclusive limit on extracted dictionary file count. - * @param maxGzipRatio Inclusive limit on decompressed bytes per compressed byte. - * @return The number of dictionary files extracted. - * @throws IOException Thrown if reading or writing fails, the archive contains no - * dictionary file, or a budget is exceeded. - * @throws IllegalArgumentException Thrown if a parameter is {@code null}. - */ - static int extract(InputStream archiveStream, Path targetDirectory, long maxEntryBytes, - long maxTotalBytes, int maxEntries, int maxGzipRatio) throws IOException { - if (archiveStream == null) { - throw new IllegalArgumentException("archiveStream must not be null"); + if (archive == null) { + throw new IllegalArgumentException("archive must not be null"); } if (targetDirectory == null) { throw new IllegalArgumentException("targetDirectory must not be null"); } - Files.createDirectories(targetDirectory); - final Path stagingDirectory = Files.createTempDirectory(targetDirectory, STAGING_PREFIX); - final List stagedFiles = new ArrayList<>(); - final List publishedFiles = new ArrayList<>(); - boolean published = false; + final Path unpacked = createScratch(targetDirectory); try { - final int extracted = extractToStaging(archiveStream, stagingDirectory, stagedFiles, - maxEntryBytes, maxTotalBytes, maxEntries, maxGzipRatio); - for (final Path stagedFile : stagedFiles) { - final Path target = targetDirectory.resolve(stagedFile.getFileName()); - if (Files.exists(target)) { - throw new IOException("dictionary file already exists: " + target); - } - } - for (final Path stagedFile : stagedFiles) { - final Path target = targetDirectory.resolve(stagedFile.getFileName()); - Files.move(stagedFile, target); - publishedFiles.add(target); - } - published = true; - return extracted; + ResourceInstaller.install(archive, unpacked, expectedChecksum); + return promoteDictionaryFiles(unpacked, targetDirectory); } finally { - if (!published) { - for (final Path file : publishedFiles) { - Files.deleteIfExists(file); - } - } - for (final Path file : stagedFiles) { - Files.deleteIfExists(file); - } - Files.deleteIfExists(stagingDirectory); + deleteRecursively(unpacked); } } /** - * Validates and extracts an archive into a temporary directory. + * Downloads a dictionary named in an application-supplied + * {@link DictionaryCatalog} and unpacks it. Requires + * {@code -Dopennlp.download.remote=true}. * - * @param archiveStream The gzip-compressed archive. Not closed. - * @param stagingDirectory The empty directory that receives extracted files. - * @param stagedFiles Receives each extracted file. - * @param maxEntryBytes Inclusive limit on one entry's declared size. - * @param maxTotalBytes Inclusive limit on total extracted bytes. - * @param maxEntries Inclusive limit on extracted dictionary file count. - * @param maxGzipRatio Inclusive limit on decompressed bytes per compressed byte. - * @return The number of dictionary files extracted. - * @throws IOException Thrown if validation, reading, or writing fails. + * @param catalog The application-supplied catalog. Must not be {@code null}. + * @param dictionaryId The catalog id, for example {@code mecab.ipadic} or + * {@code mecab.ko-dic}. Must not be {@code null}. + * @param targetDirectory The directory to unpack into; created when absent. Must not + * be {@code null}. + * @return The number of dictionary files installed. + * @throws IOException Thrown if the catalog entry is missing, remote downloads are + * disabled, or install fails. + * @throws IllegalArgumentException Thrown if a parameter is {@code null}. */ - private static int extractToStaging(InputStream archiveStream, Path stagingDirectory, - List stagedFiles, long maxEntryBytes, long maxTotalBytes, int maxEntries, - int maxGzipRatio) throws IOException { - final CountingInputStream compressed = new CountingInputStream(archiveStream); - try (GZIPInputStream gzip = new GZIPInputStream( - new UncloseableInputStream(compressed))) { - final BudgetedInputStream tar = - new BudgetedInputStream(gzip, compressed, maxGzipRatio); - final byte[] header = new byte[TAR_BLOCK]; - int extracted = 0; - long totalExtracted = 0; - while (readBlock(tar, header)) { - if (isEndBlock(header)) { - break; - } - verifyHeaderChecksum(header); - final String name = headerName(header); - final long size = headerSize(header); - if (size > maxEntryBytes) { - throw new IOException( - "tar entry size exceeds safe limit of " + maxEntryBytes); - } - final char type = (char) header[TAR_TYPE_OFFSET]; - final String baseName = baseName(name); - // Only the archive root contains dictionary payload. Deeper files such as - // mecab-ko-dic's user-dic templates carry empty numeric fields for - // mecab-dict-index and would fail the load, or on a case-insensitive file - // system overwrite a real lexicon file of the same base name. - final boolean wanted = (type == '0' || type == 0) && pathDepth(name) <= 2 - && (baseName.endsWith(MecabDictionary.LEXICON_EXTENSION) - || baseName.endsWith(MecabDictionary.DEFINITION_EXTENSION) - || MecabDictionary.CONFIGURATION_FILE.equals(baseName)); - if (wanted) { - if (extracted >= maxEntries) { - throw new IOException( - "extracted entry count exceeds safe limit of " + maxEntries); - } - if (size > maxTotalBytes || totalExtracted > maxTotalBytes - size) { - throw new IOException( - "extracted archive size exceeds safe limit of " + maxTotalBytes); - } - final Path file = stagingDirectory.resolve(baseName); - if (Files.exists(file)) { - throw new IOException("duplicate dictionary file in archive: " + baseName); - } - stagedFiles.add(file); - try (InputStream entry = boundedStream(tar, size)) { - Files.copy(entry, file); - } - extracted++; - totalExtracted += size; - skip(tar, padding(size)); - } else { - skip(tar, size + padding(size)); - } - } - if (extracted == 0) { - throw new IOException("the archive contains no dictionary file"); - } - return extracted; + public static int installFromCatalog(DictionaryCatalog catalog, String dictionaryId, + Path targetDirectory) throws IOException { + if (catalog == null) { + throw new IllegalArgumentException("catalog must not be null"); } - } - - /** - * Fills one tar block from the stream. - * - * @param in The tar stream. - * @param block The block buffer to fill completely. - * @return {@code true} when a full block was read, {@code false} at a clean end of - * stream before any byte of the block. - * @throws IOException Thrown if the stream ends inside the block or reading fails. - */ - private static boolean readBlock(InputStream in, byte[] block) throws IOException { - int filled = 0; - while (filled < block.length) { - final int read = in.read(block, filled, block.length - filled); - if (read < 0) { - if (filled == 0) { - return false; - } - throw new IOException("truncated tar header"); - } - filled += read; + if (dictionaryId == null) { + throw new IllegalArgumentException("dictionaryId must not be null"); } - return true; - } - - /** - * Recognizes the all-zero block that terminates a tar archive. - * - * @param block The block to inspect. - * @return {@code true} when every byte is zero. - */ - private static boolean isEndBlock(byte[] block) { - for (final byte b : block) { - if (b != 0) { - return false; - } + if (targetDirectory == null) { + throw new IllegalArgumentException("targetDirectory must not be null"); } - return true; - } - - /** - * Reads the NUL-terminated entry name from a tar header block. - * - * @param header The header block. - * @return The entry name. Not {@code null}. - */ - private static String headerName(byte[] header) { - int end = 0; - while (end < TAR_NAME_LENGTH && header[end] != 0) { - end++; + final Path unpacked = createScratch(targetDirectory); + try { + catalog.install(dictionaryId, unpacked); + return promoteDictionaryFiles(unpacked, targetDirectory); + } finally { + deleteRecursively(unpacked); } - return new String(header, 0, end, StandardCharsets.UTF_8); - } - - /** - * Reads the octal entry size from a tar header block. - * - * @param header The header block. - * @return The entry size in bytes. - * @throws IOException Thrown if the size field contains a non-octal digit. - */ - private static long headerSize(byte[] header) throws IOException { - return parseOctalField(header, TAR_SIZE_OFFSET, TAR_SIZE_LENGTH, "size"); } /** - * Verifies the checksum stored in a tar header. + * Creates the scratch directory the archive unpacks into. It lives beneath the target + * so the download, the unpacked tree, and the installed files share one filesystem + * and a large dictionary cannot fill the system temporary directory. Scratch + * directories that an earlier installation left behind, because its process ended + * before cleanup, are removed first. * - * @param header The header block. - * @throws IOException Thrown if the checksum is malformed or does not match. + * @param targetDirectory The directory to install into; created when absent. + * @return The new scratch directory. Not {@code null}. + * @throws IOException Thrown if a directory cannot be created or a stale one removed. */ - private static void verifyHeaderChecksum(byte[] header) throws IOException { - final long expected = parseOctalField( - header, TAR_CHECKSUM_OFFSET, TAR_CHECKSUM_LENGTH, "checksum"); - long actual = 0; - for (int i = 0; i < header.length; i++) { - actual += i >= TAR_CHECKSUM_OFFSET && i < TAR_CHECKSUM_OFFSET + TAR_CHECKSUM_LENGTH - ? TAR_CHECKSUM_SPACE : header[i] & 0xFF; + private static Path createScratch(Path targetDirectory) throws IOException { + Files.createDirectories(targetDirectory); + final List stale; + try (Stream entries = Files.list(targetDirectory)) { + stale = entries.filter(entry -> entry.getFileName().toString().startsWith(SCRATCH_PREFIX) + && Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS)).toList(); } - if (expected != actual) { - throw new IOException("tar header checksum does not match"); + for (final Path entry : stale) { + deleteRecursively(entry); } + return Files.createTempDirectory(targetDirectory, SCRATCH_PREFIX); } /** - * Reads an octal numeric field from a tar header. + * Moves the dictionary payload files from an unpacked archive tree into the target + * directory, flattened to their base names. All destinations are checked before the + * first move, so a collision leaves the target unchanged. * - * @param header The header block. - * @param offset The field offset. - * @param length The field length. - * @param name The field name used in an error message. - * @return The parsed value. - * @throws IOException Thrown if the field contains a non-octal digit. + * @param unpacked The directory the archive was unpacked into. + * @param targetDirectory The directory to install into; created when absent. + * @return The number of dictionary files installed. + * @throws IOException Thrown if the tree holds no dictionary file, two entries + * flatten to the same base name, a target file already exists, or moving + * fails. */ - private static long parseOctalField(byte[] header, int offset, int length, String name) + private static int promoteDictionaryFiles(Path unpacked, Path targetDirectory) throws IOException { - long size = 0; - for (int i = offset; i < offset + length; i++) { - final byte b = header[i]; - if (b == 0 || b == ' ') { + final List candidates; + try (Stream files = Files.walk(unpacked, MAX_PAYLOAD_DEPTH)) { + candidates = files.filter(Files::isRegularFile).toList(); + } + final List payload = new ArrayList<>(); + final Set baseNames = new HashSet<>(); + for (final Path file : candidates) { + final String baseName = file.getFileName().toString(); + if (!isDictionaryFile(baseName)) { continue; } - if (b < '0' || b > '7') { - throw new IOException("malformed tar " + name + " field"); + if (!baseNames.add(baseName)) { + throw new IOException( + "the archive flattens two entries to the same name: " + baseName); } - size = size * 8 + (b - '0'); + payload.add(file); } - return size; - } - - /** - * Strips any directory prefix from an archive entry name. - * - * @param name The entry name as stored in the archive. - * @return The part after the last {@code /}, or the complete name when there is none. - */ - private static String baseName(String name) { - final int slash = name.lastIndexOf('/'); - return slash < 0 ? name : name.substring(slash + 1); - } - - /** - * Counts the path segments of a tar entry name, ignoring {@code .} segments and - * empty segments from doubled or trailing slashes. A file at the archive root has - * depth 1 bare or 2 inside the customary versioned top directory. - * - * @param name The tar entry name. - * @return The number of real path segments. - */ - private static int pathDepth(String name) { - int depth = 0; - int start = 0; - for (int i = 0; i <= name.length(); i++) { - if (i == name.length() || name.charAt(i) == '/') { - if (i > start && !(i - start == 1 && name.charAt(start) == '.')) { - depth++; - } - start = i + 1; + if (payload.isEmpty()) { + throw new IOException("the archive contains no dictionary file"); + } + for (final Path file : payload) { + final Path destination = targetDirectory.resolve(file.getFileName().toString()); + if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("target already contains: " + destination); } } - return depth; - } - - /** - * Computes the padding after an entry: tar content is stored in complete blocks. - * - * @param size The entry size in bytes. - * @return The number of padding bytes up to the next block boundary. - */ - private static long padding(long size) { - final long remainder = size % TAR_BLOCK; - return remainder == 0 ? 0 : TAR_BLOCK - remainder; + for (final Path file : payload) { + Files.move(file, targetDirectory.resolve(file.getFileName().toString())); + } + return payload.size(); } /** - * Consumes and discards an exact number of bytes from the stream. + * Recognizes the file names a {@link MecabDictionary} loads. * - * @param in The stream to read from. - * @param bytes The number of bytes to discard. - * @throws IOException Thrown if the stream ends before that many bytes were read. + * @param baseName The file name without any directory prefix. + * @return {@code true} when the name is dictionary payload. */ - private static void skip(InputStream in, long bytes) throws IOException { - long remaining = bytes; - final byte[] buffer = new byte[8192]; - while (remaining > 0) { - final int read = in.read(buffer, 0, (int) Math.min(buffer.length, remaining)); - if (read < 0) { - throw new IOException("truncated tar entry"); - } - remaining -= read; - } + private static boolean isDictionaryFile(String baseName) { + return baseName.endsWith(".csv") || baseName.endsWith(".def") + || "dicrc".equals(baseName); } /** - * Wraps the tar stream so exactly one entry's bytes are readable. + * Deletes a directory tree, deepest entries first. * - * @param in The tar stream, positioned at the entry's first byte. - * @param size The entry size in bytes. - * @return A stream reporting end of stream after that many bytes, and failing if the - * tar stream ends first. Not {@code null}; closing it leaves {@code in} - * open and positioned after the entry content. + * @param root The directory to remove. + * @throws IOException Thrown if a deletion fails. */ - private static InputStream boundedStream(InputStream in, long size) { - return new InputStream() { - private long remaining = size; - + private static void deleteRecursively(Path root) throws IOException { + Files.walkFileTree(root, new SimpleFileVisitor<>() { @Override - public int read() throws IOException { - if (remaining <= 0) { - return -1; - } - final int b = in.read(); - if (b < 0) { - throw new IOException("truncated tar entry"); - } - remaining--; - return b; + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) + throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; } @Override - public int read(byte[] buffer, int offset, int length) throws IOException { - if (remaining <= 0) { - return -1; - } - final int read = in.read(buffer, offset, (int) Math.min(length, remaining)); - if (read < 0) { - throw new IOException("truncated tar entry"); + public FileVisitResult postVisitDirectory(Path directory, IOException error) + throws IOException { + if (error != null) { + throw error; } - remaining -= read; - return read; - } - }; - } - - /** - * Counts bytes read from a delegate stream. - */ - private static final class CountingInputStream extends FilterInputStream { - - private long count; - - private CountingInputStream(InputStream in) { - super(in); - } - - private long count() { - return count; - } - - @Override - public int read() throws IOException { - final int b = super.read(); - if (b >= 0) { - count++; + Files.delete(directory); + return FileVisitResult.CONTINUE; } - return b; - } - - @Override - public int read(byte[] buffer, int offset, int length) throws IOException { - final int read = super.read(buffer, offset, length); - if (read > 0) { - count += read; - } - return read; - } - } - - /** - * Counts decompressed bytes and rejects a gzip expansion above the supplied ratio. - */ - private static final class BudgetedInputStream extends FilterInputStream { - - private final CountingInputStream compressed; - private final int maxGzipRatio; - private long decompressed; - - private BudgetedInputStream(InputStream in, CountingInputStream compressed, - int maxGzipRatio) { - super(in); - this.compressed = compressed; - this.maxGzipRatio = maxGzipRatio; - } - - @Override - public int read() throws IOException { - final int b = super.read(); - if (b >= 0) { - decompressed++; - checkRatio(); - } - return b; - } - - @Override - public int read(byte[] buffer, int offset, int length) throws IOException { - final int read = super.read(buffer, offset, length); - if (read > 0) { - decompressed += read; - checkRatio(); - } - return read; - } - - private void checkRatio() throws IOException { - final long compressedBytes = compressed.count(); - if (compressedBytes > 0 - && decompressed > (long) maxGzipRatio * compressedBytes) { - throw new IOException( - "gzip expansion ratio exceeds safe limit of " + maxGzipRatio); - } - } + }); } } diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/DictionaryCatalog.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/DictionaryCatalog.java index d0704e8c63..5e3a2de017 100644 --- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/DictionaryCatalog.java +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/DictionaryCatalog.java @@ -23,6 +23,7 @@ import java.net.URISyntaxException; import java.nio.file.Path; import java.util.Collections; +import java.util.HexFormat; import java.util.LinkedHashSet; import java.util.Properties; import java.util.Set; @@ -30,14 +31,28 @@ /** * Opt-in catalog of remote dictionary archives and companion files, loaded from * application-supplied properties containing URLs and SHA-512 digests. Fetching an - * entry requires {@link DownloadUtil#REMOTE_DOWNLOAD_PROPERTY} to be {@code true}. + * entry requires {@link #REMOTE_DOWNLOAD_PROPERTY} to be {@code true}. * * @since 3.0.0 */ public final class DictionaryCatalog { + private static final int SHA_512_HEX_LENGTH = 128; + private static final String URL_SUFFIX = ".url"; + + /** + * System property that must be {@code true} before a catalog entry may be + * fetched. Direct {@link ResourceInstaller} calls do not require it: there the + * caller already supplied the URI and digest. + */ + public static final String REMOTE_DOWNLOAD_PROPERTY = "opennlp.download.remote"; private final Properties properties; + /** + * Initializes a catalog from loaded properties. + * + * @param properties The catalog properties. + */ private DictionaryCatalog(Properties properties) { this.properties = properties; } @@ -60,13 +75,13 @@ public static DictionaryCatalog load(InputStream in) throws IOException { } /** - * {@return the catalog entry ids, in encounter order} + * {@return an unmodifiable set of catalog entry ids} */ public Set ids() { final Set ids = new LinkedHashSet<>(); for (final String key : properties.stringPropertyNames()) { - if (key.endsWith(".url")) { - ids.add(key.substring(0, key.length() - ".url".length())); + if (key.endsWith(URL_SUFFIX)) { + ids.add(key.substring(0, key.length() - URL_SUFFIX.length())); } } return Collections.unmodifiableSet(ids); @@ -77,14 +92,14 @@ public Set ids() { * * @param id The entry id, for example {@code mecab.ipadic}. * @return The entry. Never {@code null}. - * @throws IOException Thrown if the entry is incomplete or the URI is malformed. + * @throws IOException Thrown if the entry is incomplete or invalid. * @throws IllegalArgumentException Thrown if {@code id} is {@code null}. */ public Entry get(String id) throws IOException { if (id == null) { throw new IllegalArgumentException("id must not be null"); } - final String url = properties.getProperty(id + ".url"); + final String url = properties.getProperty(id + URL_SUFFIX); final String sha512 = properties.getProperty(id + ".sha512"); if (url == null || sha512 == null) { throw new IOException("unknown or incomplete dictionary catalog entry: " + id); @@ -92,35 +107,47 @@ public Entry get(String id) throws IOException { final String filename = properties.getProperty(id + ".filename"); try { return new Entry(id, new URI(url), sha512.trim(), filename); - } catch (URISyntaxException e) { - throw new IOException("malformed catalog URI for " + id, e); + } catch (URISyntaxException | IllegalArgumentException e) { + throw new IOException("invalid dictionary catalog entry: " + id, e); } } /** - * Downloads a catalog entry into {@code target} after checking that remote catalog - * downloads are enabled. + * Installs a catalog entry into {@code targetDirectory} after checking that remote + * catalog downloads are enabled. The entry is fetched, digest-verified, and unpacked + * by {@link ResourceInstaller#install(URI, Path, String)}: an archive expands into + * the directory, and a plain file is stored under its source name. * * @param id The entry id. Must not be {@code null}. - * @param target The local file to create. Must not be {@code null}. + * @param targetDirectory The directory to install into; created when absent. Must + * not be {@code null}. * @throws IOException Thrown if the property is not enabled, the entry is missing, - * or the download fails verification. + * the download fails verification, or the target already contains an + * installed file. * @throws IllegalArgumentException Thrown if a parameter is {@code null}. */ - public void download(String id, Path target) throws IOException { - if (target == null) { - throw new IllegalArgumentException("target must not be null"); + public void install(String id, Path targetDirectory) throws IOException { + if (id == null) { + throw new IllegalArgumentException("id must not be null"); + } + if (targetDirectory == null) { + throw new IllegalArgumentException("targetDirectory must not be null"); } - if (!DownloadUtil.isRemoteDownloadEnabled()) { + if (!Boolean.getBoolean(REMOTE_DOWNLOAD_PROPERTY)) { throw new IOException("remote dictionary catalog downloads are disabled; set -D" - + DownloadUtil.REMOTE_DOWNLOAD_PROPERTY + "=true to enable"); + + REMOTE_DOWNLOAD_PROPERTY + "=true to enable"); } final Entry entry = get(id); - DownloadUtil.download(entry.uri(), target, entry.sha512()); + if (entry.filename() == null) { + ResourceInstaller.install(entry.uri(), targetDirectory, entry.sha512()); + } else { + ResourceInstaller.installNamed( + entry.uri(), targetDirectory, entry.sha512(), entry.filename()); + } } /** - * One pinned remote file: a stable URL and the SHA-512 of its bytes. + * One catalog entry with a URI and the SHA-512 digest of its bytes. * * @param id The catalog id. * @param uri The absolute download URI. @@ -133,6 +160,9 @@ public record Entry(String id, URI uri, String sha512, String filename) { * @param uri The absolute download URI. Must not be {@code null}. * @param sha512 The expected SHA-512 hex digest. Must not be {@code null}. * @param filename An optional preferred local file name; may be {@code null}. + * @throws IllegalArgumentException Thrown if a required value is {@code null}, + * {@code uri} is relative, {@code sha512} is not 128 hex digits, or + * {@code filename} is not a local file name. */ public Entry { if (id == null) { @@ -141,9 +171,27 @@ public record Entry(String id, URI uri, String sha512, String filename) { if (uri == null) { throw new IllegalArgumentException("uri must not be null"); } + if (!uri.isAbsolute()) { + throw new IllegalArgumentException("uri must be absolute"); + } if (sha512 == null) { throw new IllegalArgumentException("sha512 must not be null"); } + if (sha512.length() != SHA_512_HEX_LENGTH) { + throw new IllegalArgumentException("sha512 must be 128 hex digits"); + } + try { + HexFormat.of().parseHex(sha512); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("sha512 must be 128 hex digits", e); + } + if (filename != null) { + try { + ResourceInstaller.validateSourceName(filename); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("filename must be a file name", e); + } + } } } } diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/DownloadUtil.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/DownloadUtil.java index 3e9c1c3bb3..0f6f6800d0 100644 --- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/DownloadUtil.java +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/DownloadUtil.java @@ -21,15 +21,11 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.io.OutputStream; -import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; -import java.net.URLConnection; import java.nio.charset.StandardCharsets; -import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -42,7 +38,6 @@ import java.util.Formatter; import java.util.HashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.regex.Matcher; @@ -56,9 +51,7 @@ import opennlp.tools.util.model.BaseModel; /** - * Downloads remote resources into a local path: pretrained OpenNLP models, and any - * other file fetched through {@link #download(URI, Path, String)} with an expected - * SHA-512 digest. + * This class facilitates the downloading of pretrained OpenNLP models. */ public class DownloadUtil { @@ -71,34 +64,6 @@ public class DownloadUtil { private static final String OPENNLP_DOWNLOAD_HOME = "OPENNLP_DOWNLOAD_HOME"; private static final String CHECKSUM_EXTENSION = ".sha512"; - /** - * System property that must be {@code true} before a - * {@link DictionaryCatalog} entry may be fetched. Explicit - * {@link #download(URI, Path, String)} calls do not require it: the caller already - * supplied the URI and digest. - */ - public static final String REMOTE_DOWNLOAD_PROPERTY = "opennlp.download.remote"; - - /** - * System property for overriding {@link #MAX_DOWNLOAD_BYTES}. Set at JVM startup, - * e.g. {@code -Dopennlp.download.max.bytes=2147483648} for dictionaries larger than - * the default ceiling. Falls back to the default if absent, non-numeric, or not - * positive. - */ - public static final String MAX_DOWNLOAD_BYTES_PROPERTY = "opennlp.download.max.bytes"; - - /** - * Inclusive ceiling on bytes buffered for one {@link #download(URI, Path, String)}, - * 64 MiB unless overridden via {@link #MAX_DOWNLOAD_BYTES_PROPERTY}. - */ - public static final long MAX_DOWNLOAD_BYTES = - configuredLimit(MAX_DOWNLOAD_BYTES_PROPERTY, 64L * 1024 * 1024); - - private static final int CONNECT_TIMEOUT_MS = 30_000; - private static final int READ_TIMEOUT_MS = 300_000; - private static final int SHA512_HEX_LENGTH = 128; - private static final String DOWNLOAD_SUFFIX = ".download"; - private static Map> availableModels; /** @@ -210,175 +175,6 @@ public static T downloadModel(URL url, Class type) thro } } - /** - * Downloads {@code source} into {@code target} and requires the SHA-512 digest of the - * stored bytes to equal {@code expectedSha512}. The download is written to a sibling - * temporary file and moved into place only after the digest matches. The transfer is - * capped at {@link #MAX_DOWNLOAD_BYTES}; remote {@code http} and {@code https} URIs - * additionally use connect and read timeouts. - * - * @param source The absolute URI to fetch. Must not be {@code null}. - * @param target The local file to create or replace. Must not be {@code null}. - * @param expectedSha512 The expected SHA-512 digest as 128 lowercase or uppercase hex - * digits. Must not be {@code null}. - * @throws IOException Thrown if fetching fails, the size ceiling is exceeded, or the - * digest does not match. - * @throws IllegalArgumentException Thrown if a parameter is {@code null}, {@code source} - * is not absolute, or {@code expectedSha512} is not 128 hex digits. - */ - public static void download(URI source, Path target, String expectedSha512) - throws IOException { - download(source, target, expectedSha512, MAX_DOWNLOAD_BYTES); - } - - /** - * Downloads {@code source} into {@code target} under a caller-supplied byte ceiling. - * - * @param source The absolute URI to fetch. Must not be {@code null}. - * @param target The local file to create or replace. Must not be {@code null}. - * @param expectedSha512 The expected SHA-512 digest as 128 hex digits. Must not be - * {@code null}. - * @param maxBytes The inclusive ceiling on bytes read from {@code source}. - * @throws IOException Thrown if fetching fails, {@code maxBytes} is exceeded, or the - * digest does not match. - * @throws IllegalArgumentException Thrown if a parameter is invalid, see - * {@link #download(URI, Path, String)}. - */ - static void download(URI source, Path target, String expectedSha512, long maxBytes) - throws IOException { - if (source == null) { - throw new IllegalArgumentException("source must not be null"); - } - if (target == null) { - throw new IllegalArgumentException("target must not be null"); - } - if (expectedSha512 == null) { - throw new IllegalArgumentException("expectedSha512 must not be null"); - } - if (!source.isAbsolute()) { - throw new IllegalArgumentException("source must be an absolute URI"); - } - final String normalized = normalizeSha512(expectedSha512); - final Path parent = target.getParent(); - if (parent != null) { - Files.createDirectories(parent); - } - final Path partial = target.resolveSibling(target.getFileName() + DOWNLOAD_SUFFIX); - Files.deleteIfExists(partial); - try { - long size = 0L; - final MessageDigest digest = sha512Digest(); - final URLConnection connection = open(source); - try (InputStream in = connection.getInputStream(); - DigestInputStream digester = new DigestInputStream(in, digest); - OutputStream out = Files.newOutputStream(partial)) { - final byte[] buffer = new byte[8192]; - int n; - while ((n = digester.read(buffer)) >= 0) { - size += n; - if (size > maxBytes) { - throw new IOException("download size exceeds safe limit of " + maxBytes); - } - out.write(buffer, 0, n); - } - } finally { - if (connection instanceof HttpURLConnection http) { - http.disconnect(); - } - } - final String actual = byteArrayToHexString(digest.digest()); - if (!actual.equals(normalized)) { - throw new IOException("SHA512 checksum validation failed for " + target.getFileName() - + ". Expected: " + normalized + ", but got: " + actual); - } - try { - Files.move(partial, target, StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.ATOMIC_MOVE); - } catch (AtomicMoveNotSupportedException e) { - Files.move(partial, target, StandardCopyOption.REPLACE_EXISTING); - } - } catch (IOException e) { - Files.deleteIfExists(partial); - throw e; - } - } - - /** - * {@return {@code true} when {@link #REMOTE_DOWNLOAD_PROPERTY} is the string - * {@code true}, ignoring case} - */ - public static boolean isRemoteDownloadEnabled() { - return Boolean.parseBoolean(System.getProperty(REMOTE_DOWNLOAD_PROPERTY)); - } - - /** - * Reads a byte-budget override from a system property. Budget constants are - * initialized from it once at class load, so overrides must be set at JVM startup. - * - * @param property The system property name to read. - * @param fallback The value to use when the property is absent or invalid. - * @return The property's value when it parses as a positive {@code long}, otherwise - * {@code fallback}. - */ - public static long configuredLimit(String property, long fallback) { - final String value = System.getProperty(property, "").trim(); - if (!value.isEmpty()) { - try { - final long parsed = Long.parseLong(value); - if (parsed > 0) { - return parsed; - } - } catch (NumberFormatException ignore) { - // Fall through to the default. - } - } - return fallback; - } - - /** - * Opens a connection to {@code source} with connect and read timeouts applied. - * - * @param source The absolute URI to connect to. - * @return The configured, not yet connected, connection. - * @throws IOException Thrown if no connection can be created for {@code source}. - */ - private static URLConnection open(URI source) throws IOException { - final URLConnection connection = source.toURL().openConnection(); - connection.setConnectTimeout(CONNECT_TIMEOUT_MS); - connection.setReadTimeout(READ_TIMEOUT_MS); - return connection; - } - - /** - * Trims and lowercases a SHA-512 hex digest. - * - * @param expectedSha512 The digest to normalize. - * @return The digest as 128 lowercase hex digits. - * @throws IllegalArgumentException Thrown if the digest is not 128 hex digits. - */ - private static String normalizeSha512(String expectedSha512) { - final String hex = expectedSha512.trim().toLowerCase(Locale.ROOT); - if (hex.length() != SHA512_HEX_LENGTH || !hex.chars().allMatch( - c -> c >= '0' && c <= '9' || c >= 'a' && c <= 'f')) { - throw new IllegalArgumentException( - "expectedSha512 must be 128 hexadecimal digits"); - } - return hex; - } - - /** - * {@return a fresh SHA-512 {@link MessageDigest}} - * - * @throws IOException Thrown if the JVM does not provide the algorithm. - */ - private static MessageDigest sha512Digest() throws IOException { - try { - return MessageDigest.getInstance("SHA-512"); - } catch (NoSuchAlgorithmException e) { - throw new IOException("SHA-512 algorithm not found", e); - } - } - public static Map> getAvailableModels() { if (availableModels == null) { try { diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/ResourceInstaller.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/ResourceInstaller.java new file mode 100644 index 0000000000..54b215b6e3 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/ResourceInstaller.java @@ -0,0 +1,1558 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.util; + +import java.io.BufferedInputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.file.FileSystem; +import java.nio.file.FileSystemNotFoundException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.ProviderNotFoundException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.Comparator; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import java.util.zip.GZIPInputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipFile; +import java.util.zip.ZipInputStream; + +import opennlp.tools.util.archive.TarStream; + +/** + * Fetches a third-party resource, such as a training corpus, a dictionary archive, or a + * lexicon, into a local directory. The caller supplies the location and thereby accepts + * that resource's license; no locations are built in and no data is bundled. Only + * {@code http}, {@code https}, and {@code file} locations are accepted. + * + *

A checksum is required for http and https sources and optional for file sources. + * It is verified against the downloaded bytes before anything is unpacked: a + * 64-character hex digest selects SHA-256, a 128-character one SHA-512. + * The content format is detected from the bytes, not from the name: gzip-compressed + * tar archives and zip archives are unpacked with their relative structure. Invalid, + * escaping, and duplicate file paths are rejected. Plain gzip files are decompressed, + * and other content is stored as a file under the source name. One name rule overrides + * byte detection: a {@code *.bin} source is always stored packed because an OpenNLP + * model file is itself a zip archive that its consumers load packed.

+ * + *

Each installation is bounded by {@link Limits}: http and https fetches use + * connection and read timeouts, follow at most a fixed number of redirects, reject + * redirects that leave the http and https schemes or downgrade https to http, and + * abort once the download or the expanded content crosses its size limit or the + * archive crosses its entry limit. Compressed content, gzip and zip alike, may + * expand to at most {@link Limits#maxExpansionRatio()} times its compressed size, + * with a floor of {@value #MIN_EXPANSION_BYTES} bytes for small sources. The defaults in + * {@link Limits#DEFAULT} apply when no limits are given, and {@link Limits#builder()} + * starts from them.

+ * + *

Installation is staged: content is unpacked into a hidden staging directory on + * the same filesystem and moved into the target only after the download was verified + * and every entry unpacked cleanly. A fetch, verification, or unpacking failure + * promotes no files into the target directory. + * Promotion does not replace a file that already exists in the target and detects + * the collision before moving anything, so refreshing a resource means removing its + * old files first. Work files left in the target by an installation that was killed + * are removed at the start of the next installation into that target, so concurrent + * installations into one target directory are not supported.

+ * + * @see DownloadUtil + * @since 3.0.0 + */ +public final class ResourceInstaller { + + private static final String SHA_256 = "SHA-256"; + private static final String SHA_512 = "SHA-512"; + private static final int SHA_256_HEX_LENGTH = 64; + private static final int SHA_512_HEX_LENGTH = 128; + private static final String GZIP_SUFFIX = ".gz"; + + /** OpenNLP model files are packed zip archives; install them packed. */ + private static final String MODEL_SUFFIX = ".bin"; + private static final String DEFAULT_RESOURCE_NAME = "resource"; + private static final String STAGING_PREFIX = ".opennlp-staging"; + private static final String DOWNLOAD_PREFIX = ".opennlp-download"; + private static final String DOWNLOAD_SUFFIX = ".part"; + private static final int BUFFER_SIZE = 8192; + private static final int MAGIC_LENGTH = 4; + private static final int GZIP_MAGIC_FIRST = 0x1F; + private static final int GZIP_MAGIC_SECOND = 0x8B; + private static final int ZIP_MAGIC_FIRST = 'P'; + private static final int ZIP_MAGIC_SECOND = 'K'; + private static final int ZIP_LOCAL_HEADER_THIRD = 3; + private static final int ZIP_LOCAL_HEADER_FOURTH = 4; + private static final int ZIP_END_HEADER_THIRD = 5; + private static final int ZIP_END_HEADER_FOURTH = 6; + private static final int ZIP_END_HEADER_LENGTH = 22; + private static final int ZIP_DISK_OFFSET = 4; + private static final int ZIP_CENTRAL_DISK_OFFSET = 6; + private static final int ZIP_DISK_ENTRIES_OFFSET = 8; + private static final int ZIP_TOTAL_ENTRIES_OFFSET = 10; + private static final int ZIP_CENTRAL_SIZE_OFFSET = 12; + private static final int ZIP_CENTRAL_OFFSET_OFFSET = 16; + private static final int ZIP_COMMENT_LENGTH_OFFSET = 20; + private static final int TAR_END_BLOCKS_LENGTH = 1024; + + /** The expanded size every compressed source may reach regardless of the ratio. */ + private static final long MIN_EXPANSION_BYTES = 1L << 20; + private static final int HTTP_TEMPORARY_REDIRECT = 307; + private static final int HTTP_PERMANENT_REDIRECT = 308; + private static final String SCHEME_HTTP = "http"; + private static final String SCHEME_HTTPS = "https"; + private static final String SCHEME_FILE = "file"; + private static final String MALFORMED_ZIP_ERROR = "malformed zip archive"; + private static final String ZIP_MISMATCH_ERROR = + "zip local headers and central directory list different files"; + + /** + * Safety limits and network behavior for one installation. + * + * @param connectTimeout How long to wait for a connection to be established. Must + * be positive. + * @param readTimeout How long to wait for data on an established connection. Must + * be positive. + * @param maxRedirects How many http redirects to follow before failing. Must not be + * negative; zero rejects all redirects. + * @param maxDownloadBytes The largest download accepted, in bytes. Must be positive. + * @param maxExpandedBytes The largest expanded byte count accepted. For gzip content, + * this counts the entire decompressed stream; otherwise, it + * counts installed file content. Must be positive. + * @param maxEntries The largest number of archive entries accepted, counting every + * entry including directories, so an archive of many tiny files + * cannot exhaust directory entries while staying under the byte + * limits. Must be positive. + * @param maxExpansionRatio The largest expanded size accepted per compressed byte of + * a source, so a small archive cannot expand to the whole + * expansion limit. Applies to gzip and zip content; a source + * may always expand to {@value #MIN_EXPANSION_BYTES} bytes + * regardless of it. Must be positive. + */ + public record Limits(Duration connectTimeout, Duration readTimeout, int maxRedirects, + long maxDownloadBytes, long maxExpandedBytes, long maxEntries, + long maxExpansionRatio) { + + /** The system property overriding the default download limit in bytes. */ + public static final String MAX_DOWNLOAD_BYTES_PROPERTY = "opennlp.download.max.bytes"; + + /** The system property overriding the default expansion limit in bytes. */ + public static final String MAX_EXPANDED_BYTES_PROPERTY = + "opennlp.install.max.total.bytes"; + + /** The system property overriding the default archive entry limit. */ + public static final String MAX_ENTRIES_PROPERTY = "opennlp.install.max.entries"; + + /** The system property overriding the default expansion ratio. */ + public static final String MAX_EXPANSION_RATIO_PROPERTY = + "opennlp.install.max.expansion.ratio"; + + /** + * The limits applied when none are given: 20 second connect timeout, 60 second + * read timeout, at most 5 redirects, a 1 GiB download limit, a 4 GiB expansion + * limit, 100000 archive entries, and an expansion ratio of 100. Each limit can be + * raised or lowered at startup through its system property + * ({@link #MAX_DOWNLOAD_BYTES_PROPERTY}, {@link #MAX_EXPANDED_BYTES_PROPERTY}, + * {@link #MAX_ENTRIES_PROPERTY}, {@link #MAX_EXPANSION_RATIO_PROPERTY}), read once + * at class load; a value that is absent, not a number, or not positive falls back + * to the built-in default. + */ + public static final Limits DEFAULT = new Limits(Duration.ofSeconds(20), + Duration.ofSeconds(60), 5, + longProperty(MAX_DOWNLOAD_BYTES_PROPERTY, 1L << 30), + longProperty(MAX_EXPANDED_BYTES_PROPERTY, 4L << 30), + longProperty(MAX_ENTRIES_PROPERTY, 100_000L), + longProperty(MAX_EXPANSION_RATIO_PROPERTY, 100L)); + + /** + * Reads a limit override from a system property, trimmed before parsing. + * + * @param name The property name. + * @param fallback The built-in default. + * @return The property's value, or {@code fallback} when the property is absent, + * not a number, or not positive. + */ + static long longProperty(String name, long fallback) { + final String value = System.getProperty(name); + if (value == null) { + return fallback; + } + final long parsed; + try { + parsed = Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return fallback; + } + return parsed > 0 ? parsed : fallback; + } + + /** + * Validates the limit values before constructing an instance. + * + * @param connectTimeout How long to wait for a connection to be established. + * @param readTimeout How long to wait for data on an established connection. + * @param maxRedirects How many http redirects to follow before failing. + * @param maxDownloadBytes The largest download accepted, in bytes. + * @param maxExpandedBytes The largest expanded byte count accepted. + * @param maxEntries The largest number of archive entries accepted. + * @param maxExpansionRatio The largest expanded size accepted per compressed byte. + * @throws IllegalArgumentException Thrown if either timeout is {@code null}, zero, + * or negative, a limit is not positive, or the redirect limit is + * negative. + */ + public Limits(Duration connectTimeout, Duration readTimeout, int maxRedirects, + long maxDownloadBytes, long maxExpandedBytes, long maxEntries, + long maxExpansionRatio) { + if (connectTimeout == null) { + throw new IllegalArgumentException("connectTimeout must not be null"); + } + if (connectTimeout.isZero() || connectTimeout.isNegative()) { + throw new IllegalArgumentException("connectTimeout must be positive"); + } + if (readTimeout == null) { + throw new IllegalArgumentException("readTimeout must not be null"); + } + if (readTimeout.isZero() || readTimeout.isNegative()) { + throw new IllegalArgumentException("readTimeout must be positive"); + } + if (maxRedirects < 0) { + throw new IllegalArgumentException("maxRedirects must not be negative"); + } + if (maxDownloadBytes <= 0) { + throw new IllegalArgumentException("maxDownloadBytes must be positive"); + } + if (maxExpandedBytes <= 0) { + throw new IllegalArgumentException("maxExpandedBytes must be positive"); + } + if (maxEntries <= 0) { + throw new IllegalArgumentException("maxEntries must be positive"); + } + if (maxExpansionRatio <= 0) { + throw new IllegalArgumentException("maxExpansionRatio must be positive"); + } + this.connectTimeout = connectTimeout; + this.readTimeout = readTimeout; + this.maxRedirects = maxRedirects; + this.maxDownloadBytes = maxDownloadBytes; + this.maxExpandedBytes = maxExpandedBytes; + this.maxEntries = maxEntries; + this.maxExpansionRatio = maxExpansionRatio; + } + + /** + * Starts from {@link #DEFAULT} so a caller can state only the limits that differ + * from it, instead of repeating all seven in the canonical constructor. + * + * @return A builder holding the default limits. Not {@code null}. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Collects limit values and validates them on {@link #build()}. Each setter returns + * this builder. Not thread safe; the {@link Limits} it builds is immutable. + */ + public static final class Builder { + + private Duration connectTimeout = DEFAULT.connectTimeout(); + private Duration readTimeout = DEFAULT.readTimeout(); + private int maxRedirects = DEFAULT.maxRedirects(); + private long maxDownloadBytes = DEFAULT.maxDownloadBytes(); + private long maxExpandedBytes = DEFAULT.maxExpandedBytes(); + private long maxEntries = DEFAULT.maxEntries(); + private long maxExpansionRatio = DEFAULT.maxExpansionRatio(); + + /** Initializes a builder with {@link Limits#DEFAULT}. */ + private Builder() { + } + + /** + * Sets how long to wait for a connection to be established. + * + * @param connectTimeout How long to wait for a connection to be established. + * Must be positive. + * @return This builder. Not {@code null}. + */ + public Builder connectTimeout(Duration connectTimeout) { + this.connectTimeout = connectTimeout; + return this; + } + + /** + * Sets how long to wait for data on an established connection. + * + * @param readTimeout How long to wait for data on an established connection. + * Must be positive. + * @return This builder. Not {@code null}. + */ + public Builder readTimeout(Duration readTimeout) { + this.readTimeout = readTimeout; + return this; + } + + /** + * Sets how many http redirects to follow before failing. + * + * @param maxRedirects How many http redirects to follow before failing. Must not + * be negative; zero rejects all redirects. + * @return This builder. Not {@code null}. + */ + public Builder maxRedirects(int maxRedirects) { + this.maxRedirects = maxRedirects; + return this; + } + + /** + * Sets the largest download accepted. + * + * @param maxDownloadBytes The largest download accepted, in bytes. Must be + * positive. + * @return This builder. Not {@code null}. + */ + public Builder maxDownloadBytes(long maxDownloadBytes) { + this.maxDownloadBytes = maxDownloadBytes; + return this; + } + + /** + * Sets the largest expanded byte count accepted. + * + * @param maxExpandedBytes The largest expanded byte count accepted. For gzip + * content, this counts the entire decompressed stream; + * otherwise, it counts installed file content. Must be + * positive. + * @return This builder. Not {@code null}. + */ + public Builder maxExpandedBytes(long maxExpandedBytes) { + this.maxExpandedBytes = maxExpandedBytes; + return this; + } + + /** + * Sets the largest number of archive entries accepted. + * + * @param maxEntries The largest number of archive entries accepted, counting + * every entry including directories. Must be positive. + * @return This builder. Not {@code null}. + */ + public Builder maxEntries(long maxEntries) { + this.maxEntries = maxEntries; + return this; + } + + /** + * Sets the largest expanded size accepted per compressed byte of a source. + * + * @param maxExpansionRatio The largest expanded size accepted per compressed + * byte of a source. Must be positive. + * @return This builder. Not {@code null}. + */ + public Builder maxExpansionRatio(long maxExpansionRatio) { + this.maxExpansionRatio = maxExpansionRatio; + return this; + } + + /** + * Builds the limits. + * + * @return The limits collected so far. Not {@code null}. + * @throws IllegalArgumentException Thrown if any value is outside its documented + * range. + */ + public Limits build() { + return new Limits(connectTimeout, readTimeout, maxRedirects, maxDownloadBytes, + maxExpandedBytes, maxEntries, maxExpansionRatio); + } + } + } + + /** Prevents construction of this utility class. */ + private ResourceInstaller() { + } + + /** + * Unpacks a resource without checksum verification, under {@link Limits#DEFAULT}. + * This overload treats the source as trusted caller input and performs no + * cryptographic integrity verification, so it accepts only {@code file} sources; an + * http or https source must go through an overload that takes its checksum. + * + * @param source The resource location, a {@code file} URI. Not {@code null}. + * @param targetDirectory The directory to install into; created when absent. Must + * not be {@code null}. + * @return The target directory. Not {@code null}. + * @throws IOException Thrown if fetching or unpacking fails. + * @throws IllegalArgumentException Thrown if {@code source} or + * {@code targetDirectory} is {@code null}, {@code source} contains a scheme + * other than {@code file}, or its last path segment is not a valid local file + * name. + */ + public static Path install(URI source, Path targetDirectory) throws IOException { + return install(source, targetDirectory, null); + } + + /** + * Fetches, verifies, and unpacks a resource under {@link Limits#DEFAULT}. + * + * @param source The resource location, an {@code http}, {@code https}, or + * {@code file} URI. Not {@code null}. + * @param targetDirectory The directory to install into; created when absent. Must + * not be {@code null}. + * @param checksum The expected digest of the downloaded bytes as a hex string, + * compared case-insensitively and ignoring leading and trailing + * whitespace: 64 characters select SHA-256, 128 characters SHA-512. + * Required for an http or https source; pass {@code null} to skip + * verification for a {@code file} source. + * @return The target directory. Not {@code null}. + * @throws IOException Thrown if fetching fails, the checksum does not match, or + * unpacking fails. + * @throws IllegalArgumentException Thrown if {@code source} or + * {@code targetDirectory} is {@code null}, {@code source} contains a scheme + * other than {@code http}, {@code https}, or {@code file}, {@code checksum} + * is not a 64-character or 128-character hex string, an http or https source + * contains no checksum, or the source does not provide a valid local file + * name. + */ + public static Path install(URI source, Path targetDirectory, String checksum) + throws IOException { + return install(source, targetDirectory, checksum, Limits.DEFAULT); + } + + /** + * Fetches, verifies, and unpacks a resource under the given {@link Limits}. + * + * @param source The resource location, an {@code http}, {@code https}, or + * {@code file} URI. Not {@code null}. + * @param targetDirectory The directory to install into; created when absent. Must + * not be {@code null}. + * @param checksum The expected digest of the downloaded bytes as a hex string, + * compared case-insensitively and ignoring leading and trailing + * whitespace: 64 characters select SHA-256, 128 characters SHA-512. + * Required for an http or https source; pass {@code null} to skip + * verification for a {@code file} source. + * @param limits The timeouts, redirect allowance, and size and entry limits to + * enforce. Not {@code null}. + * @return The target directory. Not {@code null}. + * @throws IOException Thrown if fetching fails, a limit is exceeded, the checksum + * does not match, or unpacking fails. + * @throws IllegalArgumentException Thrown if {@code source}, {@code targetDirectory}, + * or {@code limits} is {@code null}, {@code source} contains a scheme other + * than {@code http}, {@code https}, or {@code file}, {@code checksum} is + * not a 64-character or 128-character hex string, an http or https source + * contains no checksum, or the source does not provide a valid local file + * name. + */ + public static Path install(URI source, Path targetDirectory, String checksum, + Limits limits) throws IOException { + return install(source, targetDirectory, checksum, null, limits); + } + + /** + * Installs a catalog entry under its preferred file name when it is not an archive. + * + * @param source The resource location. + * @param targetDirectory The directory to install into. + * @param checksum The expected digest, or {@code null} for a file source. + * @param name The preferred name for non-archive content. + * @return The target directory. + * @throws IOException Thrown if fetching, verification, or unpacking fails. + * @throws IllegalArgumentException Thrown if an argument is invalid. + */ + static Path installNamed(URI source, Path targetDirectory, String checksum, + String name) throws IOException { + if (name == null) { + throw new IllegalArgumentException("name must not be null"); + } + return install(source, targetDirectory, checksum, name, Limits.DEFAULT); + } + + /** + * Validates the request, downloads and verifies the resource, and installs it from a + * staging directory. + * + * @param source The resource location. + * @param targetDirectory The directory to install into. + * @param checksum The expected digest, or {@code null} for a file source. + * @param name The preferred name for non-archive content, or {@code null} to use the + * source name. + * @param limits The limits to enforce. + * @return The target directory. Not {@code null}. + * @throws IOException Thrown if fetching, verification, or installation fails. + * @throws IllegalArgumentException Thrown if an argument is invalid. + */ + private static Path install(URI source, Path targetDirectory, String checksum, + String name, Limits limits) throws IOException { + if (source == null) { + throw new IllegalArgumentException("source must not be null"); + } + if (targetDirectory == null) { + throw new IllegalArgumentException("targetDirectory must not be null"); + } + if (limits == null) { + throw new IllegalArgumentException("limits must not be null"); + } + validateSource(source); + final String expected = validateChecksum(checksum); + if (expected == null && isHttp(source.getScheme())) { + throw new IllegalArgumentException( + "checksum must be given for an http or https source: " + source); + } + final String resourceName = validateSourceName( + name == null ? sourceName(source) : name); + final boolean createdTarget = Files.notExists(targetDirectory); + Files.createDirectories(targetDirectory); + removeStaleWorkFiles(targetDirectory); + final Path downloaded = createDownloadFile(targetDirectory); + try { + download(source, downloaded, limits); + if (expected != null) { + verify(downloaded, expected); + } + installStaged(downloaded, resourceName, targetDirectory, limits); + return targetDirectory; + } catch (IOException e) { + Files.deleteIfExists(downloaded); + if (createdTarget) { + removeIfEmpty(targetDirectory, e); + } + throw e; + } finally { + Files.deleteIfExists(downloaded); + } + } + + /** + * Removes download files and staging directories that an earlier installation left in + * the target because its process ended before cleanup. Only entries with the hidden + * work-file prefixes are touched. + * + * @param targetDirectory The directory to install into. + * @throws IOException Thrown if listing or deleting fails. + */ + private static void removeStaleWorkFiles(Path targetDirectory) throws IOException { + final List stale; + try (Stream entries = Files.list(targetDirectory)) { + stale = entries.filter(ResourceInstaller::isWorkFile).toList(); + } + for (final Path entry : stale) { + if (Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS)) { + deleteRecursively(entry); + } else { + Files.deleteIfExists(entry); + } + } + } + + /** + * Classifies a target directory entry as a work file of this class. + * + * @param entry The entry to inspect. + * @return {@code true} if the entry name carries a work-file prefix. + */ + private static boolean isWorkFile(Path entry) { + final String fileName = entry.getFileName().toString(); + return fileName.startsWith(STAGING_PREFIX) || fileName.startsWith(DOWNLOAD_PREFIX); + } + + /** + * Removes a target directory this installation created when the failed installation + * left nothing in it, so a failed first attempt leaves the filesystem as it was. + * + * @param targetDirectory The directory this installation created. + * @param failure The failure being reported; a cleanup error is added to it. + */ + private static void removeIfEmpty(Path targetDirectory, IOException failure) { + try (Stream entries = Files.list(targetDirectory)) { + if (entries.findAny().isEmpty()) { + Files.deleteIfExists(targetDirectory); + } + } catch (IOException cleanup) { + failure.addSuppressed(cleanup); + } + } + + /** + * Creates the file the download is written to. It is placed on the target's + * filesystem, not in the system temporary directory, so a large download cannot + * exhaust the system temporary directory while the target has room. Its hidden prefix + * distinguishes it from installed content. + * + * @param targetDirectory The directory to install into. Must already exist. + * @return The newly created, empty download file. Not {@code null}. + * @throws IOException Thrown if the file cannot be created. + */ + static Path createDownloadFile(Path targetDirectory) throws IOException { + return Files.createTempFile(targetDirectory, DOWNLOAD_PREFIX, DOWNLOAD_SUFFIX); + } + + /** + * Validates the checksum argument and normalizes it for comparison. + * + * @param checksum The digest as given by the caller, or {@code null} to skip. + * @return The stripped digest, or {@code null} when verification is skipped. + * @throws IllegalArgumentException Thrown if the digest is not a 64-character or + * 128-character hex string. + */ + private static String validateChecksum(String checksum) { + if (checksum == null) { + return null; + } + final String trimmed = checksum.strip(); + if ((trimmed.length() == SHA_256_HEX_LENGTH || trimmed.length() == SHA_512_HEX_LENGTH) + && isHex(trimmed)) { + return trimmed; + } + throw new IllegalArgumentException( + "checksum must be 64 (SHA-256) or 128 (SHA-512) hex characters; pass null to skip"); + } + + /** + * Checks whether a string is made up entirely of hexadecimal digits. + * + * @param value The string to inspect. + * @return {@code true} if every character is a hexadecimal digit. + */ + private static boolean isHex(String value) { + for (int i = 0; i < value.length(); i++) { + final char c = value.charAt(i); + final boolean digit = c >= '0' && c <= '9'; + final boolean lower = c >= 'a' && c <= 'f'; + final boolean upper = c >= 'A' && c <= 'F'; + if (!digit && !lower && !upper) { + return false; + } + } + return true; + } + + /** + * Fetches the source into the given file, bounded by the download limit. Http and + * https locations are fetched with timeouts and the redirect policy; a {@code file} + * location is read directly. The scheme was accepted by {@link #validateSource(URI)} + * at the public boundary. + * + * @param source The resource location. + * @param file The file receiving the downloaded bytes. + * @param limits The limits to enforce. + * @throws IOException Thrown if fetching fails or a limit is exceeded. + */ + private static void download(URI source, Path file, Limits limits) throws IOException { + final Budget budget = new Budget(limits.maxDownloadBytes(), + "download exceeds the limit of " + limits.maxDownloadBytes() + " bytes"); + if (isHttp(source.getScheme())) { + downloadHttp(source, file, limits, budget); + } else { + try (InputStream in = Files.newInputStream(localFile(source))) { + copyBounded(in, file, budget); + } + } + } + + /** + * Rejects a source the installer will not fetch. Only {@code http}, {@code https}, and + * {@code file} are accepted: any other scheme would be passed to any URL handler + * the runtime happens to have installed, outside the connection timeout, read timeout, + * and redirect policy this class enforces. + * + * @param source The resource location as given by the caller. + * @throws IllegalArgumentException Thrown if the scheme is absent or unsupported. + */ + private static void validateSource(URI source) { + final String scheme = source.getScheme(); + if (!isHttp(scheme) && !SCHEME_FILE.equalsIgnoreCase(scheme)) { + throw new IllegalArgumentException( + "source scheme must be http, https, or file, but was: " + source); + } + } + + /** + * Classifies a scheme as one the http fetch path handles. + * + * @param scheme The URI scheme, or {@code null} when the location has none. + * @return {@code true} for {@code http} and {@code https}, ignoring case. + */ + private static boolean isHttp(String scheme) { + return SCHEME_HTTP.equalsIgnoreCase(scheme) || SCHEME_HTTPS.equalsIgnoreCase(scheme); + } + + /** + * Resolves a {@code file} location to a path on the default filesystem. + * + * @param source The {@code file} location, already validated as such. + * @return The local path. Not {@code null}. + * @throws IOException Thrown if the location does not name a file this runtime can + * open, such as a {@code file} URI naming a remote host. + */ + private static Path localFile(URI source) throws IOException { + try { + return Path.of(source); + } catch (IllegalArgumentException | FileSystemNotFoundException e) { + throw new IOException("not a readable local file location: " + source, e); + } + } + + /** + * Fetches an http or https source with connection and read timeouts, following at + * most the allowed number of redirects under the redirect policy, checking any + * declared content length against the download limit before reading the body, and + * bounding the transferred bytes against the same limit. + * + * @param source The resource location as requested by the caller. + * @param file The file receiving the downloaded bytes. + * @param limits The limits to enforce. + * @param budget The download budget shared with the caller. + * @throws IOException Thrown if fetching fails, the server answers with a status + * other than 200, the redirect policy is violated, or a limit is exceeded. + */ + private static void downloadHttp(URI source, Path file, Limits limits, Budget budget) + throws IOException { + URI current = source; + int redirects = 0; + while (true) { + final HttpURLConnection connection = + (HttpURLConnection) current.toURL().openConnection(); + connection.setInstanceFollowRedirects(false); + connection.setConnectTimeout(timeoutMillis(limits.connectTimeout())); + connection.setReadTimeout(timeoutMillis(limits.readTimeout())); + try { + final int status = connection.getResponseCode(); + if (isRedirect(status)) { + if (redirects >= limits.maxRedirects()) { + throw new IOException( + "more than " + limits.maxRedirects() + " redirects: " + source); + } + current = resolveRedirect(current, connection.getHeaderField("Location")); + redirects++; + continue; + } + if (status != HttpURLConnection.HTTP_OK) { + throw new IOException( + "download failed with HTTP status " + status + ": " + current); + } + final long declared = connection.getContentLengthLong(); + if (declared > limits.maxDownloadBytes()) { + throw new IOException("declared content length " + declared + + " exceeds the download limit of " + limits.maxDownloadBytes() + + " bytes"); + } + try (InputStream in = connection.getInputStream()) { + copyBounded(in, file, budget); + } + return; + } finally { + connection.disconnect(); + } + } + } + + /** + * Classifies a response status as a redirect the installer follows. + * + * @param status The HTTP response status. + * @return {@code true} if the status is one of the redirect statuses 301, 302, 303, + * 307, or 308. + */ + private static boolean isRedirect(int status) { + return status == HttpURLConnection.HTTP_MOVED_PERM + || status == HttpURLConnection.HTTP_MOVED_TEMP + || status == HttpURLConnection.HTTP_SEE_OTHER + || status == HTTP_TEMPORARY_REDIRECT + || status == HTTP_PERMANENT_REDIRECT; + } + + /** + * Resolves a redirect location against the redirected request and enforces the + * redirect policy: the target must be an http or https location, and an https + * request must not be redirected to plain http. + * + * @param from The location that returned the redirect. + * @param location The Location header value, absolute or relative, or {@code null} + * when the header is absent. + * @return The resolved redirect target. Not {@code null}. + * @throws IOException Thrown if the location is absent or malformed, leaves the + * http and https schemes, or downgrades https to http. + */ + static URI resolveRedirect(URI from, String location) throws IOException { + if (location == null || location.isEmpty()) { + throw new IOException("redirect from " + from + " contains no Location header"); + } + final URI target; + try { + target = from.resolve(location); + } catch (IllegalArgumentException e) { + throw new IOException( + "redirect from " + from + " contains a malformed Location: " + location, e); + } + final String scheme = target.getScheme(); + final boolean https = SCHEME_HTTPS.equalsIgnoreCase(scheme); + if (!https && !SCHEME_HTTP.equalsIgnoreCase(scheme)) { + throw new IOException( + "redirect target is not an http or https location: " + target); + } + if (SCHEME_HTTPS.equalsIgnoreCase(from.getScheme()) && !https) { + throw new IOException("redirect downgrades https to http: " + target); + } + return target; + } + + /** + * Converts a timeout to the millisecond form the connection setters take. A positive + * timeout shorter than a millisecond becomes one millisecond because + * {@link HttpURLConnection#setReadTimeout(int) zero disables the timeout}. A timeout + * too large for the int range is capped. + * + * @param timeout The timeout as a duration. Must be positive. + * @return The timeout in milliseconds, at least {@code 1} and at most + * {@link Integer#MAX_VALUE}. + */ + private static int timeoutMillis(Duration timeout) { + final long millis; + try { + millis = timeout.toMillis(); + } catch (ArithmeticException e) { + return Integer.MAX_VALUE; + } + return Math.clamp(millis, 1, Integer.MAX_VALUE); + } + + /** + * Computes the file's digest and compares it with the expected hex digest, ignoring + * hex letter case. The digest length selects the algorithm: 64 characters SHA-256, + * 128 characters SHA-512. + * + * @param file The file to digest. + * @param expected The expected hex digest, already trimmed. + * @throws IOException Thrown if the file cannot be read or the digests differ. + */ + private static void verify(Path file, String expected) throws IOException { + final String algorithm = + expected.length() == SHA_512_HEX_LENGTH ? SHA_512 : SHA_256; + final MessageDigest digest; + try { + digest = MessageDigest.getInstance(algorithm); + } catch (NoSuchAlgorithmException e) { + throw new IOException(algorithm + " is unavailable in this runtime", e); + } + try (InputStream in = Files.newInputStream(file)) { + final byte[] buffer = new byte[BUFFER_SIZE]; + int read; + while ((read = in.read(buffer)) >= 0) { + digest.update(buffer, 0, read); + } + } + final String actual = HexFormat.of().formatHex(digest.digest()); + if (!actual.equalsIgnoreCase(expected)) { + throw new IOException( + "checksum mismatch: expected " + expected + " but downloaded " + actual); + } + } + + /** + * Unpacks the downloaded content into a hidden staging directory beneath the target + * and promotes it into the target only after every entry unpacked cleanly. The + * staging directory lives on the target's filesystem so promotion is a sequence of + * renames, and it is removed whether the installation succeeds or fails. + * + * @param downloaded The fetched and verified file. + * @param name The file name derived from the source location. + * @param target The directory to install into. + * @param limits The limits to enforce while unpacking. + * @throws IOException Thrown if unpacking fails, a limit is exceeded, or promotion + * or staging cleanup fails. + */ + private static void installStaged(Path downloaded, String name, Path target, + Limits limits) throws IOException { + final Path staging = Files.createTempDirectory(target, STAGING_PREFIX); + try { + unpack(downloaded, name, staging, limits); + promote(staging, target); + } catch (IOException e) { + try { + deleteRecursively(staging); + } catch (IOException cleanup) { + e.addSuppressed(cleanup); + } + throw e; + } + deleteRecursively(staging); + } + + /** + * Moves all staged regular files to their relative locations beneath the target + * without replacing anything that already exists there. All destinations are + * checked before the first move, so a collision leaves the target without a mix of + * old and new files, and the move itself refuses an existing destination as well. + * + * @param staging The staging directory holding the fully unpacked content. + * @param target The directory to install into. + * @throws IOException Thrown if a destination already exists, a move fails, or a + * directory on the way to a destination is an existing symbolic link. + */ + private static void promote(Path staging, Path target) throws IOException { + final List files; + try (Stream walk = Files.walk(staging)) { + files = walk.filter(Files::isRegularFile).toList(); + } + for (final Path file : files) { + ensureVacant(target, staging.relativize(file)); + } + for (final Path file : files) { + moveIntoPlace(file, destination(target, staging.relativize(file))); + } + } + + /** + * Moves one staged file to its destination without replacing an existing file. The + * move is not requested atomically: on POSIX filesystems an atomic move renames over + * an existing destination, which would void the vacancy check. + * + * @param file The staged file. + * @param destination The destination beneath the target. + * @throws IOException Thrown if the destination exists or the move fails. + */ + static void moveIntoPlace(Path file, Path destination) throws IOException { + Files.move(file, destination); + } + + /** + * Checks that one staged file's destination is free to receive it, without creating + * anything. A missing directory on the way proves the destination vacant. + * + * @param target The directory to install into. + * @param relative The staged file's path relative to the staging directory. + * @throws IOException Thrown if the destination already exists, or a directory on + * the way is a symbolic link or exists as something other than a directory. + */ + private static void ensureVacant(Path target, Path relative) throws IOException { + final Path destination = destination(target, relative, false); + if (destination != null && Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("target already contains: " + destination); + } + } + + /** + * Resolves one staged file's destination beneath the target, creating the directories + * leading to it one at a time. + * + * @param target The directory to install into. + * @param relative The staged file's path relative to the staging directory. + * @return The destination path beneath the target. Not {@code null}. + * @throws IOException Thrown if a directory on the way is a symbolic link or exists as + * something other than a directory, or if a directory cannot be created. + */ + private static Path destination(Path target, Path relative) throws IOException { + return destination(target, relative, true); + } + + /** + * Walks the directories leading to one staged file's destination without descending + * through a symbolic link that is already there. An entry name that stays inside the + * staging directory can still land outside the target if a directory below the target + * is a link to somewhere else. + * + *

This covers links present when the installation runs. It is not a defense against + * a link created concurrently, between the check here and the move that follows.

+ * + * @param target The directory to install into. + * @param relative The staged file's path relative to the staging directory. + * @param create Whether to create a missing directory on the way; when {@code false}, + * a missing directory ends the walk. + * @return The destination path beneath the target, or {@code null} when a directory + * on the way is missing and {@code create} is {@code false}. + * @throws IOException Thrown if a directory on the way is a symbolic link or exists as + * something other than a directory, or if a directory cannot be created. + */ + private static Path destination(Path target, Path relative, boolean create) + throws IOException { + Path directory = target; + for (int i = 0; i < relative.getNameCount() - 1; i++) { + directory = directory.resolve(relative.getName(i)); + if (Files.isSymbolicLink(directory)) { + throw new IOException( + "installation path crosses a symbolic link: " + directory); + } + if (!Files.exists(directory)) { + if (!create) { + return null; + } + Files.createDirectory(directory); + } else if (!Files.isDirectory(directory)) { + throw new IOException( + "installation path crosses an existing file: " + directory); + } + } + return directory.resolve(relative.getFileName()); + } + + /** + * Removes the given directory tree, deepest entries first. + * + * @param root The directory to remove. + * @throws IOException Thrown if a deletion fails. + */ + private static void deleteRecursively(Path root) throws IOException { + final List paths; + try (Stream walk = Files.walk(root)) { + paths = walk.sorted(Comparator.reverseOrder()).toList(); + } + for (final Path path : paths) { + Files.deleteIfExists(path); + } + } + + /** + * Detects the content format from its leading bytes and unpacks accordingly, + * bounding the total expanded bytes against the expansion limit. One exception: a + * source named {@code *.bin}, in any letter case, is stored verbatim even when its + * bytes are a zip archive. OpenNLP model consumers load the packed zip artifact. Unpacking it would + * place the internal entries ({@code manifest.properties}, {@code *.model}) in the + * target instead of the model. + * + * @param downloaded The fetched file. + * @param name The file name derived from the source location. + * @param staging The staging directory to unpack into. + * @param limits The limits to enforce. + * @throws IOException Thrown if reading or unpacking fails or the expansion limit + * is exceeded. + */ + private static void unpack(Path downloaded, String name, Path staging, Limits limits) + throws IOException { + final Budget budget = new Budget(limits.maxExpandedBytes(), + "expanded content exceeds the limit of " + limits.maxExpandedBytes() + + " bytes"); + final Budget entryBudget = new Budget(limits.maxEntries(), + "archive entry count exceeds the limit of " + limits.maxEntries() + + " entries"); + try (InputStream raw = new BufferedInputStream(Files.newInputStream(downloaded))) { + raw.mark(MAGIC_LENGTH); + final byte[] magic = raw.readNBytes(MAGIC_LENGTH); + raw.reset(); + if (endsWithIgnoreCase(name, MODEL_SUFFIX)) { + copyBounded(raw, safeChild(staging, name), budget); + } else if (hasMagic(magic, GZIP_MAGIC_FIRST, GZIP_MAGIC_SECOND)) { + unpackGzip(raw, name, staging, + expansionBudget(Files.size(downloaded), limits, budget), entryBudget); + } else if (hasMagic(magic, ZIP_MAGIC_FIRST, ZIP_MAGIC_SECOND, + ZIP_LOCAL_HEADER_THIRD, ZIP_LOCAL_HEADER_FOURTH)) { + final Set unpacked = unpackZip(raw, staging, + expansionBudget(Files.size(downloaded), limits, budget), entryBudget); + validateZip(downloaded, unpacked); + } else if (hasMagic(magic, ZIP_MAGIC_FIRST, ZIP_MAGIC_SECOND, + ZIP_END_HEADER_THIRD, ZIP_END_HEADER_FOURTH)) { + validateEmptyZip(raw); + } else { + copyBounded(raw, safeChild(staging, name), budget); + } + } + } + + /** + * Bounds expansion by the ratio as well as the absolute limit, so a small source + * cannot expand to the whole absolute limit. Deflate reaches roughly 1000 to 1, so + * the absolute limit alone lets a few megabytes fill the target filesystem. + * + * @param compressedSize The size of the compressed source in bytes. + * @param limits The limits holding the accepted expansion ratio. + * @param budget The expansion budget under the absolute limit. + * @return The tighter of the two budgets. Not {@code null}. + */ + private static Budget expansionBudget(long compressedSize, Limits limits, + Budget budget) { + final long ratio = limits.maxExpansionRatio(); + final long ratioCeiling = compressedSize > Long.MAX_VALUE / ratio + ? Long.MAX_VALUE + : Math.max(MIN_EXPANSION_BYTES, compressedSize * ratio); + if (ratioCeiling >= budget.limit()) { + return budget; + } + return new Budget(ratioCeiling, "content expands beyond " + + ratio + " times its compressed size"); + } + + /** + * Compares a name's ending with a suffix, ignoring letter case. + * + * @param name The file name. + * @param suffix The suffix to look for. + * @return {@code true} if the name ends with the suffix in any letter case. + */ + private static boolean endsWithIgnoreCase(String name, String suffix) { + return name.length() >= suffix.length() && name.regionMatches(true, + name.length() - suffix.length(), suffix, 0, suffix.length()); + } + + /** + * Checks whether the bytes at the start of a resource match the given signature. + * + * @param actual The bytes read from the resource. + * @param expected The unsigned byte values in the signature. + * @return {@code true} when the resource begins with the expected values. + */ + private static boolean hasMagic(byte[] actual, int... expected) { + if (actual.length < expected.length) { + return false; + } + for (int i = 0; i < expected.length; i++) { + if ((actual[i] & 0xFF) != expected[i]) { + return false; + } + } + return true; + } + + /** + * Checks that a zip archive contains a valid central directory listing the same files + * the local headers delivered, before staged content is promoted. The two listings + * can disagree in a crafted archive, and the local headers are what was unpacked. + * + * @param archive The downloaded archive. + * @param unpacked The names of the file entries read from the local headers. + * @throws IOException Thrown if the archive is malformed, the listings differ, or the + * archive cannot be read. + */ + private static void validateZip(Path archive, Set unpacked) throws IOException { + final Set listed = new HashSet<>(); + try (ZipFile zip = new ZipFile(archive.toFile())) { + final Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + final ZipEntry entry = entries.nextElement(); + if (!entry.isDirectory()) { + listed.add(entry.getName()); + } + } + } catch (UnsupportedOperationException e) { + listed.addAll(listZipOnNonDefaultFileSystem(archive)); + } catch (ZipException e) { + throw new IOException(MALFORMED_ZIP_ERROR, e); + } + if (!listed.equals(unpacked)) { + throw new IOException(ZIP_MISMATCH_ERROR); + } + } + + /** + * Lists the file entries of an archive stored by a file-system provider that cannot + * supply a {@link java.io.File} to {@link ZipFile}. + * + * @param archive The downloaded archive. + * @return The file entry names from the central directory. Not {@code null}. + * @throws IOException Thrown if the archive is malformed or cannot be read. + */ + private static Set listZipOnNonDefaultFileSystem(Path archive) + throws IOException { + final Set listed = new HashSet<>(); + try (FileSystem zip = FileSystems.newFileSystem(archive)) { + for (final Path root : zip.getRootDirectories()) { + try (Stream walk = Files.walk(root)) { + walk.filter(Files::isRegularFile) + .map(path -> root.relativize(path).toString()) + .forEach(listed::add); + } + } + } catch (ZipException | ProviderNotFoundException e) { + throw new IOException(MALFORMED_ZIP_ERROR, e); + } + return listed; + } + + /** + * Validates an empty zip archive from its end-of-central-directory record. An empty + * archive has no local entry headers for {@link ZipInputStream} to validate. + * + * @param raw The zip content, positioned at its first byte. + * @throws IOException Thrown if the record is truncated, declares entries or central + * directory data, or has bytes beyond its declared comment. + */ + private static void validateEmptyZip(InputStream raw) throws IOException { + final byte[] header = raw.readNBytes(ZIP_END_HEADER_LENGTH); + if (header.length != ZIP_END_HEADER_LENGTH + || littleEndianShort(header, ZIP_DISK_OFFSET) != 0 + || littleEndianShort(header, ZIP_CENTRAL_DISK_OFFSET) != 0 + || littleEndianShort(header, ZIP_DISK_ENTRIES_OFFSET) != 0 + || littleEndianShort(header, ZIP_TOTAL_ENTRIES_OFFSET) != 0 + || littleEndianInt(header, ZIP_CENTRAL_SIZE_OFFSET) != 0 + || littleEndianInt(header, ZIP_CENTRAL_OFFSET_OFFSET) != 0) { + throw new IOException(MALFORMED_ZIP_ERROR); + } + final int commentLength = littleEndianShort(header, ZIP_COMMENT_LENGTH_OFFSET); + if (raw.readNBytes(commentLength).length != commentLength || raw.read() >= 0) { + throw new IOException(MALFORMED_ZIP_ERROR); + } + } + + /** + * Reads an unsigned 16-bit little-endian value. + * + * @param bytes The source bytes. + * @param offset The first byte to read. + * @return The decoded value. + */ + private static int littleEndianShort(byte[] bytes, int offset) { + return bytes[offset] & 0xFF | (bytes[offset + 1] & 0xFF) << 8; + } + + /** + * Reads an unsigned 32-bit little-endian value. + * + * @param bytes The source bytes. + * @param offset The first byte to read. + * @return The decoded value. + */ + private static long littleEndianInt(byte[] bytes, int offset) { + return littleEndianShort(bytes, offset) + | (long) littleEndianShort(bytes, offset + 2) << 16; + } + + /** + * Unpacks gzip content: a tar archive inside when present, a plain file otherwise. A + * plain file omits the {@code .gz} suffix of its source name, in any letter case. If the source name is + * only that suffix, the installed file is named {@value #DEFAULT_RESOURCE_NAME}. + * + * @param raw The gzip-compressed content. + * @param name The file name derived from the source location. + * @param staging The staging directory to unpack into. + * @param budget The expansion budget. + * @param entryBudget The entry-count budget. + * @throws IOException Thrown if decompressing or unpacking fails or a limit is + * exceeded. + */ + private static void unpackGzip(InputStream raw, String name, Path staging, + Budget budget, Budget entryBudget) throws IOException { + final InputStream decompressed = new BufferedInputStream( + new BudgetInputStream(new GZIPInputStream(raw), budget), BUFFER_SIZE); + if (TarStream.startsWithHeader(decompressed) || startsWithEmptyTar(decompressed)) { + unpackTar(decompressed, staging, entryBudget); + decompressed.transferTo(OutputStream.nullOutputStream()); + } else { + final String strippedName = endsWithIgnoreCase(name, GZIP_SUFFIX) + ? name.substring(0, name.length() - GZIP_SUFFIX.length()) : name; + final String plainName = strippedName.isEmpty() + ? DEFAULT_RESOURCE_NAME : strippedName; + copy(decompressed, safeChild(staging, plainName)); + } + } + + /** + * Checks for the two zero blocks that make up an empty tar archive. There is no entry + * header for {@link TarStream#startsWithHeader(InputStream)} to recognize in this case. + * + * @param in The decompressed content. Must support mark and reset. + * @return {@code true} when the content starts with two zero tar blocks. + * @throws IOException Thrown if reading or resetting the stream fails. + */ + private static boolean startsWithEmptyTar(InputStream in) throws IOException { + in.mark(TAR_END_BLOCKS_LENGTH); + try { + final byte[] blocks = in.readNBytes(TAR_END_BLOCKS_LENGTH); + if (blocks.length != TAR_END_BLOCKS_LENGTH) { + return false; + } + for (final byte b : blocks) { + if (b != 0) { + return false; + } + } + return true; + } finally { + in.reset(); + } + } + + /** + * Unpacks every regular tar entry to its relative location beneath the staging + * directory. + * + * @param decompressed The uncompressed tar content. + * @param staging The staging directory to unpack into. + * @param entryBudget The archive-header limit. + * @throws IOException Thrown if the archive is malformed, an entry escapes the + * staging directory, or the entry limit is exceeded. + */ + private static void unpackTar(InputStream decompressed, Path staging, + Budget entryBudget) throws IOException { + final TarStream entries = new TarStream(decompressed, entryBudget.limit()); + while (entries.next()) { + if (!entries.isFile()) { + safeChild(staging, entries.name()); + continue; + } + final Path file = newArchiveFile(staging, entries.name()); + copy(entries.entryStream(), file); + } + } + + /** + * Unpacks every regular zip entry to its relative location beneath the staging + * directory. + * + * @param raw The zip content. + * @param staging The staging directory to unpack into. + * @param budget The expansion budget. + * @param entryBudget The entry-count budget, charged for every entry including + * directories. + * @return The names of the file entries unpacked. Not {@code null}. + * @throws IOException Thrown if the archive is malformed, an entry escapes the + * staging directory, or a limit is exceeded. + */ + private static Set unpackZip(InputStream raw, Path staging, Budget budget, + Budget entryBudget) throws IOException { + final ZipInputStream zip = new ZipInputStream(raw); + final Set unpacked = new HashSet<>(); + boolean foundEntry = false; + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + foundEntry = true; + entryBudget.spend(1); + if (entry.isDirectory()) { + safeChild(staging, entry.getName()); + consumeBounded(zip, budget); + continue; + } + final Path file = newArchiveFile(staging, entry.getName()); + copyBounded(zip, file, budget); + unpacked.add(entry.getName()); + } + if (!foundEntry) { + throw new IOException(MALFORMED_ZIP_ERROR); + } + return unpacked; + } + + /** + * Resolves a file entry beneath the staging directory and creates its parent + * directories. A second entry that normalizes to the same path is rejected. + * + * @param staging The staging directory. + * @param entryName The path stored in the archive. + * @return The new file path. Not {@code null}. + * @throws IOException Thrown if the path escapes the staging directory, duplicates + * another file entry, or its parent directories cannot be created. + */ + private static Path newArchiveFile(Path staging, String entryName) throws IOException { + final Path file = safeChild(staging, entryName); + if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("archive contains duplicate file entry: " + entryName); + } + Files.createDirectories(file.getParent()); + return file; + } + + /** + * Copies the stream into the file, charging every byte against the budget before it + * is written, so an oversized transfer aborts within one buffer of its limit. + * + * @param in The content to copy. + * @param file The file to write. + * @param budget The byte budget to charge. + * @throws IOException Thrown if reading or writing fails or the budget is exceeded. + */ + private static void copyBounded(InputStream in, Path file, Budget budget) + throws IOException { + try (OutputStream out = Files.newOutputStream(file)) { + new BudgetInputStream(in, budget).transferTo(out); + } + } + + /** + * Reads and discards an entry while charging each byte against the expansion limit. + * + * @param in The entry content. + * @param budget The byte budget to charge. + * @throws IOException Thrown if reading fails or the budget is exceeded. + */ + private static void consumeBounded(InputStream in, Budget budget) throws IOException { + new BudgetInputStream(in, budget).transferTo(OutputStream.nullOutputStream()); + } + + /** + * Copies the stream into the file. The input stream must already enforce any byte + * limit. + * + * @param in The content to copy. + * @param file The file to write. + * @throws IOException Thrown if reading or writing fails. + */ + private static void copy(InputStream in, Path file) throws IOException { + try (OutputStream out = Files.newOutputStream(file)) { + in.transferTo(out); + } + } + + /** + * Resolves an archive entry inside the staging directory, rejecting escaping paths. + * + * @param staging The staging directory to unpack into. + * @param entryName The entry name as stored in the archive. + * @return The resolved path beneath the staging directory. Not {@code null}. + * @throws IOException Thrown if the entry resolves outside the staging directory. + */ + private static Path safeChild(Path staging, String entryName) throws IOException { + final Path resolved; + try { + resolved = staging.resolve(entryName).normalize(); + } catch (InvalidPathException e) { + throw new IOException("archive entry has an invalid path: " + entryName, e); + } + if (!resolved.startsWith(staging.normalize())) { + throw new IOException("archive entry escapes the target directory: " + entryName); + } + return resolved; + } + + /** + * Derives a file name from the source URI for non-archive content. + * + * @param source The resource location. + * @return The last path segment, or {@code resource} if the location has none. + */ + private static String sourceName(URI source) { + final String path = source.getPath(); + if (path == null || path.isEmpty()) { + return DEFAULT_RESOURCE_NAME; + } + final int slash = path.lastIndexOf('/'); + final String name = slash < 0 ? path : path.substring(slash + 1); + return name.isEmpty() ? DEFAULT_RESOURCE_NAME : name; + } + + /** + * Rejects names that are empty, path-like, or contain a NUL character. + * + * @param name The candidate local file name. + * @return The validated name. + * @throws IllegalArgumentException Thrown if {@code name} is not a file name. + */ + static String validateSourceName(String name) { + if (name.isEmpty() || ".".equals(name) || "..".equals(name)) { + throw new IllegalArgumentException("name must be a file name"); + } + for (int i = 0; i < name.length(); i++) { + final char c = name.charAt(i); + if (c == '/' || c == '\\' || c == 0) { + throw new IllegalArgumentException("name must be a file name"); + } + } + return name; + } + + /** + * A unit budget, counting bytes or archive entries: {@link #spend(long)} accumulates + * spent units and fails once the limit is crossed. + */ + private static final class Budget { + + private final long limit; + private final String message; + private long used; + + /** + * Creates a budget that has spent zero units. + * + * @param limit The largest total number of units accepted. + * @param message The failure message raised when the limit is crossed. + */ + Budget(long limit, String message) { + this.limit = limit; + this.message = message; + } + + /** {@return the maximum number of units accepted} */ + long limit() { + return limit; + } + + /** + * Charges the given number of units against the budget. + * + * @param units The number of units to charge. + * @throws IOException Thrown if the total charged units exceed the limit. + */ + void spend(long units) throws IOException { + used += units; + if (used > limit) { + throw new IOException(message); + } + } + } + + /** + * Charges every byte read or skipped from an expanded stream against a shared budget. + */ + private static final class BudgetInputStream extends FilterInputStream { + + private final Budget budget; + + /** + * Initializes a budgeted stream. + * + * @param in The expanded stream to read. + * @param budget The budget to charge. + */ + BudgetInputStream(InputStream in, Budget budget) { + super(in); + this.budget = budget; + } + + /** + * {@inheritDoc} + */ + @Override + public int read() throws IOException { + final int value = super.read(); + if (value >= 0) { + budget.spend(1); + } + return value; + } + + /** + * {@inheritDoc} + */ + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + final int read = super.read(buffer, offset, length); + if (read > 0) { + budget.spend(read); + } + return read; + } + + /** + * {@inheritDoc} + */ + @Override + public long skip(long bytes) throws IOException { + final long skipped = super.skip(bytes); + budget.spend(skipped); + return skipped; + } + } +} diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/archive/TarStream.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/archive/TarStream.java new file mode 100644 index 0000000000..c016a566fa --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/archive/TarStream.java @@ -0,0 +1,659 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.util.archive; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +import opennlp.tools.commons.Internal; + +/** + * A forward-only reader for classic v7, POSIX ustar, GNU, and pax tar streams. + * {@link #next()} advances to the following entry and {@link #entryStream()} exposes + * only the current entry's bytes. + * + *

The reader validates header checksums, supports ustar name prefixes, GNU long + * names, pax {@code path} and {@code size} records, and GNU base-256 sizes. Sparse + * entries and global pax records that change paths or sizes are rejected because this + * reader cannot reproduce their content or global semantics.

+ * + * @since 3.0.0 + */ +@Internal +public final class TarStream { + + private static final int BLOCK = 512; + private static final int NAME_LENGTH = 100; + private static final int CHECKSUM_OFFSET = 148; + private static final int CHECKSUM_LENGTH = 8; + private static final int SIZE_OFFSET = 124; + private static final int SIZE_LENGTH = 12; + private static final int TYPE_OFFSET = 156; + private static final int MAGIC_OFFSET = 257; + private static final int PREFIX_OFFSET = 345; + private static final int PREFIX_LENGTH = 155; + private static final String USTAR_MAGIC = "ustar"; + private static final char TYPE_REGULAR_FILE = '0'; + private static final char TYPE_REGULAR_FILE_CLASSIC = '\0'; + private static final char TYPE_GNU_LONG_NAME = 'L'; + private static final char TYPE_GNU_LONG_LINK = 'K'; + private static final char TYPE_GNU_SPARSE = 'S'; + private static final char TYPE_PAX_EXTENDED = 'x'; + private static final char TYPE_PAX_GLOBAL = 'g'; + private static final int MAX_EXTENSION_BYTES = 1 << 20; + private static final String MALFORMED_RECORD = "malformed pax extended header record"; + private static final String KEYWORD_PATH = "path"; + private static final String KEYWORD_SIZE = "size"; + private static final String SPARSE_PREFIX = "GNU.sparse."; + private static final int BASE_256_MARKER = 0x80; + private static final int BASE_256_NEGATIVE = 0x40; + private static final int BASE_256_FIRST_BYTE_BITS = 0x7F; + + private final InputStream in; + private final long maxEntries; + private final byte[] header = new byte[BLOCK]; + + private String name; + private long size; + private char type; + private long remaining; + private long entries; + private boolean ended; + + /** The name an extension header supplied for the entry that follows it, else null. */ + private String pendingPath; + + /** The size a pax header supplied for the entry that follows it, else {@code -1}. */ + private long pendingSize = -1; + + /** + * Initializes the reader. + * + * @param in The tar content. Not {@code null}. Not closed by this class. + * @throws IllegalArgumentException Thrown if {@code in} is {@code null}. + */ + public TarStream(InputStream in) { + this(in, Long.MAX_VALUE); + } + + /** + * Initializes a reader with an archive-entry limit. Extension headers count toward the + * limit. + * + * @param in The tar content. Not {@code null}. Not closed by this class. + * @param maxEntries The maximum number of archive headers to read. Must be positive. + * @throws IllegalArgumentException Thrown if {@code in} is {@code null} or + * {@code maxEntries} is not positive. + */ + public TarStream(InputStream in, long maxEntries) { + if (in == null) { + throw new IllegalArgumentException("in must not be null"); + } + if (maxEntries <= 0) { + throw new IllegalArgumentException("maxEntries must be positive"); + } + this.in = in; + this.maxEntries = maxEntries; + } + + /** + * Checks whether the given stream is positioned at a tar entry header, leaving its + * position unchanged. + * + * @param in The stream to inspect. Not {@code null} and must support + * {@link InputStream#mark(int) mark} and {@link InputStream#reset() reset}. + * @return {@code true} if the next 512 bytes read as a tar header, {@code false} if + * they do not or if fewer than 512 bytes are available. + * @throws IOException Thrown if reading from or repositioning the stream fails. + * @throws IllegalArgumentException Thrown if {@code in} is {@code null} or does not + * support mark and reset. + */ + public static boolean startsWithHeader(InputStream in) throws IOException { + if (in == null) { + throw new IllegalArgumentException("in must not be null"); + } + if (!in.markSupported()) { + throw new IllegalArgumentException("in must support mark and reset"); + } + in.mark(BLOCK); + try { + final byte[] block = new byte[BLOCK]; + return in.readNBytes(block, 0, BLOCK) == BLOCK && isHeader(block); + } finally { + in.reset(); + } + } + + /** + * Advances to the next entry. + * + * @return {@code true} if an entry is available, {@code false} at the end of the + * archive. + * @throws IOException Thrown if the archive is truncated or a header is malformed. + */ + public boolean next() throws IOException { + if (ended) { + return false; + } + skip(remaining); + skip(padding(size)); + while (true) { + if (!readBlock() || isEndBlock()) { + ended = true; + return false; + } + entries++; + if (entries > maxEntries) { + throw new IOException("archive entry count exceeds the limit of " + + maxEntries + " entries"); + } + if (!hasValidChecksum(header)) { + throw new IOException("malformed tar header checksum"); + } + type = (char) header[TYPE_OFFSET]; + size = parseSize(); + remaining = size; + if (type == TYPE_GNU_SPARSE) { + throw new IOException("sparse tar entries are not supported: " + + "the archived bytes describe file holes, not contiguous content"); + } + if (type == TYPE_PAX_EXTENDED || type == TYPE_PAX_GLOBAL) { + readRecords(readExtensionPayload(), type == TYPE_PAX_GLOBAL); + continue; + } + if (type == TYPE_GNU_LONG_NAME) { + final byte[] payload = readExtensionPayload(); + pendingPath = trimNul(decodeUtf8(payload, 0, payload.length, "GNU long name")); + continue; + } + if (type == TYPE_GNU_LONG_LINK) { + // The link target of an entry this reader does not expose. + readExtensionPayload(); + continue; + } + name = pendingPath != null ? pendingPath : readName(); + pendingPath = null; + if (pendingSize >= 0) { + size = pendingSize; + remaining = size; + pendingSize = -1; + } + if (name.isEmpty()) { + throw new IOException("tar entry header contains an empty name"); + } + return true; + } + } + + /** + * Reads the payload of the extension header just read, leaving the stream positioned on + * the header that follows it. + * + * @return The payload bytes. Not {@code null}. + * @throws IOException Thrown if the payload is larger than + * {@link #MAX_EXTENSION_BYTES} or the archive ends inside it. + */ + private byte[] readExtensionPayload() throws IOException { + if (size > MAX_EXTENSION_BYTES) { + throw new IOException("tar extension header of " + size + " bytes exceeds the " + + MAX_EXTENSION_BYTES + " byte limit"); + } + final byte[] payload = new byte[(int) size]; + if (in.readNBytes(payload, 0, payload.length) < payload.length) { + throw new IOException("truncated tar archive"); + } + skip(padding(size)); + size = 0; + remaining = 0; + return payload; + } + + /** + * Reads a pax extended header payload, which is a sequence of + * {@code " =\n"} records. Each length counts the complete + * record, including the length digits, blank, and newline. Records are parsed from raw + * bytes because the length is a byte count and a multibyte value would shift later records. + * + * @param payload The raw extended header payload. + * @param global Whether this is a global header, which applies to all following entries. + * @throws IOException Thrown if a record is malformed, if a global header contains a + * keyword that would change the entries after it, or if the entry is sparse. + */ + private void readRecords(byte[] payload, boolean global) throws IOException { + int offset = 0; + while (offset < payload.length) { + int blank = offset; + while (blank < payload.length && payload[blank] != ' ') { + blank++; + } + if (blank == payload.length || blank == offset) { + throw new IOException(MALFORMED_RECORD); + } + int length = 0; + for (int i = offset; i < blank; i++) { + final byte b = payload[i]; + if (b < '0' || b > '9') { + throw new IOException(MALFORMED_RECORD); + } + length = length * 10 + (b - '0'); + if (length > payload.length) { + throw new IOException(MALFORMED_RECORD); + } + } + final int end = offset + length; + if (length <= blank - offset || end > payload.length || payload[end - 1] != '\n') { + throw new IOException(MALFORMED_RECORD); + } + int equals = blank + 1; + while (equals < end - 1 && payload[equals] != '=') { + equals++; + } + if (equals >= end - 1 || equals == blank + 1) { + throw new IOException(MALFORMED_RECORD); + } + apply(decodeUtf8(payload, blank + 1, equals - blank - 1, "pax record"), + decodeUtf8(payload, equals + 1, end - equals - 2, "pax record"), global); + offset = end; + } + } + + /** + * Decodes archive text without replacing malformed input, which would silently + * change an archive path. + * + * @param bytes The bytes containing the text. + * @param offset The first byte to decode. + * @param length The number of bytes to decode. + * @param subject The field name to use in an error message. + * @return The decoded text. Not {@code null}. + * @throws IOException Thrown if the bytes are not valid UTF-8. + */ + private static String decodeUtf8(byte[] bytes, int offset, int length, String subject) + throws IOException { + try { + return StandardCharsets.UTF_8.newDecoder() + .decode(ByteBuffer.wrap(bytes, offset, length)).toString(); + } catch (CharacterCodingException e) { + throw new IOException(subject + " is not valid UTF-8", e); + } + } + + /** + * Applies one pax record. Only {@code path} and {@code size} change what this reader + * reports, so other keywords are ignored, except sparse entries, which cannot be + * unpacked. + * + * @param keyword The record's keyword. + * @param value The record's value. + * @param global Whether the record came from a global header. + * @throws IOException Thrown if the entry is sparse, if a global header contains a + * keyword that would change the entries after it, or if {@code size} is not a + * number. + */ + private void apply(String keyword, String value, boolean global) throws IOException { + if (keyword.startsWith(SPARSE_PREFIX)) { + throw new IOException("sparse tar entries are not supported: " + + "the archived bytes describe file holes, not contiguous content"); + } + if (!KEYWORD_PATH.equals(keyword) && !KEYWORD_SIZE.equals(keyword)) { + return; + } + if (global) { + throw new IOException("pax global header contains " + keyword + + ", which would change every entry after it"); + } + if (KEYWORD_PATH.equals(keyword)) { + pendingPath = value; + return; + } + try { + pendingSize = Long.parseLong(value); + } catch (NumberFormatException e) { + throw new IOException("pax size record is not a number: " + value, e); + } + if (pendingSize < 0) { + throw new IOException("pax size record is negative: " + value); + } + } + + /** + * Drops everything from the first NUL onward, which is how a GNU long-name header + * terminates the name it contains. + * + * @param value The decoded payload. + * @return The name without its terminator. Not {@code null}. + */ + private static String trimNul(String value) { + final int nul = value.indexOf('\0'); + return nul < 0 ? value : value.substring(0, nul); + } + + /** + * Reads the current header's entry name. On a POSIX ustar header a non-empty name + * prefix is joined to the name field with {@code /}, which is how a name longer than + * the 100-byte name field is stored when no extension header contains it. + * + * @return The entry name. Not {@code null}. + * @throws IOException Thrown if the stored name or prefix is not valid UTF-8. + */ + private String readName() throws IOException { + final String stored = field(0, NAME_LENGTH, "tar entry name"); + if (!hasPosixUstarMagic(header)) { + return stored; + } + final String prefix = field(PREFIX_OFFSET, PREFIX_LENGTH, "tar entry name prefix"); + return prefix.isEmpty() ? stored : prefix + "/" + stored; + } + + /** + * Reads a NUL-terminated text field of the current header. + * + * @param offset The field's offset in the header block. + * @param length The field's length in bytes. + * @param subject The field name to use in an error message. + * @return The field content up to its first NUL, decoded as UTF-8. Not {@code null}. + * @throws IOException Thrown if the field is not valid UTF-8. + */ + private String field(int offset, int length, String subject) throws IOException { + int end = 0; + while (end < length && header[offset + end] != 0) { + end++; + } + return decodeUtf8(header, offset, end, subject); + } + + /** + * @return The current entry's name as stored in the archive. Not {@code null} + * after a successful {@link #next()}. + */ + public String name() { + return name; + } + + /** + * @return The current entry's size in bytes. + */ + public long size() { + return size; + } + + /** + * @return {@code true} if the current entry is a regular file. + */ + public boolean isFile() { + return type == TYPE_REGULAR_FILE || type == TYPE_REGULAR_FILE_CLASSIC; + } + + /** + * Opens the current entry's content. + * + * @return A stream over exactly this entry's bytes; reading past the end returns end + * of stream, and a zero-length read returns {@code 0} as + * {@link InputStream#read(byte[], int, int)} requires. Not {@code null}. + * Closing it is not required. + */ + public InputStream entryStream() { + return new InputStream() { + @Override + public int read() throws IOException { + if (remaining <= 0) { + return -1; + } + final int b = in.read(); + if (b < 0) { + throw new IOException("truncated tar entry: " + name); + } + remaining--; + return b; + } + + /** + * {@inheritDoc} + * + *

The range is checked before the entry state, so invalid arguments are reported + * when the entry is exhausted or the requested length is zero. This override uses + * the exceptions specified by {@link InputStream}.

+ * + * @throws NullPointerException Thrown if {@code buffer} is {@code null}. + * @throws IndexOutOfBoundsException Thrown if {@code offset} or {@code length} is + * negative, or {@code length} is greater than + * {@code buffer.length - offset}. + */ + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + Objects.checkFromIndexSize(offset, length, buffer.length); + if (length == 0) { + return 0; + } + if (remaining <= 0) { + return -1; + } + final int read = in.read(buffer, offset, (int) Math.min(length, remaining)); + if (read < 0) { + throw new IOException("truncated tar entry: " + name); + } + remaining -= read; + return read; + } + }; + } + + /** + * Checks whether a full 512-byte block reads as a tar entry header, which it does when + * it starts with a name and its stored checksum matches the block. Both classic and + * ustar headers contain that checksum, so it identifies both without relying on the + * ustar magic, which arbitrary content can also contain. + * + * @param block The block to inspect. Must be 512 bytes long. + * @return {@code true} if the block reads as a tar header. + */ + private static boolean isHeader(byte[] block) { + return block[0] != 0 && hasValidChecksum(block); + } + + /** + * Verifies a header block against the checksum stored in it. The checksum is the sum + * of every header byte with the checksum field itself read as eight blanks. Historical + * writers used signed-byte sums, so both totals are accepted. + * + * @param block The header block to verify. Must be 512 bytes long. + * @return {@code true} if the stored checksum is well formed and matches the block. + */ + private static boolean hasValidChecksum(byte[] block) { + long stored = 0; + boolean digits = false; + boolean trailingPadding = false; + for (int i = CHECKSUM_OFFSET; i < CHECKSUM_OFFSET + CHECKSUM_LENGTH; i++) { + final byte b = block[i]; + if (b == 0 || b == ' ') { + if (digits) { + trailingPadding = true; + } + continue; + } + if (!isOctalDigit(b) || trailingPadding) { + return false; + } + stored = stored * 8 + (b - '0'); + digits = true; + } + if (!digits) { + return false; + } + int unsigned = 0; + int signed = 0; + for (int i = 0; i < BLOCK; i++) { + final byte b = i >= CHECKSUM_OFFSET && i < CHECKSUM_OFFSET + CHECKSUM_LENGTH + ? (byte) ' ' : block[i]; + unsigned += b & 0xFF; + signed += b; + } + return stored == unsigned || stored == signed; + } + + /** + * Checks for the POSIX ustar magic specifically, which is {@code "ustar"} followed by a + * NUL. GNU writes {@code "ustar"} followed by two blanks and a NUL in the same field, + * and its headers must not be read as ustar: GNU stores {@code atime} at the offset + * ustar gives to the name prefix, so a GNU incremental archive would otherwise deliver + * every entry under a directory named after an octal timestamp. + * + * @param block The block to inspect. Must be 512 bytes long. + * @return {@code true} if the block contains the POSIX ustar magic. + */ + private static boolean hasPosixUstarMagic(byte[] block) { + for (int i = 0; i < USTAR_MAGIC.length(); i++) { + if (block[MAGIC_OFFSET + i] != USTAR_MAGIC.charAt(i)) { + return false; + } + } + return block[MAGIC_OFFSET + USTAR_MAGIC.length()] == 0; + } + + /** + * @param b The byte to classify. + * @return {@code true} if the byte is one of the digits {@code 0} to {@code 7}. + */ + private static boolean isOctalDigit(byte b) { + return b >= '0' && b <= '7'; + } + + /** + * Fills the header buffer with the next 512-byte block. + * + * @return {@code true} when a full block was read, {@code false} at a clean end of + * the stream before any byte of the block. + * @throws IOException Thrown if the stream ends inside the block. + */ + private boolean readBlock() throws IOException { + final int filled = in.readNBytes(header, 0, header.length); + if (filled == 0) { + return false; + } + if (filled < header.length) { + throw new IOException("truncated tar header"); + } + return true; + } + + /** + * @return {@code true} if the current header buffer is one of the all-zero blocks + * that terminate a tar archive. + */ + private boolean isEndBlock() { + for (final byte b : header) { + if (b != 0) { + return false; + } + } + return true; + } + + /** + * Parses the size field of the current header, tolerating NUL and blank padding around + * the octal digits. A field with its leading bit set is in the base-256 encoding + * instead, and is read by {@link #parseBase256Size()}. + * + * @return The entry size in bytes. + * @throws IOException Thrown if the field contains a character that is not an octal + * digit, a blank, or NUL padding, or if the base-256 form is negative or too + * large for a {@code long}. + */ + private long parseSize() throws IOException { + if ((header[SIZE_OFFSET] & BASE_256_MARKER) != 0) { + return parseBase256Size(); + } + long value = 0; + boolean digitSeen = false; + boolean trailingPadding = false; + for (int i = SIZE_OFFSET; i < SIZE_OFFSET + SIZE_LENGTH; i++) { + final byte b = header[i]; + if (b == 0 || b == ' ') { + if (digitSeen) { + trailingPadding = true; + } + continue; + } + if (!isOctalDigit(b) || trailingPadding) { + throw new IOException("malformed tar size field in entry header"); + } + digitSeen = true; + value = value * 8 + (b - '0'); + } + return value; + } + + /** + * Reads a size field in the base-256 encoding, which GNU writes when a value does not + * fit the 11 octal digits the field otherwise contains, so entries of 8 GiB and above + * can state their length. + * + *

The leading bit marks the encoding, the next bit is the sign, and the remaining + * bits of that byte followed by all later bytes form a big-endian two's complement + * number. Negative sizes are rejected.

+ * + * @return The entry size in bytes. + * @throws IOException Thrown if the encoded value is negative or does not fit a + * {@code long}. + */ + private long parseBase256Size() throws IOException { + if ((header[SIZE_OFFSET] & BASE_256_NEGATIVE) != 0) { + throw new IOException("tar size field is negative"); + } + long value = 0; + for (int i = SIZE_OFFSET; i < SIZE_OFFSET + SIZE_LENGTH; i++) { + final int b = i == SIZE_OFFSET + ? header[i] & BASE_256_FIRST_BYTE_BITS : header[i] & 0xFF; + if (value >>> (Long.SIZE - Byte.SIZE - 1) != 0) { + throw new IOException( + "tar size field exceeds the largest length this reader can represent"); + } + value = value << Byte.SIZE | b; + } + return value; + } + + /** + * @param entrySize The size of an entry's content in bytes. + * @return The number of padding bytes that align the entry to the next 512-byte + * block boundary. + */ + private long padding(long entrySize) { + final long remainder = entrySize % BLOCK; + return remainder == 0 ? 0 : BLOCK - remainder; + } + + /** + * Consumes and discards the given number of bytes from the underlying stream. + * + * @param bytes The number of bytes to discard. + * @throws IOException Thrown if the stream ends before all bytes were consumed. + */ + private void skip(long bytes) throws IOException { + try { + in.skipNBytes(bytes); + } catch (EOFException e) { + throw new IOException("truncated tar archive", e); + } + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/hunspell/HunspellDictionaryDownloadTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/hunspell/HunspellDictionaryDownloadTest.java index 67d85af698..7c219fd4f3 100644 --- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/hunspell/HunspellDictionaryDownloadTest.java +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/hunspell/HunspellDictionaryDownloadTest.java @@ -25,11 +25,13 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import opennlp.tools.util.DictionaryCatalog; import opennlp.tools.util.DigestTestUtil; -import opennlp.tools.util.DownloadUtil; /** * Pins the Hunspell catalog download gate; network fetches are not exercised here. @@ -42,16 +44,18 @@ public class HunspellDictionaryDownloadTest { * * @param target A scratch directory managed by the test framework. * @throws IOException Thrown if the local catalog cannot be prepared. - */ + */ @Test void testDownloadRequiresRemoteProperty(@TempDir Path target) throws IOException { final DictionaryCatalog catalog = localCatalog(target); - final String previous = System.getProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY); - System.clearProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY); + final String previous = + System.getProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY); + System.clearProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY); try { final IOException e = Assertions.assertThrows(IOException.class, () -> HunspellDictionaryDownload.downloadFromCatalog(catalog, "demo", target)); - Assertions.assertTrue(e.getMessage().contains(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY)); + Assertions.assertTrue( + e.getMessage().contains(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY)); } finally { restore(previous); } @@ -68,8 +72,9 @@ void testDownloadRequiresRemoteProperty(@TempDir Path target) throws IOException void testDownloadsFromApplicationCatalog(@TempDir Path target) throws IOException { final DictionaryCatalog catalog = localCatalog(target); final Path output = target.resolve("output"); - final String previous = System.getProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY); - System.setProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY, "true"); + final String previous = + System.getProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY); + System.setProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY, "true"); try { HunspellDictionaryDownload.downloadFromCatalog(catalog, "demo", output); Assertions.assertEquals("SET UTF-8\n", @@ -85,18 +90,28 @@ void testDownloadsFromApplicationCatalog(@TempDir Path target) throws IOExceptio /** * Verifies that each required parameter is checked before a download begins. * + * @param argument The invalid method parameter. * @param target A scratch directory managed by the test framework. * @throws IOException Thrown if the local catalog cannot be prepared. */ - @Test - void testRejectsNullParameters(@TempDir Path target) throws IOException { + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"catalog", "dictionaryId", "targetDirectory"}) + void testRejectsNullParameters(String argument, @TempDir Path target) + throws IOException { final DictionaryCatalog catalog = localCatalog(target); - Assertions.assertThrows(IllegalArgumentException.class, - () -> HunspellDictionaryDownload.downloadFromCatalog(null, "demo", target)); - Assertions.assertThrows(IllegalArgumentException.class, - () -> HunspellDictionaryDownload.downloadFromCatalog(catalog, null, target)); - Assertions.assertThrows(IllegalArgumentException.class, - () -> HunspellDictionaryDownload.downloadFromCatalog(catalog, "demo", null)); + final Executable download = switch (argument) { + case "catalog" -> () -> + HunspellDictionaryDownload.downloadFromCatalog(null, "demo", target); + case "dictionaryId" -> () -> + HunspellDictionaryDownload.downloadFromCatalog(catalog, null, target); + case "targetDirectory" -> () -> + HunspellDictionaryDownload.downloadFromCatalog(catalog, "demo", null); + default -> throw new IllegalArgumentException("unknown argument: " + argument); + }; + + final IllegalArgumentException thrown = + Assertions.assertThrows(IllegalArgumentException.class, download); + Assertions.assertEquals(argument + " must not be null", thrown.getMessage()); } /** @@ -140,9 +155,9 @@ private static String entry(String id, Path source, byte[] content, String filen /** Restores the remote-download property to its previous value. */ private static void restore(String previous) { if (previous == null) { - System.clearProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY); + System.clearProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY); } else { - System.setProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY, previous); + System.setProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY, previous); } } } diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/LatticeUsageExampleTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/LatticeUsageExampleTest.java index fc2d59a6b7..7e3f3e5a88 100644 --- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/LatticeUsageExampleTest.java +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/LatticeUsageExampleTest.java @@ -29,6 +29,7 @@ import org.junit.jupiter.api.io.TempDir; import opennlp.tools.util.Span; +import opennlp.tools.util.archive.TarArchives; /** * Demonstrates the intended end-to-end usage of this package with miniature, @@ -60,7 +61,7 @@ void testInstallLoadAndTokenizeAMecabFormatDictionary(@TempDir Path work) throws IOException { // A minimal but complete dictionary: one lexicon file plus the three definition // files every MeCab-format distribution contains, wrapped like a release archive. - final byte[] archive = TarGzArchives.gzippedTar(new String[][] { + final byte[] archive = TarArchives.gzippedTar(new String[][] { {"mini-dict-0.1/lexicon.csv", String.join("\n", "\u6771\u4EAC,0,0,3000,noun,proper", "\u4EAC\u90FD,0,0,3000,noun,proper", diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/MecabDictionaryInstallerTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/MecabDictionaryInstallerTest.java index 3fe57e1b87..cbbfe4915b 100644 --- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/MecabDictionaryInstallerTest.java +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/MecabDictionaryInstallerTest.java @@ -18,26 +18,41 @@ package opennlp.tools.tokenize.lattice; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import opennlp.tools.util.DictionaryCatalog; +import opennlp.tools.util.DigestTestUtil; +import opennlp.tools.util.archive.TarArchives; /** * Tests the installer against project-authored, in-memory archives; no external - * dictionary data and no network access are involved. + * dictionary data and no network access are involved. Fetch, verification, and + * unpacking limits are exercised in {@code opennlp.tools.util.ResourceInstallerTest}, + * which tests the shared installation path this installer delegates to. */ public class MecabDictionaryInstallerTest { @Test - void testExtractsDictionaryFilesAndFlattensPaths(@TempDir Path target) - throws IOException { - final byte[] archive = TarGzArchives.gzippedTar(new String[][] { + void testInstallsDictionaryFilesAndFlattensPaths(@TempDir Path source, + @TempDir Path target) throws IOException { + final Path archiveFile = archive(source, new String[][] { {"dict-1.0/lexicon.csv", "cat,0,0,100,noun\n"}, {"dict-1.0/matrix.def", "1 1\n0 0 0\n"}, {"dict-1.0/char.def", "DEFAULT 0 1 0\n"}, @@ -45,10 +60,10 @@ void testExtractsDictionaryFilesAndFlattensPaths(@TempDir Path target) {"dict-1.0/README", "not a dictionary file"}, {"dict-1.0/dicrc", "config"}}); - final int extracted = MecabDictionaryInstaller.extract( - new ByteArrayInputStream(archive), target); + final int installed = + MecabDictionaryInstaller.install(archiveFile.toUri(), target); - Assertions.assertEquals(5, extracted); + Assertions.assertEquals(5, installed); Assertions.assertTrue(Files.exists(target.resolve("lexicon.csv"))); Assertions.assertTrue(Files.exists(target.resolve("matrix.def"))); Assertions.assertTrue(Files.exists(target.resolve("char.def"))); @@ -62,180 +77,257 @@ void testExtractsDictionaryFilesAndFlattensPaths(@TempDir Path target) /** * Verifies that only files at the archive root count as dictionary payload. * mecab-ko-dic ships template user dictionaries under {@code user-dic/} whose - * numeric fields are empty, input for {@code mecab-dict-index} rather than loadable + * numeric fields are empty. They are input for {@code mecab-dict-index}, not loadable * lexicon data. Flattening them next to the real lexicon fails the subsequent load, * and on a case-insensitive file system a template can silently overwrite a real * lexicon file of the same base name. */ @Test - void testNestedTemplateFilesAreNotExtracted(@TempDir Path target) throws IOException { - final byte[] archive = TarGzArchives.gzippedTar(new String[][] { + void testNestedTemplateFilesAreNotInstalled(@TempDir Path source, + @TempDir Path target) throws IOException { + final Path archiveFile = archive(source, new String[][] { {"dict-1.0/NNP.csv", "cat,1786,3546,2953,noun\n"}, {"dict-1.0/matrix.def", "1 1\n0 0 0\n"}, {"dict-1.0/user-dic/person.csv", "template,,,,noun\n"}}); - final int extracted = MecabDictionaryInstaller.extract( - new ByteArrayInputStream(archive), target); + final int installed = + MecabDictionaryInstaller.install(archiveFile.toUri(), target); - Assertions.assertEquals(2, extracted); + Assertions.assertEquals(2, installed); Assertions.assertTrue(Files.exists(target.resolve("NNP.csv"))); Assertions.assertTrue(Files.notExists(target.resolve("person.csv"))); } + /** + * Verifies that two payload entries that flatten to the same base name are rejected, + * because keeping either one silently would install an ambiguous dictionary. + */ + @Test + void testEntriesFlatteningToTheSameNameAreRejected(@TempDir Path source, + @TempDir Path target) throws IOException { + final Path archiveFile = archive(source, new String[][] { + {"words.csv", "cat,0,0,100,noun\n"}, + {"d/words.csv", "dog,0,0,100,noun\n"}}); + + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionaryInstaller.install(archiveFile.toUri(), target)); + Assertions.assertEquals( + "the archive flattens two entries to the same name: words.csv", + e.getMessage()); + Assertions.assertTrue(Files.notExists(target.resolve("words.csv"))); + } + @Test void testInstallReadsAFileUri(@TempDir Path source, @TempDir Path target) throws IOException { - final Path archiveFile = source.resolve("dict.tar.gz"); - Files.write(archiveFile, TarGzArchives.gzippedTar(new String[][] { + final Path archiveFile = archive(source, new String[][] { {"d/words.csv", "cat,0,0,100,noun\n"}, - {"d/matrix.def", "1 1\n0 0 0\n"}})); + {"d/matrix.def", "1 1\n0 0 0\n"}}); - final int extracted = + final int installed = MecabDictionaryInstaller.install(archiveFile.toUri(), target); - Assertions.assertEquals(2, extracted); + Assertions.assertEquals(2, installed); Assertions.assertTrue(Files.exists(target.resolve("words.csv"))); } - @Test - void testNonFileInstallIsRejected(@TempDir Path target) { - Assertions.assertThrows(IllegalArgumentException.class, - () -> MecabDictionaryInstaller.install( - URI.create("https://example.invalid/dict.tar.gz"), target)); - } - - @Test - void testArchivesWithoutDictionaryFilesFailLoud(@TempDir Path target) - throws IOException { - final byte[] archive = - TarGzArchives.gzippedTar(new String[][] {{"readme.txt", "nothing here"}}); - Assertions.assertThrows(IOException.class, () -> MecabDictionaryInstaller.extract( - new ByteArrayInputStream(archive), target)); - } - /** - * Verifies that a tar entry with a declared size that is above the per-entry limit is - * rejected before any payload is written. The fixture stores only the oversized - * header so the test does not allocate the declared size. + * Checks installation when the target uses a different filesystem provider. + * + * @param source The directory containing the fixture archive. + * @param scratch The directory containing the ZIP filesystem. + * @throws IOException Thrown if creating or installing the fixture fails. */ @Test - void testOversizedEntryFailsLoud(@TempDir Path target) throws IOException { - final long limit = 64; - final byte[] archive = TarGzArchives.gzippedTar( - TarGzArchives.Entry.withDeclaredSize("huge.csv", new byte[0], limit + 1)); - final IOException e = Assertions.assertThrows(IOException.class, - () -> MecabDictionaryInstaller.extract(new ByteArrayInputStream(archive), - target, limit, 1024, 16, 100)); - Assertions.assertEquals("tar entry size exceeds safe limit of " + limit, - e.getMessage()); + void testInstallIntoANonDefaultFileSystem(@TempDir Path source, @TempDir Path scratch) + throws IOException { + final Path archiveFile = archive(source, new String[][] { + {"d/words.csv", "cat,0,0,100,noun\n"}, + {"d/matrix.def", "1 1\n0 0 0\n"}}); + final URI zip = URI.create("jar:" + scratch.resolve("target.zip").toUri()); + + try (FileSystem targetFileSystem = + FileSystems.newFileSystem(zip, Map.of("create", "true"))) { + final Path target = targetFileSystem.getPath("/dictionary"); + + Assertions.assertEquals(2, + MecabDictionaryInstaller.install(archiveFile.toUri(), target)); + Assertions.assertEquals("cat,0,0,100,noun\n", + Files.readString(target.resolve("words.csv"))); + } } /** - * Verifies that extracting dictionary files with sizes that sum above the total-bytes - * limit fails with {@link IOException}. + * Verifies that a pax long-name entry installs under its real name. The common + * distributions ship plain ustar today, but {@code bsdtar} and + * {@code tar --format=posix} write pax archives, whose over-100-byte names live in an + * extension header instead of the header name field. */ @Test - void testTotalExtractedBytesBudgetFailsLoud(@TempDir Path target) throws IOException { - final long limit = 30; - final byte[] archive = TarGzArchives.gzippedTar(new String[][] { - {"a.csv", "01234567890123456789"}, - {"b.csv", "01234567890123456789"}}); - final IOException e = Assertions.assertThrows(IOException.class, - () -> MecabDictionaryInstaller.extract(new ByteArrayInputStream(archive), - target, 1024, limit, 16, 100)); - Assertions.assertEquals("extracted archive size exceeds safe limit of " + limit, - e.getMessage()); + void testPaxLongNamedEntryInstallsUnderItsRealName(@TempDir Path source, + @TempDir Path target) throws IOException { + final String longBaseName = "a".repeat(110) + ".csv"; + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + TarArchives.entry(tar, "PaxHeaders.0/lexicon", + TarArchives.paxRecord("path", "dict-1.0/" + longBaseName), 'x'); + TarArchives.entry(tar, "dict-1.0/" + "a".repeat(88), + "cat,0,0,100,noun\n".getBytes(StandardCharsets.UTF_8)); + tar.write(new byte[TarArchives.TERMINATOR_SIZE]); + final Path archiveFile = source.resolve("dict.tar.gz"); + Files.write(archiveFile, TarArchives.gzip(tar.toByteArray())); + + final int installed = + MecabDictionaryInstaller.install(archiveFile.toUri(), target); + + Assertions.assertEquals(1, installed); + Assertions.assertEquals("cat,0,0,100,noun\n", + Files.readString(target.resolve(longBaseName))); } /** - * Verifies that an archive with more dictionary files than the entry-count limit - * fails on the entry that would exceed it. + * Verifies that installing into a target that already holds a dictionary file of the + * same name is rejected, leaving the first installation in place. Refreshing a + * dictionary means removing its old files first. */ @Test - void testExtractedEntryCountBudgetFailsLoud(@TempDir Path target) throws IOException { - final int limit = 2; - final byte[] archive = TarGzArchives.gzippedTar(new String[][] { - {"a.csv", "a\n"}, - {"b.def", "b\n"}, - {"c.csv", "c\n"}}); + void testReinstallOverAnExistingDictionaryIsRejected(@TempDir Path source, + @TempDir Path target) throws IOException { + final Path archiveFile = archive(source, new String[][] { + {"d/words.csv", "cat,0,0,100,noun\n"}}); + Assertions.assertEquals(1, + MecabDictionaryInstaller.install(archiveFile.toUri(), target)); + final IOException e = Assertions.assertThrows(IOException.class, - () -> MecabDictionaryInstaller.extract(new ByteArrayInputStream(archive), - target, 1024, 1024, limit, 100)); - Assertions.assertEquals("extracted entry count exceeds safe limit of " + limit, - e.getMessage()); + () -> MecabDictionaryInstaller.install(archiveFile.toUri(), target)); + Assertions.assertTrue(e.getMessage().contains("target already contains: ")); + Assertions.assertEquals("cat,0,0,100,noun\n", + Files.readString(target.resolve("words.csv"))); } - /** - * Verifies that a highly compressible payload whose expansion exceeds the gzip - * ratio limit fails before the inflated content is kept. - */ @Test - void testGzipExpansionRatioBudgetFailsLoud(@TempDir Path target) throws IOException { - final int ratio = 2; - final byte[] zeros = new byte[64 * 1024]; - Arrays.fill(zeros, (byte) 0); - final byte[] archive = TarGzArchives.gzippedTar( - TarGzArchives.Entry.of("zeros.csv", zeros)); - final IOException e = Assertions.assertThrows(IOException.class, - () -> MecabDictionaryInstaller.extract(new ByteArrayInputStream(archive), - target, zeros.length, zeros.length, 16, ratio)); - Assertions.assertEquals("gzip expansion ratio exceeds safe limit of " + ratio, - e.getMessage()); + void testRemoteInstallWithoutDigestIsRejected(@TempDir Path target) { + final IllegalArgumentException e = + Assertions.assertThrows(IllegalArgumentException.class, + () -> MecabDictionaryInstaller.install( + URI.create("https://example.invalid/dict.tar.gz"), target)); + Assertions.assertTrue( + e.getMessage().contains("checksum must be given for an http or https source")); } @Test - void testInvalidTarChecksumIsRejected(@TempDir Path target) throws IOException { - final byte[] archive = TarGzArchives.gzippedTarWithInvalidHeaderChecksum( - TarGzArchives.Entry.of("words.csv", "cat,0,0,100,noun\n")); + void testInstallVerifiesDigest(@TempDir Path source, @TempDir Path target) + throws Exception { + final Path archiveFile = archive(source, new String[][] { + {"d/words.csv", "cat,0,0,100,noun\n"}, + {"d/matrix.def", "1 1\n0 0 0\n"}}); + final byte[] archive = Files.readAllBytes(archiveFile); + + final int installed = MecabDictionaryInstaller.install( + archiveFile.toUri(), target, DigestTestUtil.sha512(archive)); + Assertions.assertEquals(2, installed); + final IOException e = Assertions.assertThrows(IOException.class, - () -> MecabDictionaryInstaller.extract(new ByteArrayInputStream(archive), target)); - Assertions.assertEquals("tar header checksum does not match", e.getMessage()); - Assertions.assertTrue(Files.notExists(target.resolve("words.csv"))); + () -> MecabDictionaryInstaller.install(archiveFile.toUri(), + target.resolve("other"), DigestTestUtil.sha512(new byte[] {1}))); + Assertions.assertTrue(e.getMessage().contains("checksum mismatch: expected")); } @Test - void testFailedExtractionDoesNotPublishEarlierEntries(@TempDir Path target) + void testInstallFromCatalogRequiresRemoteProperty(@TempDir Path target) throws IOException { - final long limit = 64; - final byte[] archive = TarGzArchives.gzippedTar( - TarGzArchives.Entry.of("words.csv", "cat,0,0,100,noun\n"), - TarGzArchives.Entry.withDeclaredSize("matrix.def", new byte[0], limit + 1)); - Assertions.assertThrows(IOException.class, - () -> MecabDictionaryInstaller.extract(new ByteArrayInputStream(archive), - target, limit, 1024, 16, 100)); - Assertions.assertTrue(Files.notExists(target.resolve("words.csv"))); + final DictionaryCatalog catalog = emptyCatalog(); + final String previous = + System.getProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY); + System.clearProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY); + try { + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionaryInstaller.installFromCatalog( + catalog, "mecab.ipadic", target)); + Assertions.assertTrue( + e.getMessage().contains(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY)); + } finally { + if (previous == null) { + System.clearProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY); + } else { + System.setProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY, previous); + } + } } @Test - void testExistingFileIsNotReplaced(@TempDir Path target) throws IOException { - final Path existing = target.resolve("words.csv"); - Files.writeString(existing, "existing\n"); - final byte[] archive = TarGzArchives.gzippedTar( - TarGzArchives.Entry.of("words.csv", "replacement\n")); - + void testArchivesWithoutDictionaryFilesAreRejected(@TempDir Path source, + @TempDir Path target) throws IOException { + final Path archiveFile = + archive(source, new String[][] {{"readme.txt", "nothing here"}}); final IOException e = Assertions.assertThrows(IOException.class, - () -> MecabDictionaryInstaller.extract(new ByteArrayInputStream(archive), target)); - Assertions.assertEquals("dictionary file already exists: " + existing, e.getMessage()); - Assertions.assertEquals("existing\n", Files.readString(existing)); + () -> MecabDictionaryInstaller.install(archiveFile.toUri(), target)); + Assertions.assertEquals("the archive contains no dictionary file", e.getMessage()); } - @Test - void testInvalidArguments(@TempDir Path target) { - Assertions.assertThrows(IllegalArgumentException.class, - () -> MecabDictionaryInstaller.install(null, target)); - Assertions.assertThrows(IllegalArgumentException.class, - () -> MecabDictionaryInstaller.install(target.toUri(), null)); - Assertions.assertThrows(IllegalArgumentException.class, - () -> MecabDictionaryInstaller.extract(null, target)); - Assertions.assertThrows(IllegalArgumentException.class, - () -> MecabDictionaryInstaller.extract( - new ByteArrayInputStream(new byte[0]), null)); + /** + * Checks each public installer parameter independently. + * + * @param argument The invalid parameter. + * @param target A scratch directory managed by the test framework. + * @throws IOException Thrown if the empty catalog cannot be loaded. + */ + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"archive", "targetDirectory", "catalog", "dictionaryId", + "catalog targetDirectory"}) + void testInvalidArguments(String argument, @TempDir Path target) throws IOException { + final DictionaryCatalog catalog = emptyCatalog(); + final Executable install = switch (argument) { + case "archive" -> () -> MecabDictionaryInstaller.install(null, target); + case "targetDirectory" -> () -> + MecabDictionaryInstaller.install(target.toUri(), null); + case "catalog" -> () -> + MecabDictionaryInstaller.installFromCatalog(null, "mecab.ipadic", target); + case "dictionaryId" -> () -> + MecabDictionaryInstaller.installFromCatalog(catalog, null, target); + case "catalog targetDirectory" -> () -> + MecabDictionaryInstaller.installFromCatalog(catalog, "mecab.ipadic", null); + default -> throw new IllegalArgumentException("unknown argument: " + argument); + }; + + final IllegalArgumentException thrown = + Assertions.assertThrows(IllegalArgumentException.class, install); + final String parameter = argument.startsWith("catalog ") + ? argument.substring("catalog ".length()) : argument; + Assertions.assertEquals(parameter + " must not be null", thrown.getMessage()); + } + + private static DictionaryCatalog emptyCatalog() throws IOException { + return DictionaryCatalog.load(new ByteArrayInputStream(new byte[0])); + } + + /** + * Writes a gzip-compressed tar archive of the given entries to a file. + * + * @param directory The directory to write the archive into. + * @param entries The entries as {@code {name, content}} pairs. + * @return The archive file. Never {@code null}. + * @throws IOException Thrown if writing fails. + */ + private static Path archive(Path directory, String[][] entries) throws IOException { + final Path archiveFile = directory.resolve("dict.tar.gz"); + Files.write(archiveFile, TarArchives.gzippedTar(entries)); + return archiveFile; } @Test - void testDefaultBudgetsWithoutOverrides() { - Assertions.assertEquals(512L * 1024 * 1024, MecabDictionaryInstaller.MAX_ENTRY_BYTES); - Assertions.assertEquals(2L * 1024 * 1024 * 1024, - MecabDictionaryInstaller.MAX_TOTAL_EXTRACTED_BYTES); + void testStaleScratchOfAKilledInstallIsRemoved(@TempDir Path source, + @TempDir Path target) throws IOException { + final Path stale = Files.createDirectories(target.resolve(".mecab-dict-OLD")); + Files.writeString(stale.resolve("words.csv"), "half"); + final Path archiveFile = archive(source, new String[][] { + {"d/words.csv", "cat,0,0,100,noun\n"}}); + + Assertions.assertEquals(1, + MecabDictionaryInstaller.install(archiveFile.toUri(), target)); + + try (Stream entries = Files.list(target)) { + Assertions.assertEquals(List.of("words.csv"), + entries.map(path -> path.getFileName().toString()).sorted().toList()); + } } } diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/TarGzArchives.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/TarGzArchives.java deleted file mode 100644 index bb883773bc..0000000000 --- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/TarGzArchives.java +++ /dev/null @@ -1,248 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package opennlp.tools.tokenize.lattice; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.zip.GZIPOutputStream; - -/** - * Builds miniature, project-authored gzip-compressed ustar archives in memory for the - * tests of this package; no external archive data is involved. - */ -final class TarGzArchives { - - /** The tar block size; headers, content, and padding are all complete blocks. */ - private static final int BLOCK = 512; - - /** The header field offsets and lengths this builder writes, in bytes. */ - private static final int NAME_LENGTH = 100; - private static final int MODE_OFFSET = 100; - private static final int SIZE_OFFSET = 124; - private static final int SIZE_LENGTH = 12; - private static final int CHECKSUM_OFFSET = 148; - private static final int CHECKSUM_LENGTH = 8; - private static final int TYPE_OFFSET = 156; - - /** The type flag of a regular file entry. */ - private static final char REGULAR_FILE = '0'; - - private TarGzArchives() { - } - - /** - * One archive entry: a path name, the bytes stored after the header, and the size - * field written into the header (which may differ from the stored content length so - * budget checks can be exercised without allocating the declared payload). - * - * @param name The entry name including any directory prefix. - * @param content The bytes written after the header; may be shorter than - * {@code declaredSize}. - * @param declaredSize The octal size field stored in the header. - */ - record Entry(String name, byte[] content, long declaredSize) { - - /** - * Builds an entry with a declared size that matches its UTF-8 content length. - * - * @param name The entry name. - * @param content The entry text. - * @return The entry. Not {@code null}. - */ - static Entry of(String name, String content) { - final byte[] bytes = content.getBytes(StandardCharsets.UTF_8); - return new Entry(name, bytes, bytes.length); - } - - /** - * Builds an entry with a declared size that matches its content length. - * - * @param name The entry name. - * @param content The entry bytes. - * @return The entry. Not {@code null}. - */ - static Entry of(String name, byte[] content) { - return new Entry(name, content, content.length); - } - - /** - * Builds an entry with a header size field that is set independently of the stored - * content, for oversized-entry budget tests. - * - * @param name The entry name. - * @param content The bytes stored after the header; typically empty for header-only - * oversized cases. - * @param declaredSize The size field written into the header. - * @return The entry. Not {@code null}. - */ - static Entry withDeclaredSize(String name, byte[] content, long declaredSize) { - return new Entry(name, content, declaredSize); - } - } - - /** - * Builds a gzip-compressed tar archive from name and content pairs, the layout a - * dictionary distribution ships in. - * - * @param entries The entries as {@code {name, content}} pairs. Must not be - * {@code null}. - * @return The compressed archive bytes. Not {@code null}. - * @throws IOException Thrown if writing to the in-memory streams fails. - */ - static byte[] gzippedTar(String[][] entries) throws IOException { - final Entry[] typed = new Entry[entries.length]; - for (int i = 0; i < entries.length; i++) { - typed[i] = Entry.of(entries[i][0], entries[i][1]); - } - return gzippedTar(typed); - } - - /** - * Builds a gzip-compressed tar archive from typed entries. - * - * @param entries The entries to store. Must not be {@code null}. - * @return The compressed archive bytes. Not {@code null}. - * @throws IOException Thrown if writing to the in-memory streams fails. - */ - static byte[] gzippedTar(Entry... entries) throws IOException { - return gzip(tar(entries)); - } - - /** - * Builds an archive with an incorrect checksum in its first header. - * - * @param entries The entries to store. Must not be {@code null} or empty. - * @return The compressed archive bytes. Not {@code null}. - * @throws IOException Thrown if writing to the in-memory streams fails. - */ - static byte[] gzippedTarWithInvalidHeaderChecksum(Entry... entries) throws IOException { - final byte[] tar = tar(entries); - tar[CHECKSUM_OFFSET] = tar[CHECKSUM_OFFSET] == '0' ? (byte) '1' : (byte) '0'; - return gzip(tar); - } - - /** - * Builds the uncompressed tar image. - * - * @param entries The entries to store. Must not be {@code null}. - * @return The tar bytes. Not {@code null}. - * @throws IOException Thrown if writing to the in-memory stream fails. - */ - private static byte[] tar(Entry... entries) throws IOException { - final ByteArrayOutputStream tar = new ByteArrayOutputStream(); - for (final Entry entry : entries) { - tarEntry(tar, entry); - } - // Two zero blocks end a tar archive. - tar.write(new byte[2 * BLOCK]); - return tar.toByteArray(); - } - - /** - * Compresses a tar image with gzip. - * - * @param tar The tar bytes. Must not be {@code null}. - * @return The compressed bytes. Not {@code null}. - * @throws IOException Thrown if compression fails. - */ - private static byte[] gzip(byte[] tar) throws IOException { - final ByteArrayOutputStream compressed = new ByteArrayOutputStream(); - try (GZIPOutputStream gzip = new GZIPOutputStream(compressed)) { - gzip.write(tar); - } - return compressed.toByteArray(); - } - - /** - * Appends one ustar file entry to a growing tar image: a 512-byte header block - * followed by the stored content padded to a block boundary of the declared size - * when content is present, or the header alone when the test supplies no payload. - * - * @param tar The tar image under construction. Must not be {@code null}. - * @param entry The entry to append. Must not be {@code null}. - * @throws IOException Thrown if writing to the in-memory stream fails. - * @throws IllegalArgumentException Thrown if {@code name} does not fit the header or - * {@code declaredSize} is negative. - */ - private static void tarEntry(ByteArrayOutputStream tar, Entry entry) throws IOException { - final byte[] nameBytes = entry.name().getBytes(StandardCharsets.UTF_8); - if (nameBytes.length == 0 || nameBytes.length > NAME_LENGTH) { - throw new IllegalArgumentException( - "entry name must be 1.." + NAME_LENGTH + " bytes, got " + nameBytes.length); - } - if (entry.declaredSize() < 0) { - throw new IllegalArgumentException("declaredSize must not be negative"); - } - final byte[] header = new byte[BLOCK]; - System.arraycopy(nameBytes, 0, header, 0, nameBytes.length); - final byte[] mode = "0000644".getBytes(StandardCharsets.US_ASCII); - System.arraycopy(mode, 0, header, MODE_OFFSET, mode.length); - // Both numeric fields hold octal digits followed by one terminator byte. - final byte[] size = String.format("%0" + (SIZE_LENGTH - 1) + "o", entry.declaredSize()) - .getBytes(StandardCharsets.US_ASCII); - System.arraycopy(size, 0, header, SIZE_OFFSET, size.length); - header[TYPE_OFFSET] = REGULAR_FILE; - // The checksum is computed with its own field read as spaces. - for (int i = CHECKSUM_OFFSET; i < CHECKSUM_OFFSET + CHECKSUM_LENGTH; i++) { - header[i] = ' '; - } - int checksum = 0; - for (final byte b : header) { - checksum += b & 0xFF; - } - final byte[] checksumText = String.format("%0" + (CHECKSUM_LENGTH - 2) + "o", checksum) - .getBytes(StandardCharsets.US_ASCII); - System.arraycopy(checksumText, 0, header, CHECKSUM_OFFSET, checksumText.length); - header[CHECKSUM_OFFSET + CHECKSUM_LENGTH - 2] = 0; - header[CHECKSUM_OFFSET + CHECKSUM_LENGTH - 1] = ' '; - tar.write(header); - if (entry.declaredSize() == 0) { - return; - } - if (entry.content().length == 0) { - // Header-only oversized fixtures: the extractor rejects on the size field before - // reading a payload, so the declared bytes are not materialised here. - return; - } - tar.write(entry.content()); - final long missing = Math.max(0, entry.declaredSize() - entry.content().length); - if (missing > 0) { - writeZeros(tar, missing); - } - final int padding = (BLOCK - (int) (entry.declaredSize() % BLOCK)) % BLOCK; - tar.write(new byte[padding]); - } - - /** - * Writes {@code count} zero bytes to the stream. - * - * @param out The stream to write to. - * @param count The number of zero bytes. - * @throws IOException Thrown if writing fails. - */ - private static void writeZeros(ByteArrayOutputStream out, long count) throws IOException { - final byte[] zeros = new byte[8192]; - long remaining = count; - while (remaining > 0) { - final int chunk = (int) Math.min(zeros.length, remaining); - out.write(zeros, 0, chunk); - remaining -= chunk; - } - } -} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DictionaryCatalogTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DictionaryCatalogTest.java index 9233b297af..cbb65dfb3c 100644 --- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DictionaryCatalogTest.java +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DictionaryCatalogTest.java @@ -20,13 +20,17 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import opennlp.tools.stemmer.hunspell.HunspellDictionary; @@ -36,47 +40,152 @@ */ public class DictionaryCatalogTest { + private static final String SHA_512 = "0".repeat(128); + + /** Checks a catalog entry with all required fields. */ + @Test + void testEntryAcceptsItsDocumentedFields() { + final URI absolute = URI.create("https://example.invalid/dictionary.tar.gz"); + + Assertions.assertDoesNotThrow(() -> + new DictionaryCatalog.Entry("demo", absolute, "A".repeat(128), null)); + } + + /** + * Checks each invalid field of the public catalog entry record. + * + * @param field The invalid record field. + */ + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"id", "uri", "relative uri", "sha512", "invalid sha512", + "empty filename", "path filename"}) + void testEntryRejectsInvalidFields(String field) { + final URI absolute = URI.create("https://example.invalid/dictionary.tar.gz"); + final Executable construction = switch (field) { + case "id" -> () -> new DictionaryCatalog.Entry(null, absolute, SHA_512, null); + case "uri" -> () -> new DictionaryCatalog.Entry("demo", null, SHA_512, null); + case "relative uri" -> () -> new DictionaryCatalog.Entry( + "demo", URI.create("dictionary.tar.gz"), SHA_512, null); + case "sha512" -> () -> new DictionaryCatalog.Entry("demo", absolute, null, null); + case "invalid sha512" -> + () -> new DictionaryCatalog.Entry("demo", absolute, "not-a-digest", null); + case "empty filename" -> + () -> new DictionaryCatalog.Entry("demo", absolute, SHA_512, ""); + case "path filename" -> + () -> new DictionaryCatalog.Entry("demo", absolute, SHA_512, "../dict.bin"); + default -> throw new IllegalArgumentException("unknown field: " + field); + }; + + final IllegalArgumentException thrown = + Assertions.assertThrows(IllegalArgumentException.class, construction); + final String expectedMessage = switch (field) { + case "id" -> "id must not be null"; + case "uri" -> "uri must not be null"; + case "relative uri" -> "uri must be absolute"; + case "sha512" -> "sha512 must not be null"; + case "invalid sha512" -> "sha512 must be 128 hex digits"; + case "empty filename", "path filename" -> "filename must be a file name"; + default -> throw new IllegalArgumentException("unknown field: " + field); + }; + Assertions.assertEquals(expectedMessage, thrown.getMessage()); + } + + /** + * Checks that invalid property values are reported as catalog I/O errors. + * + * @throws IOException Thrown if a fixture catalog cannot be loaded. + */ + @Test + void testGetRejectsInvalidCatalogValues() throws IOException { + final DictionaryCatalog relativeUri = DictionaryCatalog.load(new ByteArrayInputStream( + ("demo.url=dictionary.tar.gz\ndemo.sha512=" + SHA_512 + "\n") + .getBytes(StandardCharsets.UTF_8))); + final DictionaryCatalog invalidDigest = DictionaryCatalog.load(new ByteArrayInputStream( + "demo.url=https://example.invalid/dictionary.tar.gz\ndemo.sha512=invalid\n" + .getBytes(StandardCharsets.UTF_8))); + + Assertions.assertAll( + () -> Assertions.assertThrows(IOException.class, () -> relativeUri.get("demo")), + () -> Assertions.assertThrows(IOException.class, () -> invalidDigest.get("demo"))); + } + + /** + * Checks argument validation before the opt-in remote setting is read. + * + * @param argument The invalid method parameter. + * @param dir A scratch directory managed by the test framework. + * @throws IOException Thrown if the fixture catalog cannot be loaded. + */ + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"id", "targetDirectory"}) + void testInstallValidatesArgumentsBeforeTheRemoteSetting(String argument, + @TempDir Path dir) + throws IOException { + final DictionaryCatalog catalog = DictionaryCatalog.load( + new ByteArrayInputStream(new byte[0])); + final String previous = System.getProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY); + System.clearProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY); + try { + final Executable install = switch (argument) { + case "id" -> () -> catalog.install(null, dir); + case "targetDirectory" -> () -> catalog.install("demo", null); + default -> throw new IllegalArgumentException("unknown argument: " + argument); + }; + final IllegalArgumentException thrown = + Assertions.assertThrows(IllegalArgumentException.class, install); + Assertions.assertEquals(argument + " must not be null", thrown.getMessage()); + } finally { + restore(previous); + } + } + /** - * Verifies that a catalog download without the remote-download property fails with - * the property name in the message. + * Verifies that a catalog install without the remote-download property fails with + * the property name in the message, before anything is fetched or created. * * @param dir A scratch directory managed by the test framework. * @throws Exception Thrown if the fixture catalog cannot be prepared. */ @Test - void testDownloadRequiresRemoteProperty(@TempDir Path dir) throws Exception { + void testInstallRequiresRemoteProperty(@TempDir Path dir) throws Exception { final byte[] payload = "payload".getBytes(StandardCharsets.UTF_8); final DictionaryCatalog loaded = demoCatalog(dir, payload); + final Path target = dir.resolve("out"); - final String previous = System.getProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY); - System.clearProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY); + final String previous = + System.getProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY); + System.clearProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY); try { final IOException e = Assertions.assertThrows(IOException.class, - () -> loaded.download("demo", dir.resolve("out.bin"))); - Assertions.assertTrue(e.getMessage().contains(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY)); + () -> loaded.install("demo", target)); + Assertions.assertTrue( + e.getMessage().contains(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY)); + Assertions.assertTrue(Files.notExists(target)); } finally { restore(previous); } } /** - * Verifies that an enabled catalog download fetches the entry and writes the - * digest-verified bytes to the target. + * Verifies that an enabled catalog install fetches the entry and stores its + * digest-verified bytes under the source name in the target directory. * * @param dir A scratch directory managed by the test framework. * @throws Exception Thrown if the fixture catalog cannot be prepared or fetched. */ @Test - void testDownloadWithRemotePropertyEnabled(@TempDir Path dir) throws Exception { + void testInstallStoresTheEntryUnderItsSourceName(@TempDir Path dir) throws Exception { final byte[] payload = "payload".getBytes(StandardCharsets.UTF_8); final DictionaryCatalog loaded = demoCatalog(dir, payload); - final Path target = dir.resolve("out.bin"); + final Path target = dir.resolve("out"); - final String previous = System.getProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY); - System.setProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY, "true"); + final String previous = + System.getProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY); + System.setProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY, "true"); try { - loaded.download("demo", target); - Assertions.assertArrayEquals(payload, Files.readAllBytes(target)); + loaded.install("demo", target); + Assertions.assertArrayEquals(payload, + Files.readAllBytes(target.resolve("dict.bin"))); } finally { restore(previous); } @@ -146,9 +255,9 @@ private static DictionaryCatalog demoCatalog(Path dir, byte[] payload) */ private static void restore(String previous) { if (previous == null) { - System.clearProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY); + System.clearProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY); } else { - System.setProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY, previous); + System.setProperty(DictionaryCatalog.REMOTE_DOWNLOAD_PROPERTY, previous); } } } diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DigestTestUtil.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DigestTestUtil.java index af85b09a8e..20c9e08cc6 100644 --- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DigestTestUtil.java +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DigestTestUtil.java @@ -22,22 +22,41 @@ import java.util.HexFormat; /** - * Computes SHA-512 digests for test fixtures. + * Computes digests for test fixtures. */ public final class DigestTestUtil { private DigestTestUtil() { } + /** + * {@return the SHA-256 digest of {@code bytes} as 64 lowercase hex digits} + * + * @param bytes The content to digest. Must not be {@code null}. + */ + public static String sha256(byte[] bytes) { + return digest("SHA-256", bytes); + } + /** * {@return the SHA-512 digest of {@code bytes} as 128 lowercase hex digits} * * @param bytes The content to digest. Must not be {@code null}. */ public static String sha512(byte[] bytes) { + return digest("SHA-512", bytes); + } + + /** + * {@return the digest of {@code bytes} as lowercase hex digits} + * + * @param algorithm The digest algorithm name. + * @param bytes The content to digest. Must not be {@code null}. + */ + private static String digest(String algorithm, byte[] bytes) { try { return HexFormat.of().formatHex( - MessageDigest.getInstance("SHA-512").digest(bytes)); + MessageDigest.getInstance(algorithm).digest(bytes)); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DownloadUtilFileTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DownloadUtilFileTest.java deleted file mode 100644 index 493bb20503..0000000000 --- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DownloadUtilFileTest.java +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package opennlp.tools.util; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -/** - * Pins {@link DownloadUtil#download(java.net.URI, Path, String)} against local file URIs - * so digest verification and the size ceiling are covered without a network. - */ -public class DownloadUtilFileTest { - - /** The fixture bytes the download tests serve and digest. */ - private static final byte[] PAYLOAD = "dictionary-bytes".getBytes(StandardCharsets.UTF_8); - - /** - * Verifies that a download whose bytes match the expected digest lands in the - * target file. - * - * @param dir A scratch directory managed by the test framework. - * @throws IOException Thrown if the fixture cannot be written or fetched. - */ - @Test - void testDownloadAcceptsMatchingDigest(@TempDir Path dir) throws IOException { - final Path source = dir.resolve("source.bin"); - Files.write(source, PAYLOAD); - final Path target = dir.resolve("target.bin"); - - DownloadUtil.download(source.toUri(), target, DigestTestUtil.sha512(PAYLOAD)); - - Assertions.assertArrayEquals(PAYLOAD, Files.readAllBytes(target)); - } - - /** - * Verifies that a digest mismatch fails the download and leaves no target file - * behind. - * - * @param dir A scratch directory managed by the test framework. - * @throws IOException Thrown if the fixture cannot be written. - */ - @Test - void testDownloadRejectsMismatchedDigest(@TempDir Path dir) throws IOException { - final Path source = dir.resolve("source.bin"); - Files.write(source, PAYLOAD); - final Path target = dir.resolve("target.bin"); - final String wrong = DigestTestUtil.sha512("other".getBytes(StandardCharsets.UTF_8)); - - final IOException e = Assertions.assertThrows(IOException.class, - () -> DownloadUtil.download(source.toUri(), target, wrong)); - Assertions.assertTrue(e.getMessage().contains("SHA512 checksum validation failed")); - Assertions.assertTrue(Files.notExists(target)); - } - - /** Verifies that a {@code null} digest is rejected with the documented exception. */ - @Test - void testDownloadRequiresSha512() { - Assertions.assertThrows(IllegalArgumentException.class, - () -> DownloadUtil.download(Path.of("x").toUri(), Path.of("y"), null)); - } - - /** - * Verifies that a digest shorter than 128 hex digits is rejected before anything is - * fetched. - * - * @param dir A scratch directory managed by the test framework. - * @throws IOException Thrown if the fixture cannot be written. - */ - @Test - void testDownloadRejectsMalformedSha512(@TempDir Path dir) throws IOException { - final Path source = dir.resolve("source.bin"); - Files.write(source, PAYLOAD); - - Assertions.assertThrows(IllegalArgumentException.class, - () -> DownloadUtil.download(source.toUri(), dir.resolve("target.bin"), "abc123")); - } - - /** - * Verifies that a source larger than the byte ceiling fails the download and leaves - * no target file behind. - * - * @param dir A scratch directory managed by the test framework. - * @throws IOException Thrown if the fixture cannot be written. - */ - @Test - void testDownloadRejectsOversizedSource(@TempDir Path dir) throws IOException { - final Path source = dir.resolve("source.bin"); - Files.write(source, PAYLOAD); - final Path target = dir.resolve("target.bin"); - - final IOException e = Assertions.assertThrows(IOException.class, - () -> DownloadUtil.download(source.toUri(), target, - DigestTestUtil.sha512(PAYLOAD), PAYLOAD.length - 1)); - Assertions.assertTrue(e.getMessage().contains("exceeds safe limit")); - Assertions.assertTrue(Files.notExists(target)); - } - - /** - * Pins the inclusive byte ceiling: a source of exactly the ceiling's size still - * downloads. - * - * @param dir A scratch directory managed by the test framework. - * @throws IOException Thrown if the fixture cannot be written or fetched. - */ - @Test - void testDownloadCeilingIsInclusive(@TempDir Path dir) throws IOException { - final Path source = dir.resolve("source.bin"); - Files.write(source, PAYLOAD); - final Path target = dir.resolve("target.bin"); - - DownloadUtil.download(source.toUri(), target, - DigestTestUtil.sha512(PAYLOAD), PAYLOAD.length); - - Assertions.assertArrayEquals(PAYLOAD, Files.readAllBytes(target)); - } - - /** Verifies that a positive property value overrides the fallback limit. */ - @Test - void testConfiguredLimitOverridesFromProperty() { - final String property = "opennlp.test.limit.override"; - System.setProperty(property, "1024"); - try { - Assertions.assertEquals(1024L, DownloadUtil.configuredLimit(property, 7L)); - } finally { - System.clearProperty(property); - } - } - - /** Verifies that an unset property falls back to the given default. */ - @Test - void testConfiguredLimitFallsBackWhenAbsent() { - Assertions.assertEquals(7L, - DownloadUtil.configuredLimit("opennlp.test.limit.absent", 7L)); - } - - /** Verifies that blank, non-numeric, and non-positive values fall back. */ - @ParameterizedTest(name = "value \"{0}\" falls back") - @ValueSource(strings = {"", " ", "abc", "-1", "0"}) - void testConfiguredLimitRejectsInvalidValues(String invalid) { - final String property = "opennlp.test.limit.invalid"; - System.setProperty(property, invalid); - try { - Assertions.assertEquals(7L, DownloadUtil.configuredLimit(property, 7L)); - } finally { - System.clearProperty(property); - } - } - - /** Pins the default download ceiling of 64 MiB when no override property is set. */ - @Test - void testDefaultBudgetsWithoutOverrides() { - Assertions.assertEquals(64L * 1024 * 1024, DownloadUtil.MAX_DOWNLOAD_BYTES); - } -} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/InstallerTestSupport.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/InstallerTestSupport.java new file mode 100644 index 0000000000..a341bc9c20 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/InstallerTestSupport.java @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import opennlp.tools.util.archive.TarArchives; + +/** + * Shared fixtures for the {@code ResourceInstaller} test classes: tar archive building, + * gzip compression, digest computation, and installed-file listing. + */ +final class InstallerTestSupport { + + static final int BLOCK = TarArchives.BLOCK; + static final int TERMINATOR_SIZE = TarArchives.TERMINATOR_SIZE; + + /** One kibibyte, a convenient small ceiling for limit tests. */ + static final long KIBIBYTE = 1024; + + /** One mebibyte, a convenient generous ceiling for tests that do not exercise it. */ + static final long MEBIBYTE = 1024 * KIBIBYTE; + + private InstallerTestSupport() { + } + + /** + * Writes one regular-file tar entry into the given buffer. + * + * @param tar The buffer receiving the entry bytes. Must not be {@code null}. + * @param name The entry name; at most 100 bytes when encoded as UTF-8. + * @param content The entry content. Must not be {@code null}. + * @throws IOException Thrown if writing to the buffer fails. + * @throws IllegalArgumentException Thrown if the name exceeds the tar name field. + */ + static void tarEntry(ByteArrayOutputStream tar, String name, byte[] content) + throws IOException { + TarArchives.entry(tar, name, content); + } + + /** + * Builds a gzip-compressed tar archive from name and content pairs. + * + * @param entries Pairs of entry name and UTF-8 text content. Must not be {@code null}. + * @return The archive bytes. Never {@code null}. + * @throws IOException Thrown if assembling the archive fails. + */ + static byte[] tarGz(String[][] entries) throws IOException { + return TarArchives.gzippedTar(entries); + } + + /** + * Compresses the given bytes with gzip. + * + * @param content The bytes to compress. Must not be {@code null}. + * @return The gzip-compressed bytes. Never {@code null}. + * @throws IOException Thrown if compressing fails. + */ + static byte[] gzip(byte[] content) throws IOException { + return TarArchives.gzip(content); + } + + /** + * Computes the SHA-256 of the given bytes as a lowercase hex string. + * + * @param content The bytes to digest. Must not be {@code null}. + * @return The 64-character lowercase hex digest. Never {@code null}. + */ + static String sha256(byte[] content) { + return DigestTestUtil.sha256(content); + } + + /** + * Computes the SHA-512 of the given bytes as a lowercase hex string. + * + * @param content The bytes to digest. Must not be {@code null}. + * @return The 128-character lowercase hex digest. Never {@code null}. + */ + static String sha512(byte[] content) { + return DigestTestUtil.sha512(content); + } + + /** + * Lists every regular file below the given directory as relative paths with forward + * slashes, sorted lexicographically, so tests can assert the exact installed file + * set. + * + * @param root The directory to walk. Must not be {@code null}. + * @return The sorted relative paths. Never {@code null}. + * @throws IOException Thrown if walking the directory fails. + */ + static List installedFiles(Path root) throws IOException { + try (Stream walk = Files.walk(root)) { + return walk.filter(Files::isRegularFile) + .map(file -> root.relativize(file).toString().replace('\\', '/')) + .sorted() + .toList(); + } + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/ResourceInstallerHttpTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/ResourceInstallerHttpTest.java new file mode 100644 index 0000000000..7e27bb528b --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/ResourceInstallerHttpTest.java @@ -0,0 +1,635 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static opennlp.tools.util.InstallerTestSupport.KIBIBYTE; +import static opennlp.tools.util.InstallerTestSupport.MEBIBYTE; +import static opennlp.tools.util.InstallerTestSupport.installedFiles; +import static opennlp.tools.util.InstallerTestSupport.sha256; +import static opennlp.tools.util.InstallerTestSupport.tarGz; + +/** + * Exercises {@link ResourceInstaller} against a local scripted HTTP server: happy + * downloads, redirect handling and its policy, error statuses, stalled responses + * against the read timeout, and download limits against incorrect or oversized bodies. + */ +public class ResourceInstallerHttpTest { + + private static final Duration GENEROUS = Duration.ofSeconds(10); + + /** The limits these tests never exercise, kept at their defaults. */ + private static final long DEFAULT_ENTRIES = + ResourceInstaller.Limits.DEFAULT.maxEntries(); + private static final long DEFAULT_RATIO = + ResourceInstaller.Limits.DEFAULT.maxExpansionRatio(); + + /** Long enough that a stalled route outlives any timeout a test configures. */ + private static final Duration STALL = Duration.ofSeconds(30); + + /** + * A well-formed digest for fetches that fail before verification runs; its value is + * never compared. + */ + private static final String UNREACHED_CHECKSUM = "0".repeat(64); + + private StubServer server; + + @BeforeEach + void startServer() throws IOException { + server = new StubServer(); + } + + @AfterEach + void stopServer() throws IOException { + server.close(); + } + + /** + * Builds installation limits with the given read timeout and otherwise generous + * values, so timeout tests state only the value they exercise. + * + * @param readTimeout The read timeout to apply. + * @return The limits. Never {@code null}. + */ + private static ResourceInstaller.Limits withReadTimeout(Duration readTimeout) { + return new ResourceInstaller.Limits(GENEROUS, readTimeout, 5, MEBIBYTE, MEBIBYTE, + DEFAULT_ENTRIES, DEFAULT_RATIO); + } + + /** + * Builds installation limits with the given redirect allowance and otherwise + * generous values. + * + * @param maxRedirects The number of redirects to follow. + * @return The limits. Never {@code null}. + */ + private static ResourceInstaller.Limits withMaxRedirects(int maxRedirects) { + return new ResourceInstaller.Limits(GENEROUS, GENEROUS, maxRedirects, + MEBIBYTE, MEBIBYTE, DEFAULT_ENTRIES, DEFAULT_RATIO); + } + + /** + * Builds installation limits with the given download limit and otherwise generous + * values. + * + * @param maxDownloadBytes The download limit in bytes. + * @return The limits. Never {@code null}. + */ + private static ResourceInstaller.Limits withDownloadLimit(long maxDownloadBytes) { + return new ResourceInstaller.Limits(GENEROUS, GENEROUS, 5, maxDownloadBytes, + MEBIBYTE, DEFAULT_ENTRIES, DEFAULT_RATIO); + } + + /** + * Asserts that the given call is rejected as an argument error demanding a checksum + * for the remote source. + * + * @param source The remote source the message must name. + * @param call The call under test. + */ + private static void assertChecksumRequired(URI source, Executable call) { + final IllegalArgumentException thrown = + Assertions.assertThrows(IllegalArgumentException.class, call); + Assertions.assertEquals( + "checksum must be given for an http or https source: " + source, + thrown.getMessage()); + } + + @Test + void testHttpSourceWithoutChecksumIsRejectedBeforeFetching(@TempDir Path target) + throws Exception { + final AtomicBoolean fetched = new AtomicBoolean(); + server.route("/corpus.tar.gz", out -> { + fetched.set(true); + StubServer.ok(out, tarGz(new String[][] {{"corpus/data.txt", "unverified"}})); + }); + + final URI source = server.uri("/corpus.tar.gz"); + Assertions.assertAll( + () -> assertChecksumRequired(source, + () -> ResourceInstaller.install(source, target)), + () -> assertChecksumRequired(source, + () -> ResourceInstaller.install(source, target, null)), + () -> assertChecksumRequired(source, + () -> ResourceInstaller.install(source, target, null, withMaxRedirects(5)))); + Assertions.assertFalse(fetched.get()); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + void testHttpsSourceWithoutChecksumIsRejectedBeforeCreatingTheTarget( + @TempDir Path parent) { + final Path target = parent.resolve("not-created-yet"); + final URI source = URI.create("https://example.invalid/corpus.tar.gz"); + + assertChecksumRequired(source, () -> ResourceInstaller.install(source, target)); + Assertions.assertTrue(Files.notExists(target)); + } + + @Test + void testHttpDownloadInstallsArchive(@TempDir Path target) throws Exception { + final byte[] archive = tarGz(new String[][] {{"corpus/data.txt", "over http"}}); + server.route("/corpus.tar.gz", out -> StubServer.ok(out, archive)); + + ResourceInstaller.install(server.uri("/corpus.tar.gz"), target, sha256(archive)); + + Assertions.assertEquals("over http", + Files.readString(target.resolve("corpus/data.txt"))); + } + + @Test + void testInvalidSourceNameIsRejectedBeforeFetching(@TempDir Path parent) + throws Exception { + final AtomicBoolean fetched = new AtomicBoolean(); + final byte[] content = "dictionary".getBytes(StandardCharsets.UTF_8); + server.route("/bad%00name.dat", out -> { + fetched.set(true); + StubServer.ok(out, content); + }); + final Path target = parent.resolve("not-created-yet"); + + final IllegalArgumentException thrown = Assertions.assertThrows( + IllegalArgumentException.class, + () -> ResourceInstaller.install(server.uri("/bad%00name.dat"), target, + sha256(content))); + + Assertions.assertEquals("name must be a file name", thrown.getMessage()); + Assertions.assertFalse(fetched.get()); + Assertions.assertTrue(Files.notExists(target)); + } + + @Test + void testAbsoluteRedirectIsFollowed(@TempDir Path target) throws Exception { + final byte[] archive = tarGz(new String[][] {{"corpus/data.txt", "moved"}}); + server.route("/old.tar.gz", out -> StubServer.redirect(out, + server.uri("/new.tar.gz").toString())); + server.route("/new.tar.gz", out -> StubServer.ok(out, archive)); + + ResourceInstaller.install(server.uri("/old.tar.gz"), target, sha256(archive)); + + Assertions.assertEquals("moved", + Files.readString(target.resolve("corpus/data.txt"))); + } + + @Test + void testRelativeRedirectIsResolvedAgainstTheSource(@TempDir Path target) + throws Exception { + final byte[] archive = tarGz(new String[][] {{"corpus/data.txt", "relative"}}); + server.route("/mirror/old.tar.gz", + out -> StubServer.redirect(out, "new.tar.gz")); + server.route("/mirror/new.tar.gz", out -> StubServer.ok(out, archive)); + + ResourceInstaller.install(server.uri("/mirror/old.tar.gz"), target, sha256(archive)); + + Assertions.assertEquals("relative", + Files.readString(target.resolve("corpus/data.txt"))); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"301 Moved Permanently", "302 Found", "303 See Other", + "307 Temporary Redirect", "308 Permanent Redirect"}) + void testEveryRedirectStatusIsFollowed(String status, @TempDir Path target) + throws Exception { + final byte[] archive = tarGz(new String[][] {{"corpus/data.txt", "followed"}}); + server.route("/old.tar.gz", out -> StubServer.redirect(out, status, "/new.tar.gz")); + server.route("/new.tar.gz", out -> StubServer.ok(out, archive)); + + ResourceInstaller.install(server.uri("/old.tar.gz"), target, sha256(archive)); + + Assertions.assertEquals("followed", + Files.readString(target.resolve("corpus/data.txt"))); + } + + @Test + void testZeroRedirectAllowanceRejectsTheFirstRedirect(@TempDir Path target) + throws Exception { + server.route("/once", out -> StubServer.redirect(out, + server.uri("/anywhere.tar.gz").toString())); + + final URI source = server.uri("/once"); + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(source, target, UNREACHED_CHECKSUM, withMaxRedirects(0))); + Assertions.assertEquals("more than 0 redirects: " + source, thrown.getMessage()); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + void testMalformedRedirectLocationFails() { + final URI from = URI.create("http://example.invalid/archive.tar.gz"); + + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.resolveRedirect(from, "http://mirror.invalid/bad path")); + Assertions.assertEquals("redirect from " + from + + " contains a malformed Location: http://mirror.invalid/bad path", + thrown.getMessage()); + } + + @Test + void testRedirectChainBeyondLimitFails(@TempDir Path target) throws Exception { + server.route("/loop", out -> StubServer.redirect(out, "/loop")); + + final URI source = server.uri("/loop"); + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(source, target, UNREACHED_CHECKSUM, withMaxRedirects(3))); + Assertions.assertEquals("more than 3 redirects: " + source, thrown.getMessage()); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + void testRedirectWithoutLocationFails(@TempDir Path target) throws Exception { + server.route("/broken", out -> StubServer.head(out, "302 Found", + "Content-Length: 4", "", "gone")); + + final URI source = server.uri("/broken"); + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(source, target, UNREACHED_CHECKSUM)); + Assertions.assertEquals("redirect from " + source + " contains no Location header", + thrown.getMessage()); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + void testRedirectToNonHttpSchemeFails(@TempDir Path target) throws Exception { + server.route("/non-http", out -> StubServer.redirect(out, "file:///etc/passwd")); + + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(server.uri("/non-http"), target, + UNREACHED_CHECKSUM)); + Assertions.assertEquals( + "redirect target is not an http or https location: file:///etc/passwd", + thrown.getMessage()); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + void testHttpsToHttpDowngradeIsRejected() { + final URI https = URI.create("https://example.invalid/archive.tar.gz"); + + // Cover the rejected downgrade and both accepted upgrade and same-scheme cases. + Assertions.assertAll( + () -> { + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.resolveRedirect(https, + "http://example.invalid/archive.tar.gz")); + Assertions.assertEquals( + "redirect downgrades https to http: http://example.invalid/archive.tar.gz", + thrown.getMessage()); + }, + () -> Assertions.assertDoesNotThrow(() -> ResourceInstaller.resolveRedirect(https, + "https://mirror.invalid/archive.tar.gz")), + () -> Assertions.assertDoesNotThrow(() -> ResourceInstaller.resolveRedirect( + URI.create("http://example.invalid/archive.tar.gz"), + "https://mirror.invalid/archive.tar.gz"))); + } + + @Test + void testHttpErrorStatusFails(@TempDir Path target) throws Exception { + server.route("/missing.tar.gz", out -> StubServer.head(out, "404 Not Found", + "Content-Length: 0", "")); + + final URI source = server.uri("/missing.tar.gz"); + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(source, target, UNREACHED_CHECKSUM)); + Assertions.assertEquals("download failed with HTTP status 404: " + source, + thrown.getMessage()); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + void testStalledResponseHitsReadTimeout(@TempDir Path target) { + server.route("/stall", out -> StubServer.sleep(STALL)); + + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(server.uri("/stall"), target, + UNREACHED_CHECKSUM, withReadTimeout(Duration.ofMillis(250)))); + Assertions.assertInstanceOf(SocketTimeoutException.class, thrown); + } + + @Test + void testStalledBodyHitsReadTimeout(@TempDir Path target) throws Exception { + server.route("/drip", out -> { + StubServer.head(out, "200 OK", "Content-Length: 100000", ""); + out.write("just a few bytes".getBytes(StandardCharsets.UTF_8)); + out.flush(); + StubServer.sleep(STALL); + }); + + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(server.uri("/drip"), target, + UNREACHED_CHECKSUM, withReadTimeout(Duration.ofMillis(250)))); + Assertions.assertInstanceOf(SocketTimeoutException.class, thrown); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + void testDeclaredContentLengthBeyondLimitFailsFast(@TempDir Path target) + throws Exception { + server.route("/liar.tar.gz", out -> StubServer.head(out, "200 OK", + "Content-Length: 10000000", "")); + + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(server.uri("/liar.tar.gz"), target, + UNREACHED_CHECKSUM, withDownloadLimit(KIBIBYTE))); + Assertions.assertEquals( + "declared content length 10000000 exceeds the download limit of 1024 bytes", + thrown.getMessage()); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + void testStreamedBodyBeyondLimitAborts(@TempDir Path target) throws Exception { + server.route("/endless", out -> { + StubServer.head(out, "200 OK", ""); + out.write(new byte[64 * 1024]); + out.flush(); + }); + + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(server.uri("/endless"), target, + UNREACHED_CHECKSUM, withDownloadLimit(KIBIBYTE))); + Assertions.assertEquals("download exceeds the limit of 1024 bytes", + thrown.getMessage()); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + @Timeout(value = 15, threadMode = Timeout.ThreadMode.SEPARATE_THREAD) + void testSubMillisecondReadTimeoutStillTimesOut(@TempDir Path target) { + server.route("/stall", out -> StubServer.sleep(STALL)); + final ResourceInstaller.Limits limits = ResourceInstaller.Limits.builder() + .readTimeout(Duration.ofNanos(1)) + .build(); + + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(server.uri("/stall"), target, + UNREACHED_CHECKSUM, limits)); + + Assertions.assertInstanceOf(SocketTimeoutException.class, thrown); + } + + + @Test + void testTimeoutBeyondTheMillisecondRangeIsCapped(@TempDir Path target) + throws Exception { + final byte[] archive = tarGz(new String[][] {{"payload/data.txt", "content"}}); + server.route("/payload.tar.gz", out -> StubServer.ok(out, archive)); + final Duration beyondMillis = Duration.ofSeconds(Long.MAX_VALUE / 1000 + 1); + final ResourceInstaller.Limits limits = ResourceInstaller.Limits.builder() + .connectTimeout(beyondMillis) + .readTimeout(beyondMillis) + .build(); + + ResourceInstaller.install(server.uri("/payload.tar.gz"), target, sha256(archive), + limits); + + Assertions.assertEquals("content", + Files.readString(target.resolve("payload/data.txt"))); + } + + /** + * A scripted HTTP server on a loopback socket. Each registered route writes a raw + * response for redirect, timeout, malformed-response, and size-limit tests. + */ + private static final class StubServer implements AutoCloseable { + + /** Writes a raw HTTP response to the connected client. */ + @FunctionalInterface + interface Responder { + + /** + * Writes the raw response bytes for one request. + * + * @param out The response stream. + * @throws IOException Thrown if writing fails. + */ + void respond(OutputStream out) throws IOException; + } + + private final ServerSocket socket; + private final Map routes = new ConcurrentHashMap<>(); + private final List connections = new ArrayList<>(); + + StubServer() throws IOException { + socket = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + final Thread acceptor = new Thread(this::acceptLoop, "stub-http-acceptor"); + acceptor.setDaemon(true); + acceptor.start(); + } + + /** + * Registers the responder serving the given absolute request path. + * + * @param path The absolute request path, starting with {@code /}. + * @param responder The script producing the raw response. + */ + void route(String path, Responder responder) { + routes.put(path, responder); + } + + /** + * Builds the http URI of the given absolute path on this server. + * + * @param path The absolute request path, starting with {@code /}. + * @return The URI. Never {@code null}. + */ + URI uri(String path) { + return URI.create("http://127.0.0.1:" + socket.getLocalPort() + path); + } + + /** + * Writes the HTTP/1.0 status line followed by the given lines, each terminated by + * CRLF. The caller supplies the header lines, then an empty string for the blank + * line separating head from body, then optional body text. + * + * @param out The response stream. + * @param status The status line content after the protocol, such as {@code 200 OK}. + * @param lines Header lines, then an empty string separator, then optional body + * text, each written with a trailing CRLF. + * @throws IOException Thrown if writing fails. + */ + static void head(OutputStream out, String status, String... lines) + throws IOException { + final StringBuilder response = new StringBuilder("HTTP/1.0 ").append(status) + .append("\r\n"); + for (final String line : lines) { + response.append(line).append("\r\n"); + } + out.write(response.toString().getBytes(StandardCharsets.US_ASCII)); + out.flush(); + } + + /** + * Writes a complete 200 response carrying the given body with its exact length. + * + * @param out The response stream. + * @param body The response body bytes. + * @throws IOException Thrown if writing fails. + */ + static void ok(OutputStream out, byte[] body) throws IOException { + head(out, "200 OK", "Content-Length: " + body.length, ""); + out.write(body); + out.flush(); + } + + /** + * Writes a 302 redirect to the given location. + * + * @param out The response stream. + * @param location The Location header value, absolute or relative. + * @throws IOException Thrown if writing fails. + */ + static void redirect(OutputStream out, String location) throws IOException { + redirect(out, "302 Found", location); + } + + /** + * Writes a redirect with the given status to the given location. + * + * @param out The response stream. + * @param status The status line content after the protocol, such as + * {@code 301 Moved Permanently}. + * @param location The Location header value, absolute or relative. + * @throws IOException Thrown if writing fails. + */ + static void redirect(OutputStream out, String status, String location) + throws IOException { + head(out, status, "Location: " + location, "Content-Length: 0", ""); + } + + /** + * Blocks the handler thread, simulating a stalled server. Interruption during + * teardown ends the sleep early. + * + * @param duration How long to stall. + */ + static void sleep(Duration duration) { + try { + Thread.sleep(duration.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void acceptLoop() { + while (!socket.isClosed()) { + final Socket connection; + try { + connection = socket.accept(); + } catch (IOException e) { + return; + } + synchronized (connections) { + connections.add(connection); + } + final Thread handler = new Thread(() -> handle(connection), + "stub-http-handler"); + handler.setDaemon(true); + handler.start(); + } + } + + /** + * Reads one request, dispatches it to the registered responder, and closes the + * connection. Write failures from clients that abort mid-transfer are expected + * and ignored. + * + * @param connection The accepted client connection. + */ + private void handle(Socket connection) { + try (connection) { + final String path = readRequestPath(connection.getInputStream()); + final Responder responder = routes.get(path); + if (responder == null) { + head(connection.getOutputStream(), "404 Not Found", "Content-Length: 0", ""); + return; + } + responder.respond(connection.getOutputStream()); + } catch (IOException e) { + // The client hung up or the server is shutting down; both are test-normal. + } + } + + /** + * Reads the request head up to its terminating blank line and returns the path + * from the request line. + * + * @param in The request stream. + * @return The request path. Never {@code null}. + * @throws IOException Thrown if the request head is malformed or truncated. + */ + private static String readRequestPath(InputStream in) throws IOException { + final ByteArrayOutputStream headBytes = new ByteArrayOutputStream(); + int last4 = 0; + int b; + while ((b = in.read()) >= 0) { + headBytes.write(b); + last4 = (last4 << 8) | b; + if (last4 == 0x0D0A0D0A) { + break; + } + } + final String head = headBytes.toString(StandardCharsets.US_ASCII); + final int firstLineEnd = head.indexOf("\r\n"); + final String requestLine = firstLineEnd < 0 ? head : head.substring(0, firstLineEnd); + final String[] parts = requestLine.split(" "); + if (parts.length < 2) { + throw new IOException("malformed request line: " + requestLine); + } + return parts[1]; + } + + @Override + public void close() throws IOException { + socket.close(); + synchronized (connections) { + for (final Socket connection : connections) { + connection.close(); + } + } + } + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/ResourceInstallerTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/ResourceInstallerTest.java new file mode 100644 index 0000000000..f336234142 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/ResourceInstallerTest.java @@ -0,0 +1,1412 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Stream; +import java.util.zip.GZIPOutputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import opennlp.tools.util.archive.TarArchives; + +import static opennlp.tools.util.InstallerTestSupport.BLOCK; +import static opennlp.tools.util.InstallerTestSupport.KIBIBYTE; +import static opennlp.tools.util.InstallerTestSupport.MEBIBYTE; +import static opennlp.tools.util.InstallerTestSupport.TERMINATOR_SIZE; +import static opennlp.tools.util.InstallerTestSupport.gzip; +import static opennlp.tools.util.InstallerTestSupport.installedFiles; +import static opennlp.tools.util.InstallerTestSupport.sha256; +import static opennlp.tools.util.InstallerTestSupport.sha512; +import static opennlp.tools.util.InstallerTestSupport.tarEntry; +import static opennlp.tools.util.InstallerTestSupport.tarGz; + +public class ResourceInstallerTest { + + private static final String CHECKSUM_ARGUMENT_ERROR = + "checksum must be 64 (SHA-256) or 128 (SHA-512) hex characters; pass null to skip"; + private static final String ESCAPE_ERROR = + "archive entry escapes the target directory: "; + private static final String EXPANSION_LIMIT_ERROR = + "expanded content exceeds the limit of " + KIBIBYTE + " bytes"; + private static final String ENTRY_LIMIT_ERROR = + "archive entry count exceeds the limit of 2 entries"; + private static final String COLLISION_ERROR = "target already contains: "; + private static final String DUPLICATE_ENTRY_ERROR = + "archive contains duplicate file entry: "; + private static final String RATIO_ERROR = + "content expands beyond 100 times its compressed size"; + private static final String ZIP_MISMATCH_ERROR = + "zip local headers and central directory list different files"; + + /** The property name used by the parser tests; never read by the installer. */ + private static final String TEST_LIMIT_PROPERTY = "opennlp.test.limit"; + + /** + * Installs the given file under the default limits and asserts that it fails with the + * expected message, leaving the target directory without a single installed file. + * + * @param file The source file to install. + * @param target The target directory, which must have been empty before the attempt. + * @param message The exact failure message expected. + * @throws IOException Thrown if listing the target directory fails. + */ + private static void assertInstallFails(Path file, Path target, String message) + throws IOException { + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(file.toUri(), target)); + Assertions.assertEquals(message, thrown.getMessage()); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + /** + * Installs the given file under the given limits and asserts that it fails with the + * expected message, leaving the target directory without a single installed file. + * + * @param file The source file to install. + * @param target The target directory, which must have been empty before the attempt. + * @param limits The limits to install under. + * @param message The exact failure message expected. + * @throws IOException Thrown if listing the target directory fails. + */ + private static void assertInstallFails(Path file, Path target, + ResourceInstaller.Limits limits, String message) throws IOException { + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(file.toUri(), target, null, limits)); + Assertions.assertEquals(message, thrown.getMessage()); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + /** + * Computes the SHA-256 of the given bytes as an uppercase hex string, so tests can + * prove that checksum comparison does not depend on the hex letter case. + * + * @param content The bytes to digest. Must not be {@code null}. + * @return The 64-character uppercase hex digest. Never {@code null}. + * @throws NoSuchAlgorithmException Thrown if the digest algorithm is unavailable. + */ + private static String sha256UpperCase(byte[] content) throws NoSuchAlgorithmException { + return sha256(content).toUpperCase(Locale.ROOT); + } + + /** + * Builds installation limits with generous timeouts and redirect allowance but the + * given size limits, so limit tests state only the values they exercise. + * + * @param maxDownloadBytes The download limit in bytes. + * @param maxExpandedBytes The expansion limit in bytes. + * @return The limits. Never {@code null}. + */ + private static ResourceInstaller.Limits limits(long maxDownloadBytes, + long maxExpandedBytes) { + return new ResourceInstaller.Limits(Duration.ofSeconds(10), Duration.ofSeconds(10), + 5, maxDownloadBytes, maxExpandedBytes, + ResourceInstaller.Limits.DEFAULT.maxEntries(), + ResourceInstaller.Limits.DEFAULT.maxExpansionRatio()); + } + + @Test + void testInstallEndToEndUsageExample(@TempDir Path source, @TempDir Path target) + throws Exception { + final byte[] archive = tarGz(new String[][] { + {"corpus/README", "A tiny example corpus.\n"}, + {"corpus/tokens.txt", "the\ncat\n"}, + {"corpus/pos/tags.tsv", "the\tDET\ncat\tNOUN\n"}}); + final Path file = source.resolve("corpus.tar.gz"); + Files.write(file, archive); + + final Path result = ResourceInstaller.install(file.toUri(), target, sha256(archive)); + + Assertions.assertEquals(target, result); + Assertions.assertEquals( + List.of("corpus/README", "corpus/pos/tags.tsv", "corpus/tokens.txt"), + installedFiles(target)); + Assertions.assertEquals("A tiny example corpus.\n", + Files.readString(target.resolve("corpus/README"))); + Assertions.assertEquals("the\ncat\n", + Files.readString(target.resolve("corpus/tokens.txt"))); + Assertions.assertEquals("the\tDET\ncat\tNOUN\n", + Files.readString(target.resolve("corpus/pos/tags.tsv"))); + } + + @Test + void testTarGzUnpacksWithStructure(@TempDir Path source, @TempDir Path target) + throws Exception { + final byte[] archive = tarGz(new String[][] { + {"corpus-1.0/train.conllu", "# sent_id = 1\n"}, + {"corpus-1.0/sub/readme.txt", "hello"}}); + final Path file = source.resolve("corpus.tgz"); + Files.write(file, archive); + + ResourceInstaller.install(file.toUri(), target, sha256(archive)); + + Assertions.assertEquals("# sent_id = 1\n", + Files.readString(target.resolve("corpus-1.0/train.conllu"))); + Assertions.assertEquals("hello", + Files.readString(target.resolve("corpus-1.0/sub/readme.txt"))); + } + + @Test + void testChecksumMismatchFailsBeforeUnpacking(@TempDir Path source, + @TempDir Path target) throws Exception { + final byte[] archive = tarGz(new String[][] {{"a/file.txt", "content"}}); + final Path file = source.resolve("archive.tar.gz"); + Files.write(file, archive); + + final String wrong = "0".repeat(64); + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(file.toUri(), target, wrong)); + Assertions.assertEquals("checksum mismatch: expected " + wrong + + " but downloaded " + sha256(archive), thrown.getMessage()); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + void testChecksumComparisonIgnoresHexLetterCase(@TempDir Path source, + @TempDir Path target) throws Exception { + final byte[] archive = tarGz(new String[][] {{"data/entry.txt", "payload"}}); + final Path file = source.resolve("cased.tar.gz"); + Files.write(file, archive); + // The uppercase digest must differ textually from the lowercase one, otherwise + // this test would not exercise the case handling at all. + Assertions.assertNotEquals(sha256(archive), sha256UpperCase(archive)); + + ResourceInstaller.install(file.toUri(), target, sha256UpperCase(archive)); + + Assertions.assertEquals("payload", + Files.readString(target.resolve("data/entry.txt"))); + } + + @Test + void testChecksumIgnoresUnicodeWhitespace(@TempDir Path source, + @TempDir Path target) throws Exception { + final byte[] archive = tarGz(new String[][] {{"corpus/data.txt", "verified"}}); + final Path file = source.resolve("unicode-space.tar.gz"); + Files.write(file, archive); + final String emSpace = Character.toString(0x2003); + + ResourceInstaller.install(file.toUri(), target, + emSpace + sha256(archive) + emSpace); + + Assertions.assertEquals("verified", + Files.readString(target.resolve("corpus/data.txt"))); + } + + @Test + void testEscapingEntriesAreRejected(@TempDir Path source, @TempDir Path target) + throws Exception { + final byte[] archive = tarGz(new String[][] {{"../escape.txt", "bad"}}); + final Path file = source.resolve("evil.tar.gz"); + Files.write(file, archive); + + assertInstallFails(file, target, ESCAPE_ERROR + "../escape.txt"); + Assertions.assertTrue(Files.notExists(target.getParent().resolve("escape.txt"))); + } + + @Test + void testAbsoluteTarEntryIsRejected(@TempDir Path source, @TempDir Path target) + throws Exception { + final byte[] archive = tarGz(new String[][] { + {"/absolute-escape-attempt/evil.txt", "bad"}}); + final Path file = source.resolve("absolute.tar.gz"); + Files.write(file, archive); + + assertInstallFails(file, target, + ESCAPE_ERROR + "/absolute-escape-attempt/evil.txt"); + Assertions.assertTrue(Files.notExists(Path.of("/absolute-escape-attempt"))); + } + + @Test + void testEscapingTarDirectoryEntryIsRejected(@TempDir Path source, + @TempDir Path target) throws Exception { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + TarArchives.entry(tar, "../outside/", new byte[0], TarArchives.TYPE_DIRECTORY); + tar.write(new byte[TERMINATOR_SIZE]); + final Path file = source.resolve("directory-escape.tar.gz"); + Files.write(file, gzip(tar.toByteArray())); + + assertInstallFails(file, target, ESCAPE_ERROR + "../outside/"); + } + + @Test + void testZipEntryWithTraversalIsRejected(@TempDir Path source, @TempDir Path target) + throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("../zip-escape.txt")); + zip.write("bad".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + final Path file = source.resolve("evil.zip"); + Files.write(file, out.toByteArray()); + + assertInstallFails(file, target, ESCAPE_ERROR + "../zip-escape.txt"); + Assertions.assertTrue(Files.notExists(target.getParent().resolve("zip-escape.txt"))); + } + + @Test + void testEscapingZipDirectoryEntryIsRejected(@TempDir Path source, + @TempDir Path target) throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("../outside/")); + zip.closeEntry(); + } + final Path file = source.resolve("directory-escape.zip"); + Files.write(file, out.toByteArray()); + + assertInstallFails(file, target, ESCAPE_ERROR + "../outside/"); + } + + @Test + void testZipUnpacks(@TempDir Path source, @TempDir Path target) throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("lexicon/words.txt")); + zip.write("cat 100\n".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + final Path file = source.resolve("lexicon.zip"); + Files.write(file, out.toByteArray()); + + ResourceInstaller.install(file.toUri(), target); + + Assertions.assertEquals("cat 100\n", + Files.readString(target.resolve("lexicon/words.txt"))); + } + + /** + * Checks that ZIP validation works when the target uses a non-default file system. + * + * @param source The directory containing the source ZIP file. + * @param scratch The directory containing the target file system. + * @throws IOException Thrown if the fixture or installation cannot be read or written. + */ + @Test + void testZipUnpacksIntoANonDefaultFileSystem(@TempDir Path source, + @TempDir Path scratch) throws IOException { + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + zip.putNextEntry(new ZipEntry("payload/data.txt")); + zip.write("content".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + final Path archive = source.resolve("payload.zip"); + Files.write(archive, bytes.toByteArray()); + final URI targetUri = URI.create("jar:" + scratch.resolve("target.zip").toUri()); + + try (FileSystem fileSystem = + FileSystems.newFileSystem(targetUri, Map.of("create", "true"))) { + final Path target = fileSystem.getPath("/installed"); + + ResourceInstaller.install(archive.toUri(), target); + + Assertions.assertEquals("content", + Files.readString(target.resolve("payload/data.txt"))); + } + } + + @Test + void testModelBinIsStoredPacked(@TempDir Path source, @TempDir Path target) + throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("manifest.properties")); + zip.write("OpenNLP-Version: 0.0.0\n".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + final Path file = source.resolve("en-ner-person.bin"); + Files.write(file, out.toByteArray()); + + ResourceInstaller.install(file.toUri(), target); + + Assertions.assertEquals(List.of("en-ner-person.bin"), installedFiles(target)); + Assertions.assertArrayEquals(out.toByteArray(), + Files.readAllBytes(target.resolve("en-ner-person.bin"))); + } + + @Test + void testPlainGzipDecompressesToTheSourceName(@TempDir Path source, + @TempDir Path target) throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(out)) { + gzip.write("word\tlemma\n".getBytes(StandardCharsets.UTF_8)); + } + final Path file = source.resolve("lexicon.tsv.gz"); + Files.write(file, out.toByteArray()); + + ResourceInstaller.install(file.toUri(), target); + + Assertions.assertEquals("word\tlemma\n", + Files.readString(target.resolve("lexicon.tsv"))); + } + + @Test + void testPlainGzipWithoutABaseNameUsesResource(@TempDir Path source, + @TempDir Path target) throws Exception { + final Path file = source.resolve(".gz"); + Files.write(file, gzip("word\tlemma\n".getBytes(StandardCharsets.UTF_8))); + + ResourceInstaller.install(file.toUri(), target); + + Assertions.assertEquals("word\tlemma\n", + Files.readString(target.resolve("resource"))); + } + + @Test + void testPlainFilesAreStoredUnderTheirSourceName(@TempDir Path source, + @TempDir Path target) throws Exception { + final Path file = source.resolve("frequencies.txt"); + Files.writeString(file, "cat 100"); + + ResourceInstaller.install(file.toUri(), target); + + Assertions.assertEquals("cat 100", Files.readString(target.resolve("frequencies.txt"))); + } + + /** + * Checks each required installation parameter. + * + * @param argument The invalid method parameter. + * @param target A scratch directory managed by the test framework. + */ + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"source", "targetDirectory", "limits"}) + void testInvalidArguments(String argument, @TempDir Path target) { + final Executable call = switch (argument) { + case "source" -> () -> ResourceInstaller.install(null, target); + case "targetDirectory" -> () -> ResourceInstaller.install(target.toUri(), null); + case "limits" -> + () -> ResourceInstaller.install(target.toUri(), target, null, null); + default -> throw new IllegalArgumentException("unknown argument: " + argument); + }; + + assertArgumentError(argument + " must not be null", call); + } + + /** + * Supplies checksum arguments that are neither a valid SHA-256 nor a valid SHA-512 + * digest. + * + * @return One case per rejected digest. Never {@code null}. + */ + static Stream rejectedChecksums() { + return Stream.of( + Arguments.of("blank", " "), + Arguments.of("too short", "abc123"), + Arguments.of("right length, non-hex characters", "g".repeat(64)), + Arguments.of("between the two supported lengths", "a".repeat(96)), + Arguments.of("longer than SHA-512", "a".repeat(129))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("rejectedChecksums") + void testInvalidChecksumIsRejected(String label, String checksum, @TempDir Path target) { + assertArgumentError(CHECKSUM_ARGUMENT_ERROR, + () -> ResourceInstaller.install(target.toUri(), target, checksum)); + } + + /** + * Asserts that the given call fails as an argument error carrying the exact message. + * + * @param message The exact failure message expected. + * @param call The call under test. + */ + private static void assertArgumentError(String message, Executable call) { + final IllegalArgumentException thrown = + Assertions.assertThrows(IllegalArgumentException.class, call); + Assertions.assertEquals(message, thrown.getMessage()); + } + + @Test + void testSha512ChecksumVerifies(@TempDir Path source, @TempDir Path target) + throws Exception { + final byte[] archive = tarGz(new String[][] {{"data/entry.txt", "payload"}}); + final Path file = source.resolve("checked.tar.gz"); + Files.write(file, archive); + + ResourceInstaller.install(file.toUri(), target, sha512(archive)); + + Assertions.assertEquals("payload", + Files.readString(target.resolve("data/entry.txt"))); + } + + @Test + void testSha512ChecksumMismatchFailsBeforeUnpacking(@TempDir Path source, + @TempDir Path target) throws Exception { + final byte[] archive = tarGz(new String[][] {{"a/file.txt", "content"}}); + final Path file = source.resolve("archive.tar.gz"); + Files.write(file, archive); + + final String wrong = "0".repeat(128); + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(file.toUri(), target, wrong)); + Assertions.assertEquals("checksum mismatch: expected " + wrong + + " but downloaded " + sha512(archive), thrown.getMessage()); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + void testFailedTarUnpackLeavesTargetEmpty(@TempDir Path source, @TempDir Path target) + throws Exception { + final byte[] archive = tarGz(new String[][] { + {"good.txt", "fine"}, + {"../escape.txt", "bad"}}); + final Path file = source.resolve("partial.tar.gz"); + Files.write(file, archive); + + assertInstallFails(file, target, ESCAPE_ERROR + "../escape.txt"); + } + + @Test + void testFailedZipUnpackLeavesTargetEmpty(@TempDir Path source, @TempDir Path target) + throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("good.txt")); + zip.write("fine".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + zip.putNextEntry(new ZipEntry("../zip-escape.txt")); + zip.write("bad".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + final Path file = source.resolve("partial.zip"); + Files.write(file, out.toByteArray()); + + assertInstallFails(file, target, ESCAPE_ERROR + "../zip-escape.txt"); + } + + @Test + void testDuplicateTarFileEntryIsRejected(@TempDir Path source, @TempDir Path target) + throws Exception { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + tarEntry(tar, "corpus/data.txt", "first".getBytes(StandardCharsets.UTF_8)); + tarEntry(tar, "corpus/data.txt", "second".getBytes(StandardCharsets.UTF_8)); + tar.write(new byte[TERMINATOR_SIZE]); + final Path file = source.resolve("duplicate.tar.gz"); + Files.write(file, gzip(tar.toByteArray())); + + assertInstallFails(file, target, DUPLICATE_ENTRY_ERROR + "corpus/data.txt"); + } + + @Test + void testEquivalentZipFileEntriesAreRejected(@TempDir Path source, + @TempDir Path target) throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("corpus/data.txt")); + zip.write("first".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + zip.putNextEntry(new ZipEntry("corpus/./data.txt")); + zip.write("second".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + final Path file = source.resolve("duplicate.zip"); + Files.write(file, out.toByteArray()); + + assertInstallFails(file, target, DUPLICATE_ENTRY_ERROR + "corpus/./data.txt"); + } + + @Test + void testPlainFileBeginningWithPkIsStored(@TempDir Path source, @TempDir Path target) + throws Exception { + final byte[] content = "PK plain dictionary".getBytes(StandardCharsets.UTF_8); + final Path file = source.resolve("dictionary.dat"); + Files.write(file, content); + + ResourceInstaller.install(file.toUri(), target); + + Assertions.assertArrayEquals(content, + Files.readAllBytes(target.resolve("dictionary.dat"))); + } + + @Test + void testTruncatedZipHeaderIsRejected(@TempDir Path source, @TempDir Path target) + throws Exception { + final Path file = source.resolve("truncated.zip"); + Files.write(file, new byte[] {'P', 'K', 3, 4}); + + assertInstallFails(file, target, "malformed zip archive"); + } + + @Test + void testZipWithoutCentralDirectoryIsRejected(@TempDir Path source, + @TempDir Path target) throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("corpus/data.txt")); + zip.write("content".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + final byte[] complete = out.toByteArray(); + int centralDirectory = -1; + for (int i = 0; i <= complete.length - 4; i++) { + if (complete[i] == 'P' && complete[i + 1] == 'K' + && complete[i + 2] == 1 && complete[i + 3] == 2) { + centralDirectory = i; + break; + } + } + Assertions.assertTrue(centralDirectory > 0); + final Path file = source.resolve("missing-central-directory.zip"); + Files.write(file, Arrays.copyOf(complete, centralDirectory)); + + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(file.toUri(), target)); + Assertions.assertEquals("malformed zip archive", thrown.getMessage()); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + void testTruncatedEmptyZipHeaderIsRejected(@TempDir Path source, @TempDir Path target) + throws Exception { + final Path file = source.resolve("truncated-empty.zip"); + Files.write(file, new byte[] {'P', 'K', 5, 6}); + + assertInstallFails(file, target, "malformed zip archive"); + } + + @Test + void testValidEmptyZipInstallsNothing(@TempDir Path source, @TempDir Path target) + throws Exception { + final byte[] endHeader = new byte[22]; + endHeader[0] = 'P'; + endHeader[1] = 'K'; + endHeader[2] = 5; + endHeader[3] = 6; + final Path file = source.resolve("empty.zip"); + Files.write(file, endHeader); + + ResourceInstaller.install(file.toUri(), target); + + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + void testValidEmptyTarGzInstallsNothing(@TempDir Path source, @TempDir Path target) + throws Exception { + final Path file = source.resolve("empty.tar.gz"); + Files.write(file, gzip(new byte[TERMINATOR_SIZE])); + + ResourceInstaller.install(file.toUri(), target); + + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + void testInvalidZipEntryPathLeavesNoStagingFiles(@TempDir Path source, + @TempDir Path target) throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("good.txt")); + zip.write("good".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + zip.putNextEntry(new ZipEntry("bad\0name.txt")); + zip.write("bad".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + final Path file = source.resolve("invalid-path.zip"); + Files.write(file, out.toByteArray()); + + assertInstallFails(file, target, + "archive entry has an invalid path: bad\0name.txt"); + } + + @Test + void testTruncatedTarLeavesTargetEmpty(@TempDir Path source, @TempDir Path target) + throws Exception { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + tarEntry(tar, "first.txt", "complete".getBytes(StandardCharsets.UTF_8)); + tarEntry(tar, "second.txt", "never finished".getBytes(StandardCharsets.UTF_8)); + final byte[] whole = tar.toByteArray(); + // Cut inside the second entry's header: first entry occupies two 512-byte blocks. + final byte[] truncated = Arrays.copyOf(whole, 2 * BLOCK + 100); + final Path file = source.resolve("truncated.tar.gz"); + Files.write(file, gzip(truncated)); + + Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(file.toUri(), target)); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + void testFailedInstallKeepsPreexistingTargetContent(@TempDir Path source, + @TempDir Path target) throws Exception { + Files.writeString(target.resolve("existing.txt"), "keep"); + final byte[] archive = tarGz(new String[][] { + {"good.txt", "fine"}, + {"../escape.txt", "bad"}}); + final Path file = source.resolve("partial.tar.gz"); + Files.write(file, archive); + + Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(file.toUri(), target)); + Assertions.assertEquals(List.of("existing.txt"), installedFiles(target)); + Assertions.assertEquals("keep", Files.readString(target.resolve("existing.txt"))); + } + + @Test + void testInstallationLeavesNoStagingResidue(@TempDir Path source, @TempDir Path target) + throws Exception { + final byte[] archive = tarGz(new String[][] {{"corpus/data.txt", "content"}}); + final Path file = source.resolve("clean.tar.gz"); + Files.write(file, archive); + + ResourceInstaller.install(file.toUri(), target); + + try (Stream walk = Files.walk(target)) { + final List hidden = walk + .filter(path -> !path.equals(target)) + .map(path -> path.getFileName().toString()) + .filter(fileName -> fileName.startsWith(".")) + .toList(); + Assertions.assertEquals(List.of(), hidden); + } + Assertions.assertEquals(List.of("corpus/data.txt"), installedFiles(target)); + } + + /** + * Supplies invalid {@code Limits} constructions with the argument error each one + * must raise. + * + * @return One case per invalid argument. Never {@code null}. + */ + static Stream invalidLimits() { + final Duration valid = Duration.ofSeconds(10); + return Stream.of( + Arguments.of("null connectTimeout", (Executable) + () -> new ResourceInstaller.Limits(null, valid, 5, 1024, 1024, 10, 100), + "connectTimeout must not be null"), + Arguments.of("zero connectTimeout", (Executable) + () -> new ResourceInstaller.Limits(Duration.ZERO, valid, 5, 1024, 1024, 10, 100), + "connectTimeout must be positive"), + Arguments.of("null readTimeout", (Executable) + () -> new ResourceInstaller.Limits(valid, null, 5, 1024, 1024, 10, 100), + "readTimeout must not be null"), + Arguments.of("negative readTimeout", (Executable) + () -> new ResourceInstaller.Limits(valid, Duration.ofSeconds(-1), + 5, 1024, 1024, 10, 100), + "readTimeout must be positive"), + Arguments.of("negative maxRedirects", (Executable) + () -> new ResourceInstaller.Limits(valid, valid, -1, 1024, 1024, 10, 100), + "maxRedirects must not be negative"), + Arguments.of("zero maxDownloadBytes", (Executable) + () -> new ResourceInstaller.Limits(valid, valid, 5, 0, 1024, 10, 100), + "maxDownloadBytes must be positive"), + Arguments.of("zero maxExpandedBytes", (Executable) + () -> new ResourceInstaller.Limits(valid, valid, 5, 1024, 0, 10, 100), + "maxExpandedBytes must be positive"), + Arguments.of("zero maxEntries", (Executable) + () -> new ResourceInstaller.Limits(valid, valid, 5, 1024, 1024, 0, 100), + "maxEntries must be positive"), + Arguments.of("zero maxExpansionRatio", (Executable) + () -> new ResourceInstaller.Limits(valid, valid, 5, 1024, 1024, 10, 0), + "maxExpansionRatio must be positive"), + Arguments.of("builder zero connectTimeout", (Executable) () -> + ResourceInstaller.Limits.builder().connectTimeout(Duration.ZERO).build(), + "connectTimeout must be positive"), + Arguments.of("builder null readTimeout", (Executable) () -> + ResourceInstaller.Limits.builder().readTimeout(null).build(), + "readTimeout must not be null"), + Arguments.of("builder negative maxRedirects", (Executable) () -> + ResourceInstaller.Limits.builder().maxRedirects(-1).build(), + "maxRedirects must not be negative"), + Arguments.of("builder zero maxDownloadBytes", (Executable) () -> + ResourceInstaller.Limits.builder().maxDownloadBytes(0).build(), + "maxDownloadBytes must be positive"), + Arguments.of("builder negative maxExpandedBytes", (Executable) () -> + ResourceInstaller.Limits.builder().maxExpandedBytes(-1).build(), + "maxExpandedBytes must be positive"), + Arguments.of("builder zero maxEntries", (Executable) () -> + ResourceInstaller.Limits.builder().maxEntries(0).build(), + "maxEntries must be positive")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidLimits") + void testLimitsRejectInvalidValues(String label, Executable construction, + String message) { + final IllegalArgumentException thrown = + Assertions.assertThrows(IllegalArgumentException.class, construction); + Assertions.assertEquals(message, thrown.getMessage()); + } + + @Test + void testDownloadLimitRejectsOversizedSource(@TempDir Path source, + @TempDir Path target) throws Exception { + final Path file = source.resolve("large.txt"); + Files.write(file, new byte[8192]); + + assertInstallFails(file, target, limits(KIBIBYTE, MEBIBYTE), + "download exceeds the limit of " + KIBIBYTE + " bytes"); + } + + @Test + void testTarExpansionLimitRejectsArchive(@TempDir Path source, @TempDir Path target) + throws Exception { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + tarEntry(tar, "bomb/zeros.bin", new byte[64 * 1024]); + tar.write(new byte[TERMINATOR_SIZE]); + final Path file = source.resolve("bomb.tar.gz"); + Files.write(file, gzip(tar.toByteArray())); + + assertInstallFails(file, target, limits(MEBIBYTE, KIBIBYTE), + EXPANSION_LIMIT_ERROR); + } + + @Test + void testTarExpansionLimitCountsBytesAfterTheTerminator(@TempDir Path source, + @TempDir Path target) throws Exception { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + tarEntry(tar, "corpus/data.txt", "content".getBytes(StandardCharsets.UTF_8)); + tar.write(new byte[TERMINATOR_SIZE]); + tar.write(new byte[64 * 1024]); + final Path file = source.resolve("trailing-data.tar.gz"); + Files.write(file, gzip(tar.toByteArray())); + + assertInstallFails(file, target, limits(MEBIBYTE, 16 * KIBIBYTE), + "expanded content exceeds the limit of " + 16 * KIBIBYTE + " bytes"); + } + + @Test + void testTarGzipTrailerIsVerified(@TempDir Path source, @TempDir Path target) + throws Exception { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + tarEntry(tar, "corpus/data.txt", "content".getBytes(StandardCharsets.UTF_8)); + tar.write(new byte[TERMINATOR_SIZE]); + tar.write(new byte[64 * 1024]); + final byte[] archive = gzip(tar.toByteArray()); + archive[archive.length - 8] ^= 1; + final Path file = source.resolve("bad-trailer.tar.gz"); + Files.write(file, archive); + + Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(file.toUri(), target)); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + void testTarMetadataCountsTowardExpansionLimit(@TempDir Path source, + @TempDir Path target) throws Exception { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + TarArchives.entry(tar, "pax_global_header", + TarArchives.paxRecord("comment", "a".repeat(64 * 1024)), 'g'); + tar.write(new byte[TERMINATOR_SIZE]); + final Path file = source.resolve("metadata-bomb.tar.gz"); + Files.write(file, gzip(tar.toByteArray())); + + assertInstallFails(file, target, limits(MEBIBYTE, KIBIBYTE), + EXPANSION_LIMIT_ERROR); + } + + @Test + void testZipExpansionLimitRejectsArchive(@TempDir Path source, @TempDir Path target) + throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("bomb/zeros.bin")); + zip.write(new byte[64 * 1024]); + zip.closeEntry(); + } + final Path file = source.resolve("bomb.zip"); + Files.write(file, out.toByteArray()); + + assertInstallFails(file, target, limits(MEBIBYTE, KIBIBYTE), + EXPANSION_LIMIT_ERROR); + } + + @Test + void testZipDirectoryContentCountsTowardExpansionLimit(@TempDir Path source, + @TempDir Path target) throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("payload/")); + zip.write(new byte[64 * 1024]); + zip.closeEntry(); + } + final Path file = source.resolve("directory-content.zip"); + Files.write(file, out.toByteArray()); + + assertInstallFails(file, target, limits(MEBIBYTE, KIBIBYTE), + EXPANSION_LIMIT_ERROR); + } + + @Test + void testPlainGzipExpansionLimitRejectsFile(@TempDir Path source, + @TempDir Path target) throws Exception { + final Path file = source.resolve("zeros.bin.gz"); + Files.write(file, gzip(new byte[64 * 1024])); + + assertInstallFails(file, target, limits(MEBIBYTE, KIBIBYTE), + EXPANSION_LIMIT_ERROR); + } + + @Test + void testInstallWithinCustomLimitsSucceeds(@TempDir Path source, @TempDir Path target) + throws Exception { + final byte[] archive = tarGz(new String[][] {{"corpus/data.txt", "small"}}); + final Path file = source.resolve("small.tar.gz"); + Files.write(file, archive); + + ResourceInstaller.install(file.toUri(), target, sha256(archive), + limits(MEBIBYTE, MEBIBYTE)); + + Assertions.assertEquals("small", Files.readString(target.resolve("corpus/data.txt"))); + } + + @Test + void testDefaultLimitsArePinned() { + final ResourceInstaller.Limits defaults = ResourceInstaller.Limits.DEFAULT; + Assertions.assertEquals(Duration.ofSeconds(20), defaults.connectTimeout()); + Assertions.assertEquals(Duration.ofSeconds(60), defaults.readTimeout()); + Assertions.assertEquals(5, defaults.maxRedirects()); + Assertions.assertEquals(1L << 30, defaults.maxDownloadBytes()); + Assertions.assertEquals(4L << 30, defaults.maxExpandedBytes()); + Assertions.assertEquals(100_000L, defaults.maxEntries()); + Assertions.assertEquals(100L, defaults.maxExpansionRatio()); + } + + @Test + void testLimitPropertyNames() { + Assertions.assertEquals("opennlp.download.max.bytes", + ResourceInstaller.Limits.MAX_DOWNLOAD_BYTES_PROPERTY); + Assertions.assertEquals("opennlp.install.max.total.bytes", + ResourceInstaller.Limits.MAX_EXPANDED_BYTES_PROPERTY); + Assertions.assertEquals("opennlp.install.max.entries", + ResourceInstaller.Limits.MAX_ENTRIES_PROPERTY); + Assertions.assertEquals("opennlp.install.max.expansion.ratio", + ResourceInstaller.Limits.MAX_EXPANSION_RATIO_PROPERTY); + } + + @Test + void testLimitPropertyOverrideIsRead() { + System.setProperty(TEST_LIMIT_PROPERTY, " 123 "); + try { + Assertions.assertEquals(123L, + ResourceInstaller.Limits.longProperty(TEST_LIMIT_PROPERTY, 7L)); + } finally { + System.clearProperty(TEST_LIMIT_PROPERTY); + } + } + + /** + * Supplies property values that must fall back to the built-in default: absent, + * not a number, zero, negative, and empty. + * + * @return One case per unusable value. Never {@code null}. + */ + static Stream unusableLimitProperties() { + return Stream.of( + Arguments.of("absent", null), + Arguments.of("not a number", "abc"), + Arguments.of("zero", "0"), + Arguments.of("negative", "-5"), + Arguments.of("empty", "")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("unusableLimitProperties") + void testLimitPropertyFallsBackOnUnusableValues(String label, String value) { + if (value == null) { + System.clearProperty(TEST_LIMIT_PROPERTY); + } else { + System.setProperty(TEST_LIMIT_PROPERTY, value); + } + try { + Assertions.assertEquals(7L, + ResourceInstaller.Limits.longProperty(TEST_LIMIT_PROPERTY, 7L)); + } finally { + System.clearProperty(TEST_LIMIT_PROPERTY); + } + } + + /** + * Builds installation limits with the given entry limit and otherwise default + * values, so entry-count tests state only the value they exercise. + * + * @param maxEntries The entry limit. + * @return The limits. Never {@code null}. + */ + private static ResourceInstaller.Limits entryLimit(long maxEntries) { + return ResourceInstaller.Limits.builder().maxEntries(maxEntries).build(); + } + + @Test + void testTarEntryCountLimitRejectsArchive(@TempDir Path source, @TempDir Path target) + throws Exception { + final byte[] archive = tarGz(new String[][] { + {"corpus/one.txt", "1"}, + {"corpus/two.txt", "2"}, + {"corpus/three.txt", "3"}}); + final Path file = source.resolve("many.tar.gz"); + Files.write(file, archive); + + assertInstallFails(file, target, entryLimit(2), + ENTRY_LIMIT_ERROR); + } + + /** + * Checks that tar extension headers count toward the archive entry limit. + * + * @param source A scratch directory for the source archive. + * @param target A scratch installation directory. + * @throws Exception Thrown if the fixture cannot be created or installed. + */ + @Test + void testTarEntryCountLimitIncludesMetadata(@TempDir Path source, + @TempDir Path target) throws Exception { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + TarArchives.entry(tar, "PaxHeaders/one", + TarArchives.paxRecord("comment", "first"), 'x'); + TarArchives.entry(tar, "PaxHeaders/b", + TarArchives.paxRecord("comment", "right"), 'x'); + tarEntry(tar, "payload/data.txt", "content".getBytes(StandardCharsets.UTF_8)); + tar.write(new byte[TERMINATOR_SIZE]); + final Path file = source.resolve("metadata-entries.tar.gz"); + Files.write(file, gzip(tar.toByteArray())); + + assertInstallFails(file, target, entryLimit(2), ENTRY_LIMIT_ERROR); + } + + @Test + void testZipEntryCountLimitRejectsArchive(@TempDir Path source, @TempDir Path target) + throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + for (int i = 0; i < 3; i++) { + zip.putNextEntry(new ZipEntry("corpus/entry-" + i + ".txt")); + zip.write("x".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + } + final Path file = source.resolve("many.zip"); + Files.write(file, out.toByteArray()); + + assertInstallFails(file, target, entryLimit(2), + ENTRY_LIMIT_ERROR); + } + + @Test + void testEntryCountExactlyAtLimitSucceeds(@TempDir Path source, @TempDir Path target) + throws Exception { + final byte[] archive = tarGz(new String[][] { + {"corpus/one.txt", "1"}, + {"corpus/two.txt", "2"}}); + final Path file = source.resolve("exact.tar.gz"); + Files.write(file, archive); + + ResourceInstaller.install(file.toUri(), target, sha256(archive), entryLimit(2)); + + Assertions.assertEquals(List.of("corpus/one.txt", "corpus/two.txt"), + installedFiles(target)); + } + + @Test + void testSha512ChecksumComparisonIgnoresHexLetterCase(@TempDir Path source, + @TempDir Path target) throws Exception { + final byte[] archive = tarGz(new String[][] {{"data/entry.txt", "payload"}}); + final Path file = source.resolve("cased512.tar.gz"); + Files.write(file, archive); + final String upperCase = sha512(archive).toUpperCase(Locale.ROOT); + Assertions.assertNotEquals(sha512(archive), upperCase); + + ResourceInstaller.install(file.toUri(), target, upperCase); + + Assertions.assertEquals("payload", + Files.readString(target.resolve("data/entry.txt"))); + } + + @Test + void testDownloadExactlyAtLimitSucceeds(@TempDir Path source, @TempDir Path target) + throws Exception { + final Path file = source.resolve("exact.dat"); + Files.write(file, new byte[1024]); + + ResourceInstaller.install(file.toUri(), target, null, limits(KIBIBYTE, MEBIBYTE)); + + Assertions.assertEquals(List.of("exact.dat"), installedFiles(target)); + Assertions.assertEquals(1024, Files.size(target.resolve("exact.dat"))); + } + + @Test + void testExpansionExactlyAtLimitSucceeds(@TempDir Path source, @TempDir Path target) + throws Exception { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + tarEntry(tar, "corpus/exact.bin", new byte[1024]); + tar.write(new byte[TERMINATOR_SIZE]); + final Path file = source.resolve("exact.tar.gz"); + Files.write(file, gzip(tar.toByteArray())); + + ResourceInstaller.install(file.toUri(), target, null, + limits(MEBIBYTE, tar.size())); + + Assertions.assertEquals(List.of("corpus/exact.bin"), installedFiles(target)); + Assertions.assertEquals(1024, Files.size(target.resolve("corpus/exact.bin"))); + } + + @Test + void testCumulativeExpansionAcrossEntriesHitsLimit(@TempDir Path source, + @TempDir Path target) throws Exception { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + tarEntry(tar, "corpus/first.bin", new byte[768]); + tarEntry(tar, "corpus/second.bin", new byte[768]); + tar.write(new byte[TERMINATOR_SIZE]); + final Path file = source.resolve("cumulative.tar.gz"); + Files.write(file, gzip(tar.toByteArray())); + + assertInstallFails(file, target, limits(MEBIBYTE, KIBIBYTE), + EXPANSION_LIMIT_ERROR); + } + + @Test + void testReinstallOverAnExistingFileIsRejected(@TempDir Path source, @TempDir Path target) + throws Exception { + final byte[] first = tarGz(new String[][] {{"corpus/data.txt", "version one"}}); + final byte[] second = tarGz(new String[][] {{"corpus/data.txt", "version two"}}); + final Path firstFile = source.resolve("first.tar.gz"); + final Path secondFile = source.resolve("second.tar.gz"); + Files.write(firstFile, first); + Files.write(secondFile, second); + ResourceInstaller.install(firstFile.toUri(), target, sha256(first)); + + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(secondFile.toUri(), target, sha256(second))); + + Assertions.assertEquals( + COLLISION_ERROR + target.resolve("corpus/data.txt"), + thrown.getMessage()); + Assertions.assertEquals("version one", + Files.readString(target.resolve("corpus/data.txt"))); + + Files.delete(target.resolve("corpus/data.txt")); + ResourceInstaller.install(secondFile.toUri(), target, sha256(second)); + Assertions.assertEquals("version two", + Files.readString(target.resolve("corpus/data.txt"))); + } + + @Test + void testCollidingInstallPromotesNothing(@TempDir Path source, @TempDir Path target) + throws Exception { + Files.createDirectories(target.resolve("corpus")); + Files.writeString(target.resolve("corpus/data.txt"), "keep"); + final byte[] archive = tarGz(new String[][] { + {"corpus/fresh.txt", "new"}, + {"corpus/data.txt", "replacement"}}); + final Path file = source.resolve("colliding.tar.gz"); + Files.write(file, archive); + + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(file.toUri(), target, sha256(archive))); + + Assertions.assertEquals( + COLLISION_ERROR + target.resolve("corpus/data.txt"), + thrown.getMessage()); + Assertions.assertEquals("keep", Files.readString(target.resolve("corpus/data.txt"))); + Assertions.assertTrue(Files.notExists(target.resolve("corpus/fresh.txt"))); + Assertions.assertEquals(List.of("corpus/data.txt"), installedFiles(target)); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void testPromotionRejectsToFollowASymlinkedDirectory(@TempDir Path source, + @TempDir Path target, @TempDir Path outside) throws Exception { + final Path link = target.resolve("link"); + Files.createSymbolicLink(link, outside); + final byte[] archive = tarGz(new String[][] {{"link/planted.txt", "escaped"}}); + final Path file = source.resolve("symlink.tar.gz"); + Files.write(file, archive); + + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> ResourceInstaller.install(file.toUri(), target)); + + Assertions.assertEquals("installation path crosses a symbolic link: " + link, + thrown.getMessage()); + Assertions.assertEquals(List.of(), installedFiles(outside)); + Assertions.assertTrue(Files.notExists(outside.resolve("planted.txt"))); + } + + @Test + void testNestedDirectoryThatIsNotASymlinkStillInstalls(@TempDir Path source, + @TempDir Path target) throws Exception { + Files.createDirectory(target.resolve("link")); + final byte[] archive = tarGz(new String[][] {{"link/planted.txt", "fine"}}); + final Path file = source.resolve("nested.tar.gz"); + Files.write(file, archive); + + ResourceInstaller.install(file.toUri(), target); + + Assertions.assertEquals("fine", + Files.readString(target.resolve("link/planted.txt"))); + } + + /** + * Supplies source schemes the installer rejects because they are outside the bounded + * HTTP and local-file paths. + * + * @return One case per rejected scheme. Never {@code null}. + */ + static Stream rejectedSourceSchemes() { + return Stream.of( + Arguments.of("ftp", URI.create("ftp://example.invalid/corpus.tar.gz")), + Arguments.of("jar", URI.create("jar:file:/tmp/a.jar!/corpus.tar.gz")), + Arguments.of("mailto", URI.create("mailto:someone@example.invalid")), + Arguments.of("no scheme at all", URI.create("corpus.tar.gz"))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("rejectedSourceSchemes") + void testUnsupportedSourceSchemeIsRejected(String label, URI source, + @TempDir Path target) throws IOException { + assertArgumentError("source scheme must be http, https, or file, but was: " + source, + () -> ResourceInstaller.install(source, target)); + Assertions.assertEquals(List.of(), installedFiles(target)); + } + + @Test + void testUnsupportedSourceSchemeIsRejectedBeforeCreatingTheTarget(@TempDir Path parent) { + final Path target = parent.resolve("not-created-yet"); + + assertArgumentError( + "source scheme must be http, https, or file, but was: ftp://example.invalid/c.gz", + () -> ResourceInstaller.install(URI.create("ftp://example.invalid/c.gz"), target)); + Assertions.assertTrue(Files.notExists(target)); + } + + + + + @Test + void testBuilderWithoutOverridesEqualsTheDefaults() { + Assertions.assertEquals(ResourceInstaller.Limits.DEFAULT, + ResourceInstaller.Limits.builder().build()); + } + + @Test + void testBuilderOverridesOnlyWhatIsSet() { + final ResourceInstaller.Limits limits = ResourceInstaller.Limits.builder() + .maxDownloadBytes(MEBIBYTE) + .maxExpandedBytes(2 * MEBIBYTE) + .build(); + + Assertions.assertEquals(MEBIBYTE, limits.maxDownloadBytes()); + Assertions.assertEquals(2 * MEBIBYTE, limits.maxExpandedBytes()); + Assertions.assertEquals(ResourceInstaller.Limits.DEFAULT.connectTimeout(), + limits.connectTimeout()); + Assertions.assertEquals(ResourceInstaller.Limits.DEFAULT.readTimeout(), + limits.readTimeout()); + Assertions.assertEquals(ResourceInstaller.Limits.DEFAULT.maxRedirects(), + limits.maxRedirects()); + Assertions.assertEquals(ResourceInstaller.Limits.DEFAULT.maxEntries(), + limits.maxEntries()); + } + + @Test + void testDownloadFileIsCreatedInTheTargetDirectory(@TempDir Path target) + throws Exception { + final Path downloaded = ResourceInstaller.createDownloadFile(target); + try { + Assertions.assertEquals(target, downloaded.getParent()); + Assertions.assertTrue( + downloaded.getFileName().toString().startsWith(".opennlp-download"), + downloaded.getFileName().toString()); + } finally { + Files.deleteIfExists(downloaded); + } + } + + static Stream expansionBombs() throws IOException { + // Four mebibytes of repeated text compress to a few kibibytes in either format: far + // beyond the ratio ceiling, far below the absolute expansion limit. Repeated text + // rather than zeros, because a gzip stream of zeros reads as an empty tar archive. + final byte[] repetitive = + "opennlp ".repeat(512 * 1024).getBytes(StandardCharsets.UTF_8); + return Stream.of( + Arguments.of("corpus.txt.gz", gzip(repetitive)), + Arguments.of("corpus.zip", zipOf("corpus.txt", repetitive))); + } + + @ParameterizedTest + @MethodSource("expansionBombs") + void testExpansionRatioIsBounded(String name, byte[] content, @TempDir Path source, + @TempDir Path target) throws Exception { + final Path file = source.resolve(name); + Files.write(file, content); + + assertInstallFails(file, target, RATIO_ERROR); + } + + @ParameterizedTest + @MethodSource("expansionBombs") + void testRaisingTheExpansionRatioAcceptsWhatTheDefaultRejects(String name, + byte[] content, @TempDir Path source, @TempDir Path target) throws Exception { + final Path file = source.resolve(name); + Files.write(file, content); + + // Raising the byte limit alone cannot lift the ratio: the tighter budget wins. + final ResourceInstaller.Limits raised = ResourceInstaller.Limits.builder() + .maxExpansionRatio(2000) + .build(); + ResourceInstaller.install(file.toUri(), target, null, raised); + + Assertions.assertEquals(4 << 20, Files.size(target.resolve("corpus.txt"))); + } + + @Test + void testMoveIntoPlaceDoesNotReplaceAnExistingFile(@TempDir Path directory) + throws Exception { + final Path staged = directory.resolve("staged.txt"); + final Path destination = directory.resolve("installed.txt"); + Files.writeString(staged, "new"); + Files.writeString(destination, "old"); + + Assertions.assertThrows(FileAlreadyExistsException.class, + () -> ResourceInstaller.moveIntoPlace(staged, destination)); + + Assertions.assertEquals("old", Files.readString(destination)); + Assertions.assertEquals("new", Files.readString(staged)); + } + + @Test + void testModelSuffixIsMatchedIgnoringCase(@TempDir Path source, @TempDir Path target) + throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("manifest.properties")); + zip.write("OpenNLP-Version: 0.0.0\n".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + final Path file = source.resolve("en-ner-person.BIN"); + Files.write(file, out.toByteArray()); + + ResourceInstaller.install(file.toUri(), target); + + Assertions.assertEquals(List.of("en-ner-person.BIN"), installedFiles(target)); + } + + @Test + void testGzipSuffixIsStrippedIgnoringCase(@TempDir Path source, @TempDir Path target) + throws Exception { + final Path file = source.resolve("lexicon.tsv.GZ"); + Files.write(file, gzip("word\tlemma\n".getBytes(StandardCharsets.UTF_8))); + + ResourceInstaller.install(file.toUri(), target); + + Assertions.assertEquals(List.of("lexicon.tsv"), installedFiles(target)); + } + + @Test + void testStaleWorkFilesOfAKilledInstallAreRemoved(@TempDir Path source, + @TempDir Path target) throws Exception { + final Path staleStaging = Files.createDirectory(target.resolve(".opennlp-stagingOLD")); + Files.writeString(staleStaging.resolve("partial.txt"), "half"); + Files.writeString(target.resolve(".opennlp-downloadOLD.part"), "half"); + final Path file = source.resolve("corpus.tar.gz"); + Files.write(file, tarGz(new String[][] {{"corpus/data.txt", "content"}})); + + ResourceInstaller.install(file.toUri(), target); + + try (Stream entries = Files.list(target)) { + Assertions.assertEquals(List.of("corpus"), + entries.map(path -> path.getFileName().toString()).sorted().toList()); + } + } + + @Test + void testFailedInstallRemovesTheTargetDirectoryItCreated(@TempDir Path source, + @TempDir Path parent) throws Exception { + final byte[] archive = tarGz(new String[][] {{"corpus/data.txt", "content"}}); + final Path file = source.resolve("corpus.tar.gz"); + Files.write(file, archive); + final Path target = parent.resolve("fresh"); + + Assertions.assertThrows(IOException.class, () -> ResourceInstaller.install( + file.toUri(), target, sha256("other".getBytes(StandardCharsets.UTF_8)))); + + Assertions.assertTrue(Files.notExists(target)); + } + + @Test + void testZipLocalHeadersMustMatchTheCentralDirectory(@TempDir Path source, + @TempDir Path target) throws Exception { + // The same content under two names, so the local file sections are the same size and + // one archive's central directory fits the other's local headers. + final byte[] first = zipOf("a.txt", "content"); + final byte[] second = zipOf("b.txt", "content"); + final int centralFirst = centralDirectoryStart(first); + final int centralSecond = centralDirectoryStart(second); + final byte[] hybrid = new byte[centralFirst + second.length - centralSecond]; + System.arraycopy(first, 0, hybrid, 0, centralFirst); + System.arraycopy(second, centralSecond, hybrid, centralFirst, + second.length - centralSecond); + final Path file = source.resolve("hybrid.zip"); + Files.write(file, hybrid); + + assertInstallFails(file, target, ZIP_MISMATCH_ERROR); + } + + /** Builds a zip archive holding one text entry. */ + private static byte[] zipOf(String name, String content) throws IOException { + return zipOf(name, content.getBytes(StandardCharsets.UTF_8)); + } + + /** Builds a zip archive holding one entry with the given bytes. */ + private static byte[] zipOf(String name, byte[] content) throws IOException { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry(name)); + zip.write(content); + zip.closeEntry(); + } + return out.toByteArray(); + } + + /** Finds the first central directory header signature in a zip archive. */ + private static int centralDirectoryStart(byte[] zip) { + for (int i = 0; i + 3 < zip.length; i++) { + if (zip[i] == 'P' && zip[i + 1] == 'K' && zip[i + 2] == 1 && zip[i + 3] == 2) { + return i; + } + } + throw new AssertionError("no central directory"); + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/archive/TarArchives.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/archive/TarArchives.java new file mode 100644 index 0000000000..6646cd1732 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/archive/TarArchives.java @@ -0,0 +1,345 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.util.archive; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.zip.GZIPOutputStream; + +/** + * Builds tar archives byte by byte, so tests can assemble well-formed, boundary, and + * malformed archives without an extra library. Each header has the ustar + * magic and a valid checksum, as a real archive does; {@link #reseal(byte[])} restores + * the checksum after a test has corrupted some other field on purpose. + */ +public final class TarArchives { + + /** The tar block size; headers, contents, and terminators are multiples of it. */ + public static final int BLOCK = 512; + + /** The size of the two all-zero blocks that terminate a tar archive. */ + public static final int TERMINATOR_SIZE = 2 * BLOCK; + + /** The type flag of a regular file. */ + public static final char TYPE_REGULAR_FILE = '0'; + + /** The type flag an old-style archive leaves NUL for a regular file. */ + public static final char TYPE_REGULAR_FILE_CLASSIC = '\0'; + + /** The type flag of a directory. */ + public static final char TYPE_DIRECTORY = '5'; + + /** The offset of the 155-byte ustar name prefix field. */ + public static final int PREFIX_OFFSET = 345; + + private static final int NAME_LENGTH = 100; + private static final int PREFIX_LENGTH = 155; + private static final int MODE_OFFSET = 100; + private static final int SIZE_OFFSET = 124; + private static final int SIZE_LENGTH = 12; + private static final int BASE_256_MARKER = 0x80; + private static final int BASE_256_NEGATIVE = 0x40; + private static final int CHECKSUM_OFFSET = 148; + private static final int CHECKSUM_LENGTH = 8; + private static final int TYPE_OFFSET = 156; + private static final int MAGIC_OFFSET = 257; + private static final int BLANK = ' '; + private static final String SIZE_FORMAT = "%011o"; + private static final String CHECKSUM_FORMAT = "%06o"; + private static final String MODE = "0000644 "; + + /** The ustar magic and version fields: {@code "ustar"}, NUL, then {@code "00"}. */ + private static final byte[] USTAR_MAGIC = {'u', 's', 't', 'a', 'r', 0, '0', '0'}; + + /** The GNU magic and version fields: {@code "ustar"}, two blanks, then NUL. */ + private static final byte[] GNU_MAGIC = {'u', 's', 't', 'a', 'r', ' ', ' ', 0}; + + private TarArchives() { + } + + /** + * Builds one 512-byte tar header block with the given name, declared content size, + * and type flag. The mode field is populated with a non-zero octal value so that a + * name filling the whole 100-byte name field is followed by non-zero bytes, which + * makes name-boundary tests meaningful. + * + * @param name The entry name; at most 100 bytes when encoded as UTF-8. + * @param size The content size to declare in the octal size field; must not be + * negative. + * @param typeFlag The tar type flag, for example {@link #TYPE_REGULAR_FILE}. + * @return The header block. Never {@code null}. + * @throws IllegalArgumentException Thrown if the name does not fit the name field or + * the size is negative. + */ + public static byte[] header(String name, long size, char typeFlag) { + return header(name, "", size, typeFlag, true); + } + + /** + * Builds one 512-byte classic v7 header block, which carries no ustar magic and no + * name prefix field. Real archives in this format still exist, and a reader that keys + * off the magic rather than the checksum will not see them. + * + * @param name The entry name; at most 100 bytes when encoded as UTF-8. + * @param size The content size to declare in the octal size field; must not be + * negative. + * @param typeFlag The tar type flag, for example {@link #TYPE_REGULAR_FILE}. + * @return The header block. Never {@code null}. + * @throws IllegalArgumentException Thrown if the name does not fit the name field or + * the size is negative. + */ + public static byte[] classicHeader(String name, long size, char typeFlag) { + return header(name, "", size, typeFlag, false); + } + + /** + * Builds one 512-byte GNU header block. GNU writes {@code "ustar"} followed by two + * blanks and a NUL where ustar writes {@code "ustar"}, a NUL, and the version, and it + * uses the offset ustar gives to the name prefix for {@code atime} instead. + * + * @param name The entry name; at most 100 bytes when encoded as UTF-8. + * @param size The content size to declare in the octal size field; must not be + * negative. + * @param typeFlag The tar type flag, for example {@link #TYPE_REGULAR_FILE}. + * @param atime The octal {@code atime} to write at {@link #PREFIX_OFFSET}, as a GNU + * incremental archive does, or empty to leave it NUL. + * @return The header block. Never {@code null}. + * @throws IllegalArgumentException Thrown if the name does not fit the name field or + * the size is negative. + */ + public static byte[] gnuHeader(String name, long size, char typeFlag, String atime) { + final byte[] block = header(name, "", size, typeFlag, false); + System.arraycopy(GNU_MAGIC, 0, block, MAGIC_OFFSET, GNU_MAGIC.length); + write(block, PREFIX_OFFSET, atime); + return reseal(block); + } + + /** + * Builds one 512-byte header block whose size field uses the base-256 encoding, which + * GNU writes when a length does not fit the eleven octal digits the field holds. + * + *

The leading bit marks the encoding, the next bit carries the sign, and the + * remaining bits of that byte followed by every later byte form a big-endian two's + * complement number.

+ * + * @param name The entry name; at most 100 bytes when encoded as UTF-8. + * @param size The content size to encode. May be negative, which no real writer emits + * for a size, so that the reader's rejection of it can be exercised. + * @param typeFlag The tar type flag, for example {@link #TYPE_REGULAR_FILE}. + * @return The header block. Never {@code null}. + * @throws IllegalArgumentException Thrown if the name does not fit the name field. + */ + public static byte[] base256Header(String name, long size, char typeFlag) { + final byte[] block = header(name, 0, typeFlag); + long remaining = size; + for (int i = SIZE_OFFSET + SIZE_LENGTH - 1; i >= SIZE_OFFSET; i--) { + block[i] = (byte) (remaining & 0xFF); + remaining >>= Byte.SIZE; + } + block[SIZE_OFFSET] = (byte) (size < 0 + ? block[SIZE_OFFSET] | BASE_256_MARKER | BASE_256_NEGATIVE + : block[SIZE_OFFSET] & ~BASE_256_NEGATIVE | BASE_256_MARKER); + return reseal(block); + } + + /** + * Encodes one pax extended header record, {@code " =\n"}. The + * length includes all record bytes: digits, the blank, and the newline. + * + * @param keyword The pax keyword. + * @param value The keyword's value. + * @return The encoded record. Never {@code null}. + */ + public static byte[] paxRecord(String keyword, String value) { + final byte[] body = (keyword + "=" + value + "\n").getBytes(StandardCharsets.UTF_8); + int digits = 1; + while (Integer.toString(body.length + 1 + digits).length() > digits) { + digits++; + } + final byte[] prefix = ((body.length + 1 + digits) + " ") + .getBytes(StandardCharsets.US_ASCII); + final byte[] record = new byte[prefix.length + body.length]; + System.arraycopy(prefix, 0, record, 0, prefix.length); + System.arraycopy(body, 0, record, prefix.length, body.length); + return record; + } + + /** + * Builds one 512-byte ustar header block whose entry name is split across the name + * prefix field and the name field, which is how a real archive stores a name longer + * than 100 bytes. + * + * @param name The name field content; at most 100 bytes when encoded as UTF-8. + * @param prefix The name prefix field content; at most 155 bytes when encoded as + * UTF-8. Empty for a header without a prefix. + * @param size The content size to declare in the octal size field; must not be + * negative. + * @param typeFlag The tar type flag, for example {@link #TYPE_REGULAR_FILE}. + * @return The header block. Never {@code null}. + * @throws IllegalArgumentException Thrown if either name part does not fit its field + * or the size is negative. + */ + public static byte[] header(String name, String prefix, long size, char typeFlag) { + return header(name, prefix, size, typeFlag, true); + } + + /** + * Builds one 512-byte header block, in either the ustar or the classic v7 format. + * + * @param name The name field content; at most 100 bytes when encoded as UTF-8. + * @param prefix The name prefix field content; at most 155 bytes when encoded as + * UTF-8. Empty for a header without a prefix, and required to be empty + * for a classic header, which has no prefix field. + * @param size The content size to declare in the octal size field; must not be + * negative. + * @param typeFlag The tar type flag, for example {@link #TYPE_REGULAR_FILE}. + * @param ustar Whether to write the ustar magic and version fields. + * @return The header block. Never {@code null}. + * @throws IllegalArgumentException Thrown if either name part does not fit its field, + * the size is negative, or a classic header is asked for with a prefix. + */ + private static byte[] header(String name, String prefix, long size, char typeFlag, + boolean ustar) { + final byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8); + if (nameBytes.length > NAME_LENGTH) { + throw new IllegalArgumentException( + "entry name exceeds " + NAME_LENGTH + " bytes: " + name); + } + final byte[] prefixBytes = prefix.getBytes(StandardCharsets.UTF_8); + if (prefixBytes.length > PREFIX_LENGTH) { + throw new IllegalArgumentException( + "entry name prefix exceeds " + PREFIX_LENGTH + " bytes: " + prefix); + } + if (size < 0) { + throw new IllegalArgumentException("size must not be negative: " + size); + } + if (!ustar && prefixBytes.length > 0) { + throw new IllegalArgumentException("a classic header has no name prefix field"); + } + final byte[] block = new byte[BLOCK]; + System.arraycopy(nameBytes, 0, block, 0, nameBytes.length); + System.arraycopy(prefixBytes, 0, block, PREFIX_OFFSET, prefixBytes.length); + write(block, MODE_OFFSET, MODE); + write(block, SIZE_OFFSET, String.format(SIZE_FORMAT, size)); + if (ustar) { + System.arraycopy(USTAR_MAGIC, 0, block, MAGIC_OFFSET, USTAR_MAGIC.length); + } + block[TYPE_OFFSET] = (byte) typeFlag; + return reseal(block); + } + + /** + * Recomputes and writes the header checksum of the given block, so a test can corrupt + * a field on purpose and still hand the reader an otherwise well-formed header. + * + * @param block The header block to seal. Must be 512 bytes long. + * @return The same block, with its checksum field filled in. Never {@code null}. + */ + public static byte[] reseal(byte[] block) { + for (int i = CHECKSUM_OFFSET; i < CHECKSUM_OFFSET + CHECKSUM_LENGTH; i++) { + block[i] = BLANK; + } + int sum = 0; + for (final byte b : block) { + sum += b & 0xFF; + } + write(block, CHECKSUM_OFFSET, String.format(CHECKSUM_FORMAT, sum)); + block[CHECKSUM_OFFSET + CHECKSUM_LENGTH - 1] = BLANK; + return block; + } + + /** + * Writes US-ASCII text into a header field. + * + * @param block The header block to write into. + * @param offset The field's offset in the block. + * @param text The text to write. + */ + private static void write(byte[] block, int offset, String text) { + final byte[] bytes = text.getBytes(StandardCharsets.US_ASCII); + System.arraycopy(bytes, 0, block, offset, bytes.length); + } + + /** + * Writes one complete tar entry into the given buffer: the header block declaring the + * content's actual length, the content itself, and zero padding up to the next + * 512-byte block boundary. + * + * @param tar The buffer receiving the entry bytes. Must not be {@code null}. + * @param name The entry name; at most 100 bytes when encoded as UTF-8. + * @param content The entry content. Must not be {@code null}. + * @param typeFlag The tar type flag for the header. + * @throws IOException Thrown if writing to the buffer fails. + * @throws IllegalArgumentException Thrown if the name does not fit the name field. + */ + public static void entry(ByteArrayOutputStream tar, String name, byte[] content, + char typeFlag) throws IOException { + tar.write(header(name, content.length, typeFlag)); + tar.write(content); + tar.write(new byte[(BLOCK - content.length % BLOCK) % BLOCK]); + } + + /** + * Writes one complete regular-file tar entry into the given buffer. + * + * @param tar The buffer receiving the entry bytes. Must not be {@code null}. + * @param name The entry name; at most 100 bytes when encoded as UTF-8. + * @param content The entry content. Must not be {@code null}. + * @throws IOException Thrown if writing to the buffer fails. + * @throws IllegalArgumentException Thrown if the name does not fit the name field. + */ + public static void entry(ByteArrayOutputStream tar, String name, byte[] content) + throws IOException { + entry(tar, name, content, TYPE_REGULAR_FILE); + } + + /** + * Builds a gzip-compressed tar archive from name and content pairs, terminated by + * the two all-zero blocks that end a tar archive. + * + * @param entries The entries as {@code {name, content}} pairs of UTF-8 text. Must not + * be {@code null}. + * @return The compressed archive bytes. Never {@code null}. + * @throws IOException Thrown if writing to the in-memory streams fails. + */ + public static byte[] gzippedTar(String[][] entries) throws IOException { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + for (final String[] entry : entries) { + entry(tar, entry[0], entry[1].getBytes(StandardCharsets.UTF_8)); + } + tar.write(new byte[TERMINATOR_SIZE]); + return gzip(tar.toByteArray()); + } + + /** + * Compresses raw tar bytes the way a {@code .tar.gz} distribution is shipped, so + * tests can wrap hand-built or deliberately truncated tar content. + * + * @param content The raw tar bytes. Must not be {@code null}. + * @return The gzip-compressed bytes. Never {@code null}. + * @throws IOException Thrown if writing to the in-memory stream fails. + */ + public static byte[] gzip(byte[] content) throws IOException { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (GZIPOutputStream compressed = new GZIPOutputStream(out)) { + compressed.write(content); + } + return out.toByteArray(); + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/archive/TarStreamTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/archive/TarStreamTest.java new file mode 100644 index 0000000000..52b4bd96c1 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/archive/TarStreamTest.java @@ -0,0 +1,783 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.util.archive; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static opennlp.tools.util.archive.TarArchives.BLOCK; +import static opennlp.tools.util.archive.TarArchives.TERMINATOR_SIZE; +import static opennlp.tools.util.archive.TarArchives.TYPE_DIRECTORY; +import static opennlp.tools.util.archive.TarArchives.TYPE_REGULAR_FILE; +import static opennlp.tools.util.archive.TarArchives.TYPE_REGULAR_FILE_CLASSIC; +import static opennlp.tools.util.archive.TarArchives.entry; +import static opennlp.tools.util.archive.TarArchives.header; + +/** + * Exercises {@link TarStream} against archives built byte by byte in the test, covering + * regular traversal as well as boundary and corruption cases. + */ +public class TarStreamTest { + + private static final int NAME_LENGTH = 100; + private static final int SIZE_OFFSET = 124; + private static final int CHECKSUM_OFFSET = 148; + + /** The largest length eleven octal digits can hold, one byte short of 8 GiB. */ + private static final long LARGEST_OCTAL_SIZE = (1L << 33) - 1; + + private static final String MALFORMED_RECORD = "malformed pax extended header record"; + private static final String SPARSE_REJECTED = "sparse tar entries are not supported: " + + "the archived bytes describe file holes, not contiguous content"; + + /** The metadata records GNU tar and bsdtar write ahead of an ordinary entry. */ + private static final String[][] METADATA = { + {"mtime", "1786557589.505896519"}, + {"atime", "1786557589.496852337"}, + {"ctime", "1786557589.505896519"}, + {"uid", "1000"}, + {"gname", "krickert"}, + {"SCHILY.dev", "66306"}, + {"hdrcharset", "BINARY"}}; + + /** + * Supplies blocks that must not be mistaken for a tar header, each with a description + * naming the reason. + * + * @return The rejection cases. Never {@code null}. + */ + private static Stream nonHeaderContent() { + final byte[] filler = new byte[BLOCK]; + Arrays.fill(filler, (byte) 'x'); + return Stream.of( + Arguments.of("fewer bytes than one block", + "too short to be a tar header".getBytes(StandardCharsets.US_ASCII)), + Arguments.of("no ustar magic and a non-octal size field", filler), + Arguments.of("an all-zero block", new byte[BLOCK])); + } + + @Test + void testEmptyStreamHasNoEntries() throws IOException { + final TarStream stream = new TarStream(new ByteArrayInputStream(new byte[0])); + Assertions.assertFalse(stream.next()); + } + + @Test + void testTerminatorOnlyArchiveHasNoEntries() throws IOException { + final TarStream stream = + new TarStream(new ByteArrayInputStream(new byte[TERMINATOR_SIZE])); + Assertions.assertFalse(stream.next()); + } + + /** + * Checks that repeated reads after the terminator remain at end of archive. + * + * @throws IOException Thrown if the fixture archive cannot be read. + */ + @Test + void testNextRemainsAtEndAfterAnUnalignedEntry() throws IOException { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, "data.txt", "x".getBytes(StandardCharsets.UTF_8)); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + Assertions.assertTrue(stream.next()); + Assertions.assertArrayEquals("x".getBytes(StandardCharsets.UTF_8), + stream.entryStream().readAllBytes()); + Assertions.assertFalse(stream.next()); + Assertions.assertFalse(stream.next()); + } + + @Test + void testReadsEntriesSizesTypesAndContent() throws IOException { + final byte[] blockSized = new byte[BLOCK]; + for (int i = 0; i < blockSized.length; i++) { + blockSized[i] = (byte) (i % 251); + } + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, "data/", new byte[0], TYPE_DIRECTORY); + entry(tar, "data/skip.bin", "0123456789".getBytes(StandardCharsets.US_ASCII), + TYPE_REGULAR_FILE); + entry(tar, "data/alpha.txt", "alpha\n".getBytes(StandardCharsets.UTF_8), + TYPE_REGULAR_FILE_CLASSIC); + entry(tar, "block.bin", blockSized, TYPE_REGULAR_FILE); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals("data/", stream.name()); + Assertions.assertEquals(0, stream.size()); + Assertions.assertFalse(stream.isFile()); + + // The next call must skip the unread content and padding of this entry. + Assertions.assertTrue(stream.next()); + Assertions.assertEquals("data/skip.bin", stream.name()); + Assertions.assertEquals(10, stream.size()); + Assertions.assertTrue(stream.isFile()); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals("data/alpha.txt", stream.name()); + Assertions.assertEquals(6, stream.size()); + Assertions.assertTrue(stream.isFile()); + Assertions.assertArrayEquals("alpha\n".getBytes(StandardCharsets.UTF_8), + stream.entryStream().readAllBytes()); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals("block.bin", stream.name()); + Assertions.assertEquals(BLOCK, stream.size()); + Assertions.assertTrue(stream.isFile()); + Assertions.assertArrayEquals(blockSized, stream.entryStream().readAllBytes()); + + Assertions.assertFalse(stream.next()); + } + + @Test + void testNameFillsFullHundredByteField() throws IOException { + final StringBuilder longName = new StringBuilder("d/"); + while (longName.length() < NAME_LENGTH) { + longName.append('x'); + } + final String name = longName.toString(); + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, name, "n".getBytes(StandardCharsets.US_ASCII), TYPE_REGULAR_FILE); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals(NAME_LENGTH, stream.name().length()); + Assertions.assertEquals(name, stream.name()); + Assertions.assertArrayEquals("n".getBytes(StandardCharsets.US_ASCII), + stream.entryStream().readAllBytes()); + Assertions.assertFalse(stream.next()); + } + + @Test + void testTruncatedHeaderReportsError() { + final byte[] partial = + Arrays.copyOf(header("cut.bin", 0, TYPE_REGULAR_FILE), BLOCK / 2); + final TarStream stream = new TarStream(new ByteArrayInputStream(partial)); + + final IOException thrown = Assertions.assertThrows(IOException.class, stream::next); + Assertions.assertEquals("truncated tar header", thrown.getMessage()); + } + + @Test + void testTruncatedEntryContentReportsError() throws IOException { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + tar.write(header("data.bin", 10, TYPE_REGULAR_FILE)); + tar.write("1234".getBytes(StandardCharsets.US_ASCII)); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + Assertions.assertTrue(stream.next()); + final IOException thrown = Assertions.assertThrows(IOException.class, + () -> stream.entryStream().readAllBytes()); + Assertions.assertEquals("truncated tar entry: data.bin", thrown.getMessage()); + } + + @Test + void testTruncatedArchiveWhenSkippingReportsError() throws IOException { + final TarStream stream = new TarStream( + new ByteArrayInputStream(header("gone.bin", 600, TYPE_REGULAR_FILE))); + + Assertions.assertTrue(stream.next()); + final IOException thrown = Assertions.assertThrows(IOException.class, stream::next); + Assertions.assertEquals("truncated tar archive", thrown.getMessage()); + } + + @Test + void testMalformedSizeFieldReportsError() { + final byte[] block = header("bad.bin", 0, TYPE_REGULAR_FILE); + block[SIZE_OFFSET] = '9'; + // Reseal, so the reader reaches the size field instead of stopping at the checksum. + TarArchives.reseal(block); + final TarStream stream = new TarStream(new ByteArrayInputStream(block)); + + final IOException thrown = Assertions.assertThrows(IOException.class, stream::next); + Assertions.assertEquals("malformed tar size field in entry header", + thrown.getMessage()); + } + + @Test + void testSizeFieldWithEmbeddedPaddingIsRejected() { + final byte[] block = header("bad.bin", 0, TYPE_REGULAR_FILE); + Arrays.fill(block, SIZE_OFFSET, SIZE_OFFSET + 12, (byte) 0); + block[SIZE_OFFSET] = '1'; + block[SIZE_OFFSET + 1] = ' '; + block[SIZE_OFFSET + 2] = '2'; + TarArchives.reseal(block); + final TarStream stream = new TarStream(new ByteArrayInputStream(block)); + + final IOException thrown = Assertions.assertThrows(IOException.class, stream::next); + Assertions.assertEquals("malformed tar size field in entry header", + thrown.getMessage()); + } + + @Test + void testStartsWithHeaderDetectsTarAndKeepsPosition() throws IOException { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, "data/alpha.txt", "alpha\n".getBytes(StandardCharsets.UTF_8), + TYPE_REGULAR_FILE); + tar.write(new byte[TERMINATOR_SIZE]); + final InputStream in = + new BufferedInputStream(new ByteArrayInputStream(tar.toByteArray())); + + Assertions.assertTrue(TarStream.startsWithHeader(in)); + + final TarStream stream = new TarStream(in); + Assertions.assertTrue(stream.next()); + Assertions.assertEquals("data/alpha.txt", stream.name()); + Assertions.assertArrayEquals("alpha\n".getBytes(StandardCharsets.UTF_8), + stream.entryStream().readAllBytes()); + } + + @ParameterizedTest + @MethodSource("nonHeaderContent") + void testStartsWithHeaderRejectsNonTarContent(String description, byte[] content) + throws IOException { + Assertions.assertFalse( + TarStream.startsWithHeader(new ByteArrayInputStream(content)), description); + } + + @Test + void testStartsWithHeaderRejectsUnusableStreams() { + final InputStream notMarkable = new InputStream() { + @Override + public int read() { + return -1; + } + }; + // assertAll so a missing check on one argument does not hide the other. + Assertions.assertAll( + () -> Assertions.assertThrows(IllegalArgumentException.class, + () -> TarStream.startsWithHeader(null)), + () -> Assertions.assertThrows(IllegalArgumentException.class, + () -> TarStream.startsWithHeader(notMarkable))); + } + + @Test + void testNullStreamIsRejected() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new TarStream(null)); + } + + /** + * @param maxEntries The invalid entry limit. + */ + @ParameterizedTest(name = "{0}") + @ValueSource(longs = {0, -1}) + void testInvalidEntryLimitIsRejected(long maxEntries) { + final IllegalArgumentException thrown = Assertions.assertThrows( + IllegalArgumentException.class, + () -> new TarStream(InputStream.nullInputStream(), maxEntries)); + Assertions.assertEquals("maxEntries must be positive", thrown.getMessage()); + } + + @Test + void testHeaderWithWrongChecksumIsRejected() { + final byte[] block = header("tampered.bin", 10, TYPE_REGULAR_FILE); + block[0] = 'X'; + final TarStream stream = new TarStream(new ByteArrayInputStream(block)); + + final IOException thrown = Assertions.assertThrows(IOException.class, stream::next); + Assertions.assertEquals("malformed tar header checksum", thrown.getMessage()); + } + + @Test + void testChecksumWithEmbeddedPaddingIsRejected() { + final byte[] block = header("bad-checksum.bin", 0, TYPE_REGULAR_FILE); + System.arraycopy(block, CHECKSUM_OFFSET + 1, block, CHECKSUM_OFFSET + 2, 5); + block[CHECKSUM_OFFSET + 1] = ' '; + final TarStream stream = new TarStream(new ByteArrayInputStream(block)); + + final IOException thrown = Assertions.assertThrows(IOException.class, stream::next); + Assertions.assertEquals("malformed tar header checksum", thrown.getMessage()); + } + + @Test + void testStartsWithHeaderRejectsUstarMagicWithoutAChecksum() throws IOException { + final byte[] block = header("looks-real.bin", 0, TYPE_REGULAR_FILE); + Arrays.fill(block, CHECKSUM_OFFSET, CHECKSUM_OFFSET + 8, (byte) '0'); + + Assertions.assertFalse(TarStream.startsWithHeader(new ByteArrayInputStream(block))); + } + + @Test + void testUstarPrefixIsJoinedToTheName() throws IOException { + final String prefix = "corpus-1.0/" + "d".repeat(120); + final String tail = "annotations/train.conllu"; + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + final byte[] content = "# sent_id = 1\n".getBytes(StandardCharsets.UTF_8); + tar.write(TarArchives.header(tail, prefix, content.length, TYPE_REGULAR_FILE)); + tar.write(content); + tar.write(new byte[BLOCK - content.length]); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals(prefix + "/" + tail, stream.name()); + Assertions.assertTrue(stream.name().length() > 100); + Assertions.assertArrayEquals(content, stream.entryStream().readAllBytes()); + } + + @Test + void testEmptyUstarPrefixLeavesTheNameAlone() throws IOException { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, "plain.txt", "x".getBytes(StandardCharsets.UTF_8)); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals("plain.txt", stream.name()); + } + + @Test + void testPaxGlobalHeaderWithOnlyACommentIsConsumed() throws IOException { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, "pax_global_header", + TarArchives.paxRecord("comment", "0123456789abcdef"), 'g'); + entry(tar, "data.txt", "content".getBytes(StandardCharsets.UTF_8)); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals("data.txt", stream.name()); + Assertions.assertTrue(stream.isFile()); + Assertions.assertArrayEquals("content".getBytes(StandardCharsets.UTF_8), + stream.entryStream().readAllBytes()); + Assertions.assertFalse(stream.next()); + } + + /** + * Supplies pax global headers that must be rejected: the two keywords that would change + * every entry after them, the sparse family whose content cannot be unpacked, and + * payloads that are not pax records at all. + * + * @return One case per rejected global header. Never {@code null}. + */ + private static Stream rejectedGlobalHeaders() { + return Stream.of( + Arguments.of("path redirects every following entry", + TarArchives.paxRecord("path", "../escape.txt"), + "pax global header contains path, which would change every entry after it"), + Arguments.of("size resizes every following entry", + TarArchives.paxRecord("size", "999999"), + "pax global header contains size, which would change every entry after it"), + Arguments.of("sparse map cannot be unpacked", + TarArchives.paxRecord("GNU.sparse.name", "holes.bin"), + SPARSE_REJECTED), + Arguments.of("no length prefix", + "comment=0\n".getBytes(StandardCharsets.UTF_8), MALFORMED_RECORD), + Arguments.of("length longer than the payload", + "99 comment=0\n".getBytes(StandardCharsets.UTF_8), MALFORMED_RECORD), + Arguments.of("length shorter than its own prefix", + "1 comment=0\n".getBytes(StandardCharsets.UTF_8), MALFORMED_RECORD), + Arguments.of("no keyword before the equals sign", + "12 =value\n".getBytes(StandardCharsets.UTF_8), MALFORMED_RECORD), + Arguments.of("no equals sign at all", + "12 comment0\n".getBytes(StandardCharsets.UTF_8), MALFORMED_RECORD)); + } + @ParameterizedTest(name = "{0}") + @MethodSource("rejectedGlobalHeaders") + void testRejectedPaxGlobalHeader(String description, byte[] payload, String message) + throws IOException { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, "pax_global_header", payload, 'g'); + entry(tar, "data.txt", "content".getBytes(StandardCharsets.UTF_8)); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + final IOException thrown = Assertions.assertThrows(IOException.class, stream::next); + Assertions.assertEquals(message, thrown.getMessage()); + } + + @Test + void testClassicHeaderWithoutUstarMagicIsRead() throws IOException { + final byte[] content = "classic\n".getBytes(StandardCharsets.UTF_8); + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + tar.write(TarArchives.classicHeader("v7/data.txt", content.length, + TYPE_REGULAR_FILE)); + tar.write(content); + tar.write(new byte[BLOCK - content.length]); + tar.write(new byte[TERMINATOR_SIZE]); + final byte[] archive = tar.toByteArray(); + + Assertions.assertTrue(TarStream.startsWithHeader( + new BufferedInputStream(new ByteArrayInputStream(archive)))); + final TarStream stream = new TarStream(new ByteArrayInputStream(archive)); + Assertions.assertTrue(stream.next()); + Assertions.assertEquals("v7/data.txt", stream.name()); + Assertions.assertTrue(stream.isFile()); + Assertions.assertArrayEquals(content, stream.entryStream().readAllBytes()); + Assertions.assertFalse(stream.next()); + } + + @Test + void testHeaderWithAnEmptyNameIsRejected() throws IOException { + final byte[] block = TarArchives.reseal(header("", 0, TYPE_REGULAR_FILE)); + final TarStream stream = new TarStream(new ByteArrayInputStream(block)); + + Assertions.assertAll( + () -> { + final IOException thrown = + Assertions.assertThrows(IOException.class, stream::next); + Assertions.assertEquals("tar entry header contains an empty name", + thrown.getMessage()); + }, + () -> Assertions.assertFalse( + TarStream.startsWithHeader(new ByteArrayInputStream(block)))); + } + + @Test + void testHeaderNameWithMalformedUtf8IsRejected() { + final byte[] block = header("data.txt", 0, TYPE_REGULAR_FILE); + block[0] = (byte) 0xC3; + block[1] = 0; + TarArchives.reseal(block); + final TarStream stream = new TarStream(new ByteArrayInputStream(block)); + + final IOException thrown = Assertions.assertThrows(IOException.class, stream::next); + Assertions.assertEquals("tar entry name is not valid UTF-8", thrown.getMessage()); + } + + @Test + void testEntryStreamRejectsInvalidReadRanges() throws IOException { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, "data.txt", "content".getBytes(StandardCharsets.UTF_8)); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + Assertions.assertTrue(stream.next()); + final InputStream content = stream.entryStream(); + final byte[] buffer = new byte[8]; + + Assertions.assertAll( + () -> Assertions.assertThrows(NullPointerException.class, + () -> content.read(null, 0, 1)), + () -> Assertions.assertThrows(NullPointerException.class, + () -> content.read(null, 0, 0)), + () -> Assertions.assertThrows(IndexOutOfBoundsException.class, + () -> content.read(buffer, -1, 1)), + () -> Assertions.assertThrows(IndexOutOfBoundsException.class, + () -> content.read(buffer, 0, -1)), + () -> Assertions.assertThrows(IndexOutOfBoundsException.class, + () -> content.read(buffer, 0, buffer.length + 1)), + () -> Assertions.assertThrows(IndexOutOfBoundsException.class, + () -> content.read(buffer, buffer.length, 1)), + // Zero length at a valid offset is legal and must not be mistaken for an error. + () -> Assertions.assertEquals(0, content.read(buffer, buffer.length, 0))); + } + + @Test + void testZeroLengthReadReturnsZero() throws IOException { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, "data.txt", "content".getBytes(StandardCharsets.UTF_8)); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + Assertions.assertTrue(stream.next()); + final InputStream content = stream.entryStream(); + final byte[] buffer = new byte[8]; + + Assertions.assertEquals(0, content.read(buffer, 0, 0)); + Assertions.assertArrayEquals("content".getBytes(StandardCharsets.UTF_8), + content.readAllBytes()); + // Exhausted: a zero-length read still reports zero, not end of stream. + Assertions.assertEquals(0, content.read(buffer, 0, 0)); + Assertions.assertEquals(-1, content.read(buffer, 0, buffer.length)); + // Exhausted, and the range is still checked before the end-of-stream answer. + Assertions.assertThrows(IndexOutOfBoundsException.class, + () -> content.read(buffer, 0, buffer.length + 1)); + } + + /** + * Concatenates byte arrays, so a test can assemble one extension header payload from + * several records. + * + * @param parts The pieces to join. Must not be {@code null}. + * @return The concatenation. Never {@code null}. + */ + private static byte[] concat(byte[]... parts) { + final ByteArrayOutputStream joined = new ByteArrayOutputStream(); + for (final byte[] part : parts) { + joined.writeBytes(part); + } + return joined.toByteArray(); + } + + /** + * Encodes metadata records emitted by real archive writers, so tests cover their output. + * + * @return The encoded records. Never {@code null}. + */ + private static byte[] metadataRecords() { + final ByteArrayOutputStream records = new ByteArrayOutputStream(); + for (final String[] record : METADATA) { + records.writeBytes(TarArchives.paxRecord(record[0], record[1])); + } + return records.toByteArray(); + } + + @Test + void testPaxExtendedHeaderSuppliesTheEntryName() throws IOException { + final String path = "./corpus-1.0/" + "d".repeat(120) + "/annotations/train.conllu"; + final byte[] content = "content\n".getBytes(StandardCharsets.UTF_8); + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, "./PaxHeaders/train.conllu", + concat(TarArchives.paxRecord("path", path), metadataRecords()), 'x'); + // GNU tar stores the truncated name in the entry header. + entry(tar, path.substring(0, 100), content); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals(path, stream.name()); + Assertions.assertTrue(stream.isFile()); + Assertions.assertArrayEquals(content, stream.entryStream().readAllBytes()); + Assertions.assertFalse(stream.next()); + } + + @Test + void testPaxPathWithMalformedUtf8IsRejected() throws IOException { + final byte[] malformedPath = { + '1', '0', ' ', 'p', 'a', 't', 'h', '=', (byte) 0xC3, '\n'}; + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, "./PaxHeaders/data.txt", malformedPath, 'x'); + entry(tar, "data.txt", "content".getBytes(StandardCharsets.UTF_8)); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + final IOException thrown = Assertions.assertThrows(IOException.class, stream::next); + Assertions.assertEquals("pax record is not valid UTF-8", thrown.getMessage()); + } + + @Test + void testPaxMetadataOnlyHeaderLeavesTheEntryAlone() throws IOException { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, "./PaxHeaders/short.txt", metadataRecords(), 'x'); + entry(tar, "./short.txt", "short\n".getBytes(StandardCharsets.UTF_8)); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals("./short.txt", stream.name()); + Assertions.assertEquals(6, stream.size()); + Assertions.assertFalse(stream.next()); + } + + @Test + void testPaxExtendedHeaderAppliesOnlyToTheNextEntry() throws IOException { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, "./PaxHeaders/first", + TarArchives.paxRecord("path", "overridden/first.txt"), 'x'); + entry(tar, "truncated-first", "one".getBytes(StandardCharsets.UTF_8)); + entry(tar, "second.txt", "two".getBytes(StandardCharsets.UTF_8)); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals("overridden/first.txt", stream.name()); + Assertions.assertTrue(stream.next()); + Assertions.assertEquals("second.txt", stream.name()); + Assertions.assertFalse(stream.next()); + } + + @Test + void testPaxExtendedHeaderSuppliesTheEntrySize() throws IOException { + final byte[] content = "0123456789".getBytes(StandardCharsets.US_ASCII); + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, "./PaxHeaders/data.bin", TarArchives.paxRecord("size", "10"), 'x'); + tar.write(header("data.bin", 0, TYPE_REGULAR_FILE)); + tar.write(content); + tar.write(new byte[BLOCK - content.length]); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals(10, stream.size()); + Assertions.assertArrayEquals(content, stream.entryStream().readAllBytes()); + } + + @Test + void testGnuLongNameHeaderSuppliesTheEntryName() throws IOException { + final String path = "./corpus-1.0/" + "d".repeat(120) + "/annotations/train.conllu"; + final byte[] content = "content\n".getBytes(StandardCharsets.UTF_8); + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, "././@LongLink", + (path + "\0").getBytes(StandardCharsets.UTF_8), 'L'); + entry(tar, path.substring(0, 100), content); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals(path, stream.name()); + Assertions.assertArrayEquals(content, stream.entryStream().readAllBytes()); + Assertions.assertFalse(stream.next()); + } + + @Test + void testGnuLongNameWithMalformedUtf8IsRejected() throws IOException { + final byte[] malformedName = {'b', 'a', 'd', (byte) 0xC3, 0}; + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, "././@LongLink", malformedName, 'L'); + entry(tar, "data.txt", "content".getBytes(StandardCharsets.UTF_8)); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + final IOException thrown = Assertions.assertThrows(IOException.class, stream::next); + Assertions.assertEquals("GNU long name is not valid UTF-8", thrown.getMessage()); + } + + @Test + void testGnuLongLinkHeaderIsConsumed() throws IOException { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + entry(tar, "././@LongLink", + ("../" + "t".repeat(120) + "/target\0").getBytes(StandardCharsets.UTF_8), 'K'); + entry(tar, "data.txt", "content".getBytes(StandardCharsets.UTF_8)); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals("data.txt", stream.name()); + Assertions.assertArrayEquals("content".getBytes(StandardCharsets.UTF_8), + stream.entryStream().readAllBytes()); + } + + @Test + void testGnuHeaderDoesNotReadItsAtimeAsANamePrefix() throws IOException { + final byte[] content = "short\n".getBytes(StandardCharsets.UTF_8); + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + tar.write(TarArchives.gnuHeader("./short.txt", content.length, TYPE_REGULAR_FILE, + "15237132225")); + tar.write(content); + tar.write(new byte[BLOCK - content.length]); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals("./short.txt", stream.name()); + Assertions.assertArrayEquals(content, stream.entryStream().readAllBytes()); + } + + /** + * Supplies the two sparse entry declarations. Their archived bytes describe file holes, + * so copying those bytes would produce incorrect content. + * + * @return One case per sparse declaration. Never {@code null}. + */ + private static Stream sparseEntries() { + return Stream.of( + Arguments.of("GNU sparse type flag", (Supplier) () -> { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + tar.writeBytes(header("holes.bin", 0, 'S')); + return tar.toByteArray(); + }), + Arguments.of("pax sparse records", (Supplier) () -> { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + try { + entry(tar, "./PaxHeaders/holes.bin", + concat(TarArchives.paxRecord("GNU.sparse.major", "1"), + TarArchives.paxRecord("GNU.sparse.name", "holes.bin")), 'x'); + entry(tar, "holes.bin", "not the content".getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new IllegalStateException(e); + } + return tar.toByteArray(); + })); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("sparseEntries") + void testSparseEntriesAreRejected(String description, Supplier archive) { + final TarStream stream = new TarStream(new ByteArrayInputStream(archive.get())); + + final IOException thrown = Assertions.assertThrows(IOException.class, stream::next); + Assertions.assertEquals(SPARSE_REJECTED, thrown.getMessage()); + } + + @Test + void testBase256SizeFieldIsRead() throws IOException { + final byte[] content = "0123456789".getBytes(StandardCharsets.US_ASCII); + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + tar.write(TarArchives.base256Header("big.bin", content.length, TYPE_REGULAR_FILE)); + tar.write(content); + tar.write(new byte[BLOCK - content.length]); + tar.write(new byte[TERMINATOR_SIZE]); + final TarStream stream = new TarStream(new ByteArrayInputStream(tar.toByteArray())); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals("big.bin", stream.name()); + Assertions.assertEquals(content.length, stream.size()); + Assertions.assertArrayEquals(content, stream.entryStream().readAllBytes()); + } + + @Test + void testBase256SizeFieldCarriesLengthsBeyondTheOctalRange() throws IOException { + final long beyondOctal = 8L * 1024 * 1024 * 1024; + Assertions.assertTrue(beyondOctal > LARGEST_OCTAL_SIZE); + final TarStream stream = new TarStream(new ByteArrayInputStream( + TarArchives.base256Header("huge.bin", beyondOctal, TYPE_REGULAR_FILE))); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals(beyondOctal, stream.size()); + } + + @Test + void testBase256SizeFieldAcceptsTheLargestRepresentableLength() throws IOException { + final TarStream stream = new TarStream(new ByteArrayInputStream( + TarArchives.base256Header("max.bin", Long.MAX_VALUE, TYPE_REGULAR_FILE))); + + Assertions.assertTrue(stream.next()); + Assertions.assertEquals(Long.MAX_VALUE, stream.size()); + final IOException thrown = Assertions.assertThrows(IOException.class, stream::next); + Assertions.assertEquals("truncated tar archive", thrown.getMessage()); + } + + @Test + void testBase256SizeFieldBeyondTheLongRangeIsRejected() { + final byte[] block = TarArchives.base256Header("huge.bin", 0, TYPE_REGULAR_FILE); + // One bit above the 63 a long can hold, left of everything the encoder can write. + block[SIZE_OFFSET + 1] = 1; + TarArchives.reseal(block); + final TarStream stream = new TarStream(new ByteArrayInputStream(block)); + + final IOException thrown = Assertions.assertThrows(IOException.class, stream::next); + Assertions.assertEquals( + "tar size field exceeds the largest length this reader can represent", + thrown.getMessage()); + } + + @Test + void testNegativeBase256SizeFieldIsRejected() { + final TarStream stream = new TarStream(new ByteArrayInputStream( + TarArchives.base256Header("negative.bin", -1, TYPE_REGULAR_FILE))); + + final IOException thrown = Assertions.assertThrows(IOException.class, stream::next); + Assertions.assertEquals("tar size field is negative", thrown.getMessage()); + } +} diff --git a/opennlp-docs/src/docbkx/model-loading.xml b/opennlp-docs/src/docbkx/model-loading.xml index eb2356a80d..8a8d6cb394 100644 --- a/opennlp-docs/src/docbkx/model-loading.xml +++ b/opennlp-docs/src/docbkx/model-loading.xml @@ -164,4 +164,57 @@ for(ClassPathModelEntry entry : models) { - \ No newline at end of file + +
+ Installing third-party resources + + ResourceInstaller copies a supplied http, + https, or file URI into a target directory. Http and https + sources require a SHA-256 or SHA-512 checksum. File sources may omit it. + Gzip-compressed tar and zip archives are unpacked, plain gzip files are decompressed, + and other content is stored under its source name. OpenNLP *.bin model + files remain packed. + + + + + + Content is unpacked in a staging directory on the target filesystem. Archive paths + cannot escape that directory, duplicate file paths are rejected, and existing target + files are not replaced. A checksum, download, or unpacking error promotes no files. + Promotion also rejects symbolic links below the target. Concurrent changes to the + target directory are outside this guarantee. + + + Http and https requests use connection and read timeouts, follow up to five + redirects by default, and reject an https-to-http downgrade. Default limits are 1 GiB + downloaded, 4 GiB expanded, and 100000 archive entries; compressed content may + expand to at most 100 times its compressed size, with a 1 MiB floor for small + sources. These defaults can be set at + JVM startup with + opennlp.download.max.bytes, opennlp.install.max.total.bytes, + opennlp.install.max.entries, and + opennlp.install.max.expansion.ratio. Use + ResourceInstaller.Limits to set limits for one call. + + + The expansion ratio and the expansion limit are enforced independently and the + tighter one applies, so raising opennlp.install.max.total.bytes alone + does not admit an archive that exceeds the ratio. A resource that legitimately + compresses better than 100 to 1, such as a corpus of highly repetitive text, needs + opennlp.install.max.expansion.ratio raised as well. + + + + +
+ diff --git a/opennlp-docs/src/docbkx/stemmer.xml b/opennlp-docs/src/docbkx/stemmer.xml index d777842fd6..ddb5056411 100644 --- a/opennlp-docs/src/docbkx/stemmer.xml +++ b/opennlp-docs/src/docbkx/stemmer.xml @@ -78,7 +78,7 @@ new CachingStemmer(factory).stem("running"); // "run"]]> .aff affix file and its .dic word list. OpenNLP bundles no dictionary data; dictionaries are downloaded separately, and each states its own license. The dictionary is immutable and safe to share; - HunspellStemmerFactory hands out a fresh stemmer per call. + HunspellStemmerFactory creates a fresh stemmer per call. HunspellManualExampleTest asserts the behavior shown here. dev/README-hunspell-dictionaries.md. An opt-in catalog download accepts an application-supplied DictionaryCatalog, needs - -Dopennlp.download.remote=true, and verifies SHA-512 digests. + -Dopennlp.download.remote=true, and fetches through the + digest-verified ResourceInstaller path. A file that already exists + in the target is not replaced. Remove old files before refreshing a dictionary. Directives that would change stems when ignored (ICONV, OCONV, COMPLEXPREFIXES, COMPOUNDRULE, IGNORE, KEEPCASE) diff --git a/opennlp-docs/src/docbkx/tokenizer.xml b/opennlp-docs/src/docbkx/tokenizer.xml index 32ee38e1f4..c666c9001b 100644 --- a/opennlp-docs/src/docbkx/tokenizer.xml +++ b/opennlp-docs/src/docbkx/tokenizer.xml @@ -545,19 +545,25 @@ wordTokenizer.tokenize("The quick brown fox.", (start, end, type) -> { LatticeTokenizer scores paths over a MeCab-format dictionary and emits the cheapest segmentation with spans in original text coordinates. UnigramSegmenter does the same from a plain frequency lexicon. - Install a trusted local dictionary archive with - MecabDictionaryInstaller, load it as a - MecabDictionary, and tokenize. + Install a dictionary archive with MecabDictionaryInstaller, load it + as a MecabDictionary, and tokenize. Remote archives require an + expected SHA-256 or SHA-512 digest; an application-supplied opt-in catalog + (installFromCatalog) also needs + -Dopennlp.download.remote=true. matrix.def must list a cost for every declared cell; matrix dimensions plus lexicon size are bounded by the shared ResourceLimits.MAX_ENTRIES limit and the matrix cell count by ResourceLimits.MAX_MATRIX_CELLS. - Extraction limits per-entry size, total bytes, entry count, and gzip expansion. - The byte limits default to 512 MiB per entry and 2 GiB total and can be raised - at JVM startup via - opennlp.install.max.entry.bytes and - opennlp.install.max.total.bytes for larger dictionaries such as - UniDic. + Fetching, verification, and unpacking use ResourceInstaller, + described in the model loading chapter. Default limits are a 1 GiB + download, 4 GiB unpacked, and 100000 archive entries. Set higher limits at + JVM startup via + opennlp.download.max.bytes, + opennlp.install.max.total.bytes, + opennlp.install.max.entries, and + opennlp.install.max.expansion.ratio for larger dictionaries such as + UniDic. Installed files are flattened to their base names. Existing target + files are not replaced and must be removed before a refresh. Dictionary loading rejects empty lexicon surfaces, duplicate matrix.def entries, duplicate char.def categories, costs outside the signed 16-bit range, malformed text in the @@ -570,13 +576,10 @@ wordTokenizer.tokenize("The quick brown fox.", (start, end, type) -> { provides unknown-word settings. Grouping continues while successive assignments overlap. Unknown words use their category's unk.def template first, with DEFAULT used when no category template exists. - Tar headers are checksum-validated before extraction. Files are staged on - the target filesystem and published after the archive passes validation. The - installer does not replace files already present in the target directory. - -LatticeUsageExampleTest asserts the install-load-tokenize and + lexicon flows shown here. + +