diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..afa5a1f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +jobs: + linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + - name: Build JVM, Android, web, Linux, and Android Native + run: >- + ./gradlew + :kflate:koverVerifyJvm + :kflate:compileAndroidMain + :kflate:compileKotlinJs + :kflate:compileKotlinWasmJs + :web-demo:assembleWebDemo + :kflate:compileKotlinLinuxX64 + :kflate:compileKotlinLinuxArm64 + :kflate:compileKotlinAndroidNativeArm32 + :kflate:compileKotlinAndroidNativeArm64 + :kflate:compileKotlinAndroidNativeX64 + :kflate:compileKotlinAndroidNativeX86 + apple: + runs-on: macos-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + - name: Build Apple targets + run: >- + ./gradlew + :kflate:compileKotlinIosX64 + :kflate:compileKotlinIosArm64 + :kflate:compileKotlinIosSimulatorArm64 + :kflate:compileKotlinMacosArm64 + :kflate:compileKotlinTvosArm64 + :kflate:compileKotlinTvosSimulatorArm64 + :kflate:compileKotlinWatchosArm32 + :kflate:compileKotlinWatchosArm64 + :kflate:compileKotlinWatchosSimulatorArm64 + + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + - name: Build Windows target + run: ./gradlew.bat :kflate:compileKotlinMingwX64 diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 2696100..0d5a965 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -36,7 +36,7 @@ jobs: - name: Assemble deploy directory run: | mkdir -p deploy - cp web-demo/build/webDemo/* deploy/ + cp -R web-demo/build/webDemo/. deploy/ - name: Deploy to gh-pages branch uses: peaceiris/actions-gh-pages@v4 diff --git a/.gitignore b/.gitignore index 02f67d5..caeb9fa 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,7 @@ Pods/ kotlin-js-store .claude docs -CLAUDE.md +AGENTS.md +.impeccable/ +DESIGN.md +design.md diff --git a/KFlate-Logo.svg b/KFlate-Logo.svg index ea80c80..fd9acde 100644 --- a/KFlate-Logo.svg +++ b/KFlate-Logo.svg @@ -1,53 +1,45 @@ - KFlate Abstract Logo - A modern, minimalist abstract logo for KFlate representing data compression - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + - - - - - - - \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed 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. diff --git a/README.md b/README.md index b80d7bf..a05d659 100644 --- a/README.md +++ b/README.md @@ -3,135 +3,164 @@

Pure Kotlin Multiplatform DEFLATE, GZIP, and ZLIB compression.

- KFlate-Logo + KFlate logo

Maven Central - + License - Platform Targets + Platform targets

- KFlate is a Kotlin Multiplatform port of the npm fflate library. It provides compression and decompression with configurable levels, dictionary support, and both blocking and streaming APIs across KMP targets. + KFlate is an independently written Kotlin implementation based on the design and API ideas of the npm fflate library.

- KFlate Web Compressor (Powered by WASM) + KFlate Web Compressor using Wasm
-### Key Features +## Features -- **Pure Kotlin Implementation**: No native dependencies, works everywhere Kotlin runs. -- **Multiplatform Support**: JVM, Android, JS (Browser/Node), WASM, and native targets. -- **Multiple Compression Formats**: Raw DEFLATE, GZIP with optional headers, and ZLIB with dictionary support. -- **Flexible APIs**: Both blocking and streaming (`kotlinx-io`) interfaces. -- **Configurable Compression**: Compression levels 0-9 with intelligent hash table sizing. -- **Dictionary Support**: Full preset dictionary support for DEFLATE/ZLIB (max 32 KB). -- **Production Ready**: Tested against standard tools and libraries. +- Raw DEFLATE, RFC 1952 GZIP, and RFC 1950 ZLIB. +- Blocking `ByteArray` and streaming `kotlinx-io` APIs. +- Compression levels from 0 through 9 with automatic hash-table sizing. +- Preset dictionaries for raw DEFLATE and ZLIB. +- GZIP filename, comment, extra fields, modification time, and header CRC. +- JVM, Android, JS, Wasm, and Kotlin/Native targets. -### Performance +## Setup -KFlate delivers performance comparable to standard implementations across all platforms: - -- **Native targets**: Matches **zlib** -- **JVM**: Matches **Java standard library** -- **Web**: Matches **fflate** - -For detailed benchmark results of each iteration, see the [performance](performance/) folder. - - -### Setup - -Add KFlate to your `commonMain` dependencies: +Add KFlate to `commonMain`: ```kotlin kotlin { sourceSets { commonMain.dependencies { - implementation("com.rafambn:KFlate:1.0.0") + implementation("com.rafambn:KFlate:1.1.0") } } } ``` -### Usage +## Imports -With KFlate, you select a format config and call the same API to compress/decompress: - -### Raw DEFLATE +Compression and decompression formats use the same short names in separate packages. Kotlin import aliases keep both sides explicit: ```kotlin import com.rafambn.kflate.KFlate -import com.rafambn.kflate.RAW -import com.rafambn.kflate.Raw - -val input = "hello".encodeToByteArray() -val deflated = KFlate.compress(input, RAW()) -val inflated = KFlate.decompress(deflated, Raw()) +import com.rafambn.kflate.compression.Gzip as CompressionGzip +import com.rafambn.kflate.compression.Raw as CompressionRaw +import com.rafambn.kflate.compression.Zlib as CompressionZlib +import com.rafambn.kflate.decompression.Gzip as DecompressionGzip +import com.rafambn.kflate.decompression.Raw as DecompressionRaw +import com.rafambn.kflate.decompression.Zlib as DecompressionZlib +import com.rafambn.kflate.error.FlateError ``` -### GZIP +## Blocking API ```kotlin -import com.rafambn.kflate.KFlate -import com.rafambn.kflate.GZIP -import com.rafambn.kflate.Gzip - val input = "hello".encodeToByteArray() -val options = GZIP( - filename = "hello.txt", - comment = "example", - extraFields = mapOf("AB" to byteArrayOf(1, 2)), - includeHeaderCrc = true +val deflated = KFlate.compress(input, CompressionRaw()) +val inflated = KFlate.decompress(deflated, DecompressionRaw()) + +val gzip = KFlate.compress( + input, + CompressionGzip( + filename = "hello.txt", + comment = "example", + extraFields = mapOf("AB" to byteArrayOf(1, 2)), + includeHeaderCrc = true, + ), ) +val ungzipped = KFlate.decompress(gzip, DecompressionGzip()) -val gz = KFlate.compress(input, options) -val roundTrip = KFlate.decompress(gz, Gzip()) +val dictionary = "common bytes".encodeToByteArray() +val zlib = KFlate.compress(input, CompressionZlib(dictionary = dictionary)) +val unzlib = KFlate.decompress( + zlib, + DecompressionZlib(dictionary = dictionary), +) ``` -### ZLIB +GZIP does not support preset dictionaries because RFC 1952 has no interoperable field for one. + +## Streaming API + +The streaming overloads read a `RawSource`, write to a `RawSink`, and flush the buffered sink. KFlate does not close either resource. A decompression failure may leave bytes already written to the sink. ```kotlin -import com.rafambn.kflate.KFlate -import com.rafambn.kflate.ZLIB -import com.rafambn.kflate.Zlib +KFlate.compress( + type = CompressionZlib(level = 6), + source = inputSource, + sink = compressedSink, +) -val input = "hello".encodeToByteArray() -val z = KFlate.compress(input, ZLIB()) -val out = KFlate.decompress(z, Zlib()) +KFlate.decompress( + type = DecompressionZlib(maxOutputSize = 64 * 1_024 * 1_024), + source = compressedSource, + sink = outputSink, +) +``` + +## Options + +All compression formats accept `level` from 0 through 9. The default is 6. + +- 0: No compression +- 1–3: Greedy parsing +- 4–8: Lazy parsing +- 9: Cost-aware parsing + +KFlate sizes the hash table automatically from the compression level and input size. Streaming compression keeps a fixed level-based hash-table size for the entire stream. + +Raw DEFLATE and ZLIB also accept a preset `dictionary` of at most 32 KiB. Decompression requires the same dictionary. + +GZIP compression additionally accepts: + +- `filename` and `comment`: ISO-8859-1 header text without NUL characters. +- `extraFields`: two-byte ISO-8859-1 field IDs mapped to at most 65,535 bytes in total. +- `mtime`: a `kotlin.time.Instant` within the unsigned 32-bit GZIP timestamp range. `null` writes the current time. +- `includeHeaderCrc`: writes the optional GZIP header CRC16. -val dict = "common".encodeToByteArray() -val zWithDict = KFlate.compress(input, ZLIB(dictionary = dict)) +All decompression formats accept `maxOutputSize`. Set it for untrusted data to stop decompression once the configured number of bytes is reached. + +## Errors + +Malformed, truncated, or oversized compressed data throws `FlateError`. Its `code` contains a `FlateErrorCode`, including `UNEXPECTED_EOF`, checksum errors, and `OUTPUT_LIMIT_EXCEEDED`. + +```kotlin +try { + KFlate.decompress(data, DecompressionGzip(maxOutputSize = 16 * 1_024 * 1_024)) +} catch (error: FlateError) { + println(error.code) +} ``` -### Configuration Options +## Tests and coverage + +Run unit tests and verify 100% instruction and branch coverage: -- **`level`**: Compression level 0–9 (default: 6) - - 0: No compression - - 1–3: Fast compression - - 4–6: Balanced (6 is default) - - 7–9: Maximum compression (9 uses full 1M entry hash table) -- **`bufferSize`**: Internal hash table size (optional, auto-sized per level) -- **`dictionary`**: Preset dictionary up to 32 KB (DEFLATE/ZLIB only) +```sh +./gradlew :kflate:jvmTest :kflate:koverVerifyJvm +``` -### GZIP-Specific Options +Generate the HTML coverage report: -- `filename`: Original filename -- `comment`: File comment -- `extraFields`: Custom header fields -- `mtime`: Modification time -- `includeHeaderCrc`: Include CRC16 of header +```sh +./gradlew :kflate:koverHtmlReportJvm +``` -### Decompression Options +## License -- **`dictionary`**: Preset dictionary for DEFLATE/ZLIB (required if compression used one) +KFlate is available under the [Apache License 2.0](LICENSE). diff --git a/build.gradle.kts b/build.gradle.kts index 9c358f1..c3dd0ac 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,5 @@ plugins { alias(libs.plugins.android.kmp.library).apply(false) alias(libs.plugins.maven.publish).apply(false) alias(libs.plugins.android.application).apply(false) + alias(libs.plugins.kover).apply(false) } diff --git a/gradle.properties b/gradle.properties index d6c28c7..ad2d921 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,9 +9,13 @@ kotlin.code.style=official kotlin.daemon.jvmargs=-Xmx4G kotlin.native.binary.gc=cms kotlin.incremental.wasm=true +kotlin.mpp.applyDefaultHierarchyTemplate=false #Android android.useAndroidX=true android.nonTransitiveRClass=true #Compose org.jetbrains.compose.experimental.jscanvas.enabled=true org.jetbrains.compose.experimental.macos.enabled=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..fa4ed51 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect +toolchainVersion=25 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6eca9d2..bd4faa5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,19 +1,14 @@ [versions] -file = "0.6.0" java = "17" -kotlin = "2.3.0" -agp = "9.0.0" +kotlin = "2.3.21" +agp = "9.2.0" maven-publish = "0.36.0" -io = "0.8.2" -kompress = "1.3.1" -datetime = "0.6.1" +io = "0.9.0" +kover = "0.9.9" [libraries] -file = { module = "io.matthewnelson.kmp-file:file", version.ref = "file" } kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "io" } -kompress-core = { module = "dev.karmakrafts.kompress:kompress-core", version.ref = "kompress" } -kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } [plugins] @@ -21,3 +16,4 @@ multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotl android-kmp-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" } android-application = { id = "com.android.application", version.ref = "agp" } +kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f78a6..1a70468 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/kflate/build.gradle.kts b/kflate/build.gradle.kts index 2d06733..3ac1713 100644 --- a/kflate/build.gradle.kts +++ b/kflate/build.gradle.kts @@ -3,24 +3,28 @@ import com.vanniktech.maven.publish.JavadocJar import com.vanniktech.maven.publish.KotlinMultiplatform import com.vanniktech.maven.publish.SourcesJar +import kotlinx.kover.gradle.plugin.dsl.CoverageUnit +import org.gradle.api.tasks.bundling.AbstractArchiveTask import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { alias(libs.plugins.multiplatform) alias(libs.plugins.android.kmp.library) alias(libs.plugins.maven.publish) + alias(libs.plugins.kover) } group = "com.rafambn" -version = "1.0.0" +version = "1.1.0" kotlin { jvmToolchain(libs.versions.java.get().toInt()) - androidLibrary { + android { namespace = "com.rafambn" compileSdk = 36 minSdk = 24 + withHostTest {} } jvm() js(IR) { @@ -32,13 +36,7 @@ kotlin { } } } - nodejs { - testTask { - useKarma { - useChromiumHeadless() - } - } - } + nodejs() } wasmJs { useEsModules() @@ -49,37 +47,28 @@ kotlin { } } } - nodejs { - testTask { - useKarma { - useChromiumHeadless() - } - } - } + nodejs() } iosX64() iosArm64() iosSimulatorArm64() mingwX64() - linuxX64{ + linuxX64 { binaries.test("release") { optimized = true debuggable = false } } linuxArm64() - macosX64() macosArm64() androidNativeArm32() androidNativeArm64() androidNativeX64() androidNativeX86() tvosArm64() - tvosX64() tvosSimulatorArm64() watchosArm32() watchosArm64() - watchosX64() watchosSimulatorArm64() sourceSets { @@ -89,58 +78,24 @@ kotlin { commonTest.dependencies { implementation(kotlin("test")) - implementation(libs.kompress.core) - implementation(libs.file) - implementation(libs.kotlinx.datetime) } } } -// Performance benchmark tasks -tasks.register("benchmarkNativeRelease") { - group = "benchmark" - description = "Run native release performance benchmark" - dependsOn("linkReleaseReleaseTestLinuxX64") - workingDir = project.rootDir - val binaryPath = project.layout.buildDirectory.file("bin/linuxX64/releaseReleaseTest/release.kexe").get().asFile.absolutePath - commandLine = listOf(binaryPath) - doFirst { - println("\n=== Running KFlate Native Release Benchmark ===\n") - } -} - -tasks.register("benchmarkJvmRelease") { - group = "benchmark" - description = "Run JVM release performance benchmark" - dependsOn("jvmTest") - doFirst { - println("\n=== Running KFlate JVM Benchmark ===\n") - } -} - -tasks.register("benchmarkWasmJs") { - group = "benchmark" - description = "Run WASM/JS (Node.js) performance benchmark" - dependsOn("cleanWasmJsNodeTest", "wasmJsNodeTest") - doFirst { - println("\n=== Running KFlate WASM/JS (Node.js) Benchmark ===\n") +kover { + reports { + verify { + rule { + minBound(100, CoverageUnit.INSTRUCTION) + minBound(100, CoverageUnit.BRANCH) + } + } } } -tasks.register("benchmarkAll") { - group = "benchmark" - description = "Run all performance benchmarks (JVM + Native Release + WASM/JS)" - dependsOn("benchmarkJvmRelease", "benchmarkNativeRelease", "benchmarkWasmJs") - doFirst { - println("\n" + "=".repeat(60)) - println("Running KFlate Performance Benchmarks (All Platforms)") - println("=".repeat(60) + "\n") - } - doLast { - println("\n" + "=".repeat(60)) - println("Benchmark Results") - println("Check performance/ directory for detailed results") - println("=".repeat(60) + "\n") +tasks.withType().configureEach { + from(rootProject.file("LICENSE")) { + into("META-INF") } } @@ -148,7 +103,7 @@ mavenPublishing { coordinates( groupId = "com.rafambn", artifactId = "KFlate", - version = "1.0.0" + version = project.version.toString(), ) // Configure POM metadata for the published artifact @@ -189,4 +144,4 @@ mavenPublishing { androidVariantsToPublish = listOf("release"), ) ) -} \ No newline at end of file +} diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/DecompressionTypes.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/DecompressionTypes.kt deleted file mode 100644 index 086dff0..0000000 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/DecompressionTypes.kt +++ /dev/null @@ -1,94 +0,0 @@ - -package com.rafambn.kflate - -/** - * Base interface for decompression configuration options. - */ -sealed interface DecompressionType { - /** - * A buffer containing common byte sequences in the input data that can be used to significantly improve compression ratios. - * - * Dictionaries should be 32kB or smaller and include strings or byte sequences likely to appear in the input. - * The decompressor must supply the same dictionary as the compressor to extract the original data. - * - * Dictionaries only improve aggregate compression ratio when reused across multiple small inputs. They should typically not be used otherwise. - * - * Avoid using dictionaries with GZIP and ZIP to maximize software compatibility. - */ - val dictionary: ByteArray? -} - -data class Raw( - override val dictionary: ByteArray? = null -) : DecompressionType { - init { - dictionary?.let { - require(it.size <= 32768) { "dictionary must be 32kB or smaller, but was ${it.size} bytes" } - } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as Raw - - if (!dictionary.contentEquals(other.dictionary)) return false - - return true - } - - override fun hashCode(): Int { - return dictionary?.contentHashCode() ?: 0 - } -} - -data class Gzip( - override val dictionary: ByteArray? = null -) : DecompressionType { - init { - dictionary?.let { - require(it.size <= 32768) { "dictionary must be 32kB or smaller, but was ${it.size} bytes" } - } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as Gzip - - if (!dictionary.contentEquals(other.dictionary)) return false - - return true - } - - override fun hashCode(): Int { - return dictionary?.contentHashCode() ?: 0 - } -} - -data class Zlib( - override val dictionary: ByteArray? = null -) : DecompressionType { - init { - dictionary?.let { - require(it.size <= 32768) { "dictionary must be 32kB or smaller, but was ${it.size} bytes" } - } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as Zlib - - if (!dictionary.contentEquals(other.dictionary)) return false - - return true - } - - override fun hashCode(): Int { - return dictionary?.contentHashCode() ?: 0 - } -} diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/KFlate.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/KFlate.kt index 8497aeb..a059ba7 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/KFlate.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/KFlate.kt @@ -1,11 +1,19 @@ package com.rafambn.kflate +import com.rafambn.kflate.compression.CompressionType +import com.rafambn.kflate.compression.Gzip as CompressionGzip +import com.rafambn.kflate.compression.Raw as CompressionRaw +import com.rafambn.kflate.compression.Zlib as CompressionZlib import com.rafambn.kflate.compression.compressRaw import com.rafambn.kflate.compression.compressGzip import com.rafambn.kflate.compression.compressZlib import com.rafambn.kflate.compression.compressStreamRaw import com.rafambn.kflate.compression.compressStreamGzip import com.rafambn.kflate.compression.compressStreamZlib +import com.rafambn.kflate.decompression.DecompressionType +import com.rafambn.kflate.decompression.Gzip as DecompressionGzip +import com.rafambn.kflate.decompression.Raw as DecompressionRaw +import com.rafambn.kflate.decompression.Zlib as DecompressionZlib import com.rafambn.kflate.decompression.decompressRaw import com.rafambn.kflate.decompression.decompressGzip import com.rafambn.kflate.decompression.decompressZlib @@ -15,37 +23,56 @@ import com.rafambn.kflate.decompression.decompressStreamZlib import kotlinx.io.RawSink import kotlinx.io.RawSource +/** Blocking and streaming DEFLATE, GZIP, and ZLIB operations. */ object KFlate { + /** Compresses [data] with the selected format. */ fun compress(data: ByteArray, type: CompressionType): ByteArray { return when (type) { - is RAW -> compressRaw(data, type) - is GZIP -> compressGzip(data, type) - is ZLIB -> compressZlib(data, type) + is CompressionRaw -> compressRaw(data, type) + is CompressionGzip -> compressGzip(data, type) + is CompressionZlib -> compressZlib(data, type) } } + /** + * Decompresses [data] with the selected format. + * + * @throws com.rafambn.kflate.error.FlateError if the input is invalid, truncated, + * or exceeds the configured output limit. + */ fun decompress(data: ByteArray, type: DecompressionType): ByteArray { return when (type) { - is Raw -> decompressRaw(data, type) - is Gzip -> decompressGzip(data, type) - is Zlib -> decompressZlib(data, type) + is DecompressionRaw -> decompressRaw(data, type) + is DecompressionGzip -> decompressGzip(data, type) + is DecompressionZlib -> decompressZlib(data, type) } } + /** + * Reads [source], writes compressed bytes to [sink], and flushes the buffered sink. + * The caller retains ownership of both resources. + */ fun compress(type: CompressionType, source: RawSource, sink: RawSink) { when (type) { - is RAW -> compressStreamRaw(type, source, sink) - is GZIP -> compressStreamGzip(type, source, sink) - is ZLIB -> compressStreamZlib(type, source, sink) + is CompressionRaw -> compressStreamRaw(type, source, sink) + is CompressionGzip -> compressStreamGzip(type, source, sink) + is CompressionZlib -> compressStreamZlib(type, source, sink) } } + /** + * Reads [source], writes decompressed bytes to [sink], and flushes the buffered sink. + * The caller retains ownership of both resources. A failure may leave partial output in [sink]. + * + * @throws com.rafambn.kflate.error.FlateError if the input is invalid, truncated, + * or exceeds the configured output limit. + */ fun decompress(type: DecompressionType, source: RawSource, sink: RawSink) { when (type) { - is Raw -> decompressStreamRaw(type, source, sink) - is Gzip -> decompressStreamGzip(type, source, sink) - is Zlib -> decompressStreamZlib(type, source, sink) + is DecompressionRaw -> decompressStreamRaw(type, source, sink) + is DecompressionGzip -> decompressStreamGzip(type, source, sink) + is DecompressionZlib -> decompressStreamZlib(type, source, sink) } } } diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/algorithm/DeflateCost.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/algorithm/DeflateCost.kt new file mode 100644 index 0000000..9099f38 --- /dev/null +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/algorithm/DeflateCost.kt @@ -0,0 +1,77 @@ +package com.rafambn.kflate.algorithm + +import com.rafambn.kflate.huffman.FIXED_DISTANCE_EXTRA_BITS +import com.rafambn.kflate.huffman.FIXED_DISTANCE_REVERSE_LOOKUP +import com.rafambn.kflate.huffman.FIXED_DISTANCE_TREE +import com.rafambn.kflate.huffman.FIXED_LENGTH_EXTRA_BITS +import com.rafambn.kflate.huffman.FIXED_LENGTH_REVERSE_LOOKUP +import com.rafambn.kflate.huffman.FIXED_LENGTH_TREE + +internal fun chooseCostAwarePath( + data: ByteArray, + start: Int, + end: Int, + matches: IntArray, + costs: IntArray, + choices: IntArray, +) { + // Fixed-tree widths provide stable prices before token frequencies can define a dynamic tree. + val size = end - start + costs[size] = 0 + + for (offset in size - 1 downTo 0) { + val literal = data[start + offset].toInt() and 0xFF + var bestCost = fixedLiteralBitCost(literal) + costs[offset + 1] + var bestLength = 1 + val match = matches[offset] + val distance = match and MATCH_DISTANCE_MASK + val distanceSymbol = FIXED_DISTANCE_REVERSE_LOOKUP[distance] and 31 + val distanceCost = (FIXED_DISTANCE_TREE[distanceSymbol].toInt() and 0xFF) + + (FIXED_DISTANCE_EXTRA_BITS[distanceSymbol].toInt() and 0xFF) + val maximumLength = match ushr MATCH_DISTANCE_BITS + val containedLength = minOf(maximumLength, size - offset) + val searchedLength = minOf(containedLength, COST_AWARE_LENGTH_SEARCH) + + for (length in 3..searchedLength) { + val candidateCost = FIXED_LENGTH_TOKEN_COSTS[length] + distanceCost + costs[offset + length] + if (candidateCost <= bestCost) { + bestCost = candidateCost + bestLength = length + } + } + + if (maximumLength > searchedLength) { + val nextCost = if (offset + maximumLength <= size) costs[offset + maximumLength] else 0 + val candidateCost = FIXED_LENGTH_TOKEN_COSTS[maximumLength] + distanceCost + nextCost + if (candidateCost <= bestCost) { + bestCost = candidateCost + bestLength = maximumLength + } + } + + costs[offset] = bestCost + choices[offset] = bestLength + } +} + +internal fun fixedLiteralBitCost(literal: Int): Int { + return FIXED_LENGTH_TREE[literal].toInt() and 0xFF +} + +internal fun fixedMatchBitCost(length: Int, distance: Int): Int { + val distanceSymbol = FIXED_DISTANCE_REVERSE_LOOKUP[distance] and 31 + return FIXED_LENGTH_TOKEN_COSTS[length] + + (FIXED_DISTANCE_TREE[distanceSymbol].toInt() and 0xFF) + + (FIXED_DISTANCE_EXTRA_BITS[distanceSymbol].toInt() and 0xFF) +} + +// Bound memory without forcing matches to stop at a window boundary. +internal const val COST_AWARE_WINDOW_SIZE = 262_144 +// Price every short match length plus the longest available match. +private const val COST_AWARE_LENGTH_SEARCH = 64 + +private val FIXED_LENGTH_TOKEN_COSTS = IntArray(259) { length -> + val lengthSymbol = FIXED_LENGTH_REVERSE_LOOKUP[length] and 31 + (FIXED_LENGTH_TREE[257 + lengthSymbol].toInt() and 0xFF) + + (FIXED_LENGTH_EXTRA_BITS[lengthSymbol].toInt() and 0xFF) +} diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/algorithm/DeflateLevel.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/algorithm/DeflateLevel.kt new file mode 100644 index 0000000..327ac28 --- /dev/null +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/algorithm/DeflateLevel.kt @@ -0,0 +1,36 @@ +package com.rafambn.kflate.algorithm + +internal class DeflateLevel( + // Stop walking the hash chain once a match reaches this length. + val niceLength: Int, + // Maximum number of prior positions considered for a match. + val chainLength: Int, + // Look ahead one byte when the current match is shorter than this. + val maxLazyLength: Int, + // Reduce the lookahead chain search once the current match reaches this length. + val goodMatchLength: Int, + // Cap the hash table so low levels retain their memory and cache advantage. + val maxHashBits: Int, + // Use bounded dynamic programming instead of greedy or one-byte lazy parsing. + val usesCostAwareParsing: Boolean = false, +) + +internal val DEFLATE_LEVELS = arrayOf( + DeflateLevel(niceLength = 0, chainLength = 0, maxLazyLength = 0, goodMatchLength = 0, maxHashBits = 12), + DeflateLevel(niceLength = 8, chainLength = 4, maxLazyLength = 0, goodMatchLength = 0, maxHashBits = 12), + DeflateLevel(niceLength = 16, chainLength = 8, maxLazyLength = 0, goodMatchLength = 0, maxHashBits = 13), + DeflateLevel(niceLength = 16, chainLength = 16, maxLazyLength = 0, goodMatchLength = 0, maxHashBits = 13), + DeflateLevel(niceLength = 16, chainLength = 32, maxLazyLength = 4, goodMatchLength = 4, maxHashBits = 14), + DeflateLevel(niceLength = 32, chainLength = 32, maxLazyLength = 16, goodMatchLength = 8, maxHashBits = 14), + DeflateLevel(niceLength = 128, chainLength = 128, maxLazyLength = 16, goodMatchLength = 8, maxHashBits = 15), + DeflateLevel(niceLength = 128, chainLength = 256, maxLazyLength = 32, goodMatchLength = 8, maxHashBits = 15), + DeflateLevel(niceLength = 258, chainLength = 1_024, maxLazyLength = 128, goodMatchLength = 32, maxHashBits = 16), + DeflateLevel( + niceLength = 258, + chainLength = 4_096, + maxLazyLength = 0, + goodMatchLength = 0, + maxHashBits = 20, + usesCostAwareParsing = true, + ), +) diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/algorithm/FlateAlgorithms.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/algorithm/FlateAlgorithms.kt index ba4c744..95068e1 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/algorithm/FlateAlgorithms.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/algorithm/FlateAlgorithms.kt @@ -1,13 +1,11 @@ -@file:OptIn(ExperimentalTime::class) - package com.rafambn.kflate.algorithm -import com.rafambn.kflate.CompressionType -import com.rafambn.kflate.RAW -import com.rafambn.kflate.GZIP -import com.rafambn.kflate.ZLIB +import com.rafambn.kflate.compression.CompressionType +import com.rafambn.kflate.compression.Raw +import com.rafambn.kflate.compression.Gzip +import com.rafambn.kflate.compression.Zlib import com.rafambn.kflate.error.FlateErrorCode -import com.rafambn.kflate.error.createFlateError +import com.rafambn.kflate.error.FlateError import com.rafambn.kflate.huffman.FIXED_DISTANCE_BASE import com.rafambn.kflate.huffman.FIXED_DISTANCE_EXTRA_BITS import com.rafambn.kflate.huffman.FIXED_DISTANCE_REVERSE_MAP @@ -16,7 +14,6 @@ import com.rafambn.kflate.huffman.FIXED_LENGTH_BASE import com.rafambn.kflate.huffman.FIXED_LENGTH_EXTRA_BITS import com.rafambn.kflate.huffman.FIXED_LENGTH_REVERSE_LOOKUP import com.rafambn.kflate.huffman.FIXED_LENGTH_REVERSE_MAP -import com.rafambn.kflate.huffman.DEFLATE_OPTIONS import com.rafambn.kflate.huffman.CODE_LENGTH_INDEX_MAP import com.rafambn.kflate.huffman.createHuffmanTree import com.rafambn.kflate.huffman.validateHuffmanCodeLengths @@ -29,38 +26,52 @@ import com.rafambn.kflate.util.readTwoBytes import com.rafambn.kflate.util.shiftToNextByte import com.rafambn.kflate.util.writeBlock import com.rafambn.kflate.util.writeFixedBlock -import kotlin.math.ceil -import kotlin.math.ln -import kotlin.math.max -import kotlin.math.min -import kotlin.time.ExperimentalTime internal fun inflate( inputData: ByteArray, inflateState: InflateState, - outputBuffer: ByteArray? = null, - dictionary: ByteArray? = null + dictionary: ByteArray? = null, + maxOutputSize: Int? = null, ): ByteArray { val sourceLength = inputData.size val dictionaryLength = dictionary?.size ?: 0 - if (sourceLength == 0 || (inflateState.isFinalBlock && inflateState.literalMap == null)) { - return outputBuffer ?: ByteArray(0) + if (inflateState.isFinalBlock && inflateState.literalMap == null) { + return ByteArray(0) } + if (sourceLength == 0) { + if (inflateState.validationMode != 0) { + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) + } + return ByteArray(0) + } + validateInflateInputSize(sourceLength) - var workingBuffer = outputBuffer - val isBufferProvided = workingBuffer != null - - val needsResize = !isBufferProvided || inflateState.validationMode != 2 val hasNoStoredState = inflateState.validationMode != 0 - - if (!isBufferProvided) - workingBuffer = ByteArray(maxOf(sourceLength * 3, 32768)) - - fun ensureCapacity(requiredSize: Int) { - val currentBuffer = workingBuffer!! + val suggestedCapacity = minOf( + maxOf(sourceLength.toLong() * 3L, 32_768L), + 1_048_576L, + ).toInt() + val initialCapacity = maxOutputSize?.let { minOf(it, suggestedCapacity) } ?: suggestedCapacity + var workingBuffer = ByteArray(initialCapacity) + + fun ensureCapacity(additionalBytes: Int, bytesWritten: Int) { + val requiredSizeLong = bytesWritten.toLong() + additionalBytes.toLong() + if (maxOutputSize != null && requiredSizeLong > maxOutputSize.toLong()) { + throw FlateError(FlateErrorCode.OUTPUT_LIMIT_EXCEEDED) + } + if (requiredSizeLong > Int.MAX_VALUE.toLong()) { + throw FlateError(FlateErrorCode.OUTPUT_LIMIT_EXCEEDED) + } + val requiredSize = requiredSizeLong.toInt() + val currentBuffer = workingBuffer if (requiredSize > currentBuffer.size) { - val newSize = maxOf(currentBuffer.size * 2, requiredSize) + val doubledSize = minOf( + maxOf(currentBuffer.size.toLong() * 2L, 1L), + Int.MAX_VALUE.toLong(), + ).toInt() + val grownSize = maxOf(doubledSize, requiredSize) + val newSize = maxOutputSize?.let { minOf(grownSize, it) } ?: grownSize val newBuffer = ByteArray(newSize) currentBuffer.copyInto(newBuffer) workingBuffer = newBuffer @@ -81,7 +92,7 @@ internal fun inflate( if (literalLengthMap == null) { // Need at least 3 bits for block header (1 BFINAL + 2 BTYPE) if (currentBitPosition + 3 > totalAvailableBits) { - if (hasNoStoredState) createFlateError(FlateErrorCode.UNEXPECTED_EOF) + if (hasNoStoredState) throw FlateError(FlateErrorCode.UNEXPECTED_EOF) break } isFinalBlock = readBits(inputData, currentBitPosition, 1) != 0 @@ -94,7 +105,7 @@ internal fun inflate( // Check if at least 4 bytes remain for LEN and NLEN if (blockStartByte + 4 > sourceLength) { - if (hasNoStoredState) createFlateError(FlateErrorCode.UNEXPECTED_EOF) + if (hasNoStoredState) throw FlateError(FlateErrorCode.UNEXPECTED_EOF) break } @@ -103,18 +114,18 @@ internal fun inflate( // Validate that NLEN is the one's complement of LEN if ((blockLength xor 0xFFFF) != blockNlen) { - createFlateError(FlateErrorCode.INVALID_BLOCK_TYPE) + throw FlateError(FlateErrorCode.INVALID_BLOCK_TYPE) } val dataStartByte = blockStartByte + 4 val blockEndByte = dataStartByte + blockLength if (blockEndByte > sourceLength) { - if (hasNoStoredState) createFlateError(FlateErrorCode.UNEXPECTED_EOF) + if (hasNoStoredState) throw FlateError(FlateErrorCode.UNEXPECTED_EOF) break } - if (needsResize) ensureCapacity(bytesWrittenToOutput + blockLength) + ensureCapacity(blockLength, bytesWrittenToOutput) inputData.copyInto( workingBuffer, @@ -142,7 +153,7 @@ internal fun inflate( 2 -> { // Check if we have at least 14 bits for the block header if (currentBitPosition + 14 > totalAvailableBits) { - if (hasNoStoredState) createFlateError(FlateErrorCode.UNEXPECTED_EOF) + if (hasNoStoredState) throw FlateError(FlateErrorCode.UNEXPECTED_EOF) break } @@ -152,8 +163,8 @@ internal fun inflate( // RFC 1951: HLIT max is 29 (286 codes), HDIST max is 31 (32 codes) // Distance codes 30-31 are never used in valid data but may appear in the tree - if (numLiteralCodes > 286 || numDistanceCodes > 32) { - createFlateError(FlateErrorCode.INVALID_BLOCK_TYPE) + if (numLiteralCodes > 286) { + throw FlateError(FlateErrorCode.INVALID_BLOCK_TYPE) } val totalCodes = numLiteralCodes + numDistanceCodes @@ -162,7 +173,7 @@ internal fun inflate( // Check if we have enough bits for the code length tree val codeLengthTreeBits = numCodeLengthCodes * 3 if (currentBitPosition + codeLengthTreeBits > totalAvailableBits) { - if (hasNoStoredState) createFlateError(FlateErrorCode.UNEXPECTED_EOF) + if (hasNoStoredState) throw FlateError(FlateErrorCode.UNEXPECTED_EOF) break } @@ -175,11 +186,7 @@ internal fun inflate( val codeLengthMaxBits = findMaxValue(codeLengthTree) // Validate code-length tree - if (codeLengthMaxBits > 0) { - if (!validateHuffmanCodeLengths(codeLengthTree, codeLengthMaxBits)) { - createFlateError(FlateErrorCode.INVALID_HUFFMAN_TREE) - } - } + validateCodeLengthTree(codeLengthTree, codeLengthMaxBits) val codeLengthBitMask = (1 shl codeLengthMaxBits) - 1 val codeLengthHuffmanMap = createHuffmanTree(codeLengthTree, codeLengthMaxBits, true) @@ -188,13 +195,15 @@ internal fun inflate( var codeIndex = 0 while (codeIndex < totalCodes) { - if (currentBitPosition > totalAvailableBits) { - if (hasNoStoredState) createFlateError(FlateErrorCode.UNEXPECTED_EOF) - break + val availableBits = totalAvailableBits - currentBitPosition + if (availableBits <= 0) { + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } val huffmanCode = codeLengthHuffmanMap[readBits(inputData, currentBitPosition, codeLengthBitMask)] - currentBitPosition += (huffmanCode.toInt() and 15) + val huffmanCodeLength = huffmanCode.toInt() and 15 + validateCodeLengthEntry(huffmanCodeLength, availableBits, codeLengthMaxBits) + currentBitPosition += huffmanCodeLength val symbol = huffmanCode.toInt() shr 4 when { @@ -204,66 +213,63 @@ internal fun inflate( symbol == 16 -> { if (codeIndex == 0) { - createFlateError(FlateErrorCode.INVALID_BLOCK_TYPE) + throw FlateError(FlateErrorCode.INVALID_BLOCK_TYPE) + } + if (currentBitPosition + 2 > totalAvailableBits) { + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } val repeatCount = 3 + readBits(inputData, currentBitPosition, 3) currentBitPosition += 2 val remainingSlots = totalCodes - codeIndex if (repeatCount > remainingSlots) { - createFlateError(FlateErrorCode.INVALID_BLOCK_TYPE) + throw FlateError(FlateErrorCode.INVALID_BLOCK_TYPE) } val valueToRepeat = allCodeLengths[codeIndex - 1] repeat(repeatCount) { allCodeLengths[codeIndex++] = valueToRepeat } } symbol == 17 -> { + if (currentBitPosition + 3 > totalAvailableBits) { + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) + } val repeatCount = 3 + readBits(inputData, currentBitPosition, 7) currentBitPosition += 3 val remainingSlots = totalCodes - codeIndex if (repeatCount > remainingSlots) { - createFlateError(FlateErrorCode.INVALID_BLOCK_TYPE) + throw FlateError(FlateErrorCode.INVALID_BLOCK_TYPE) } repeat(repeatCount) { allCodeLengths[codeIndex++] = 0 } } - symbol == 18 -> { + else -> { + if (currentBitPosition + 7 > totalAvailableBits) { + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) + } val repeatCount = 11 + readBits(inputData, currentBitPosition, 127) currentBitPosition += 7 val remainingSlots = totalCodes - codeIndex if (repeatCount > remainingSlots) { - createFlateError(FlateErrorCode.INVALID_BLOCK_TYPE) + throw FlateError(FlateErrorCode.INVALID_BLOCK_TYPE) } repeat(repeatCount) { allCodeLengths[codeIndex++] = 0 } } } } - if (currentBitPosition > totalAvailableBits) { - if (hasNoStoredState) createFlateError(FlateErrorCode.UNEXPECTED_EOF) - break - } - - if (codeIndex < totalCodes) { - if (hasNoStoredState) createFlateError(FlateErrorCode.UNEXPECTED_EOF) - break - } - val literalLengthCodeLengths = allCodeLengths.copyOfRange(0, numLiteralCodes) val distanceCodeLengths = allCodeLengths.copyOfRange(numLiteralCodes, totalCodes) // Validate that end-of-block symbol (256) has a non-zero code length - if (numLiteralCodes > 256 && literalLengthCodeLengths[256].toInt() == 0) { - createFlateError(FlateErrorCode.INVALID_HUFFMAN_TREE) + if (literalLengthCodeLengths[256].toInt() == 0) { + throw FlateError(FlateErrorCode.INVALID_HUFFMAN_TREE) } literalMaxBits = findMaxValue(literalLengthCodeLengths) distanceMaxBits = findMaxValue(distanceCodeLengths) // Validate literal/length tree - if (literalMaxBits > 0) { - if (!validateHuffmanCodeLengths(literalLengthCodeLengths, literalMaxBits)) { - createFlateError(FlateErrorCode.INVALID_HUFFMAN_TREE) - } + if (!validateHuffmanCodeLengths(literalLengthCodeLengths, literalMaxBits)) { + throw FlateError(FlateErrorCode.INVALID_HUFFMAN_TREE) } literalLengthMap = createHuffmanTree(literalLengthCodeLengths, literalMaxBits, true) @@ -271,44 +277,56 @@ internal fun inflate( // Validate distance tree if (distanceMaxBits > 0) { if (!validateHuffmanCodeLengths(distanceCodeLengths, distanceMaxBits)) { - createFlateError(FlateErrorCode.INVALID_HUFFMAN_TREE) + throw FlateError(FlateErrorCode.INVALID_HUFFMAN_TREE) } } distanceMap = createHuffmanTree(distanceCodeLengths, distanceMaxBits, true) } - else -> createFlateError(FlateErrorCode.INVALID_BLOCK_TYPE) + else -> throw FlateError(FlateErrorCode.INVALID_BLOCK_TYPE) } - if (currentBitPosition > totalAvailableBits) { - if (hasNoStoredState) createFlateError(FlateErrorCode.UNEXPECTED_EOF) - break - } } - if (needsResize) ensureCapacity(bytesWrittenToOutput + 131072) - val literalBitMask = (1 shl literalMaxBits) - 1 val distanceBitMask = (1 shl distanceMaxBits) - 1 var lastBitPosition = currentBitPosition - val currentLitMap = literalLengthMap!! + val currentLitMap = literalLengthMap val currentDistMap = distanceMap!! while (true) { + val availableLiteralBits = totalAvailableBits - currentBitPosition + if (availableLiteralBits <= 0) { + if (hasNoStoredState) throw FlateError(FlateErrorCode.UNEXPECTED_EOF) + break + } val literalCode = (currentLitMap[readBits16(inputData, currentBitPosition) and literalBitMask].toInt() and 0xFFFF) - val symbol = literalCode shr 4 - currentBitPosition += (literalCode and 15) - - if (currentBitPosition > totalAvailableBits) { - if (hasNoStoredState) createFlateError(FlateErrorCode.UNEXPECTED_EOF) + val literalCodeLength = literalCode and 15 + if (literalCode == 0 && availableLiteralBits < literalMaxBits) { + if (hasNoStoredState) throw FlateError(FlateErrorCode.UNEXPECTED_EOF) break } + if (literalCodeLength > availableLiteralBits) { + if (hasNoStoredState) throw FlateError(FlateErrorCode.UNEXPECTED_EOF) + break + } + val symbol = literalCode shr 4 + currentBitPosition += literalCodeLength - if (literalCode == 0) createFlateError(FlateErrorCode.INVALID_LENGTH_LITERAL) + // Symbols 0..285 are valid. Fixed Huffman symbols 286 and 287 are reserved by RFC 1951. + if (literalCode == 0 || symbol > 285) { + throw FlateError(FlateErrorCode.INVALID_LENGTH_LITERAL) + } when { symbol < 256 -> { + // The normal unbounded path has spare capacity almost all the time. + // Keep output-limit validation on the bounded path, but avoid doing + // long arithmetic and a function call for every literal otherwise. + if (maxOutputSize != null || bytesWrittenToOutput >= workingBuffer.size) { + ensureCapacity(1, bytesWrittenToOutput) + } workingBuffer[bytesWrittenToOutput++] = symbol.toByte() lastBitPosition = currentBitPosition } @@ -325,40 +343,59 @@ internal fun inflate( if (symbol > 264) { val lengthIndex = symbol - 257 val extraBits = FIXED_LENGTH_EXTRA_BITS[lengthIndex].toInt() and 0xFF + if (currentBitPosition + extraBits > totalAvailableBits) { + if (hasNoStoredState) throw FlateError(FlateErrorCode.UNEXPECTED_EOF) + break + } matchLength = readBits(inputData, currentBitPosition, (1 shl extraBits) - 1) + (FIXED_LENGTH_BASE[lengthIndex].toInt() and 0xFFFF) currentBitPosition += extraBits } + val availableDistanceBits = totalAvailableBits - currentBitPosition + if (availableDistanceBits <= 0) { + if (hasNoStoredState) throw FlateError(FlateErrorCode.UNEXPECTED_EOF) + break + } val distanceCode = (currentDistMap[readBits16(inputData, currentBitPosition) and distanceBitMask].toInt() and 0xFFFF) + val distanceCodeLength = distanceCode and 15 + if (distanceCode == 0 && availableDistanceBits < distanceMaxBits) { + if (hasNoStoredState) throw FlateError(FlateErrorCode.UNEXPECTED_EOF) + break + } + if (distanceCodeLength > availableDistanceBits) { + if (hasNoStoredState) throw FlateError(FlateErrorCode.UNEXPECTED_EOF) + break + } val distanceSymbol = distanceCode shr 4 - if (distanceCode == 0) createFlateError(FlateErrorCode.INVALID_DISTANCE) + if (distanceCode == 0) throw FlateError(FlateErrorCode.INVALID_DISTANCE) // RFC 1951: Distance codes 30-31 will never occur in valid compressed data - if (distanceSymbol >= 30) createFlateError(FlateErrorCode.INVALID_DISTANCE) - currentBitPosition += (distanceCode and 15) + if (distanceSymbol >= 30) throw FlateError(FlateErrorCode.INVALID_DISTANCE) + currentBitPosition += distanceCodeLength var matchDistance = FIXED_DISTANCE_BASE[distanceSymbol].toInt() and 0xFFFF if (distanceSymbol > 3) { val extraBits = FIXED_DISTANCE_EXTRA_BITS[distanceSymbol].toInt() and 0xFF + if (currentBitPosition + extraBits > totalAvailableBits) { + if (hasNoStoredState) throw FlateError(FlateErrorCode.UNEXPECTED_EOF) + break + } matchDistance += readBits16(inputData, currentBitPosition) and ((1 shl extraBits) - 1) currentBitPosition += extraBits } - if (currentBitPosition > totalAvailableBits) { - if (hasNoStoredState) createFlateError(FlateErrorCode.UNEXPECTED_EOF) - break + if (maxOutputSize != null || matchLength > workingBuffer.size - bytesWrittenToOutput) { + ensureCapacity(matchLength, bytesWrittenToOutput) } - if (needsResize) ensureCapacity(bytesWrittenToOutput + matchLength) - val copyEndIndex = bytesWrittenToOutput + matchLength - val buffer = workingBuffer!! + val buffer = workingBuffer if (bytesWrittenToOutput < matchDistance) { val dictionaryOffset = dictionaryLength - matchDistance val dictionaryEndIndex = minOf(matchDistance, copyEndIndex) if (dictionaryOffset + bytesWrittenToOutput < 0) { - createFlateError(FlateErrorCode.INVALID_DISTANCE) + throw FlateError(FlateErrorCode.INVALID_DISTANCE) } dictionary!!.copyInto( @@ -370,9 +407,10 @@ internal fun inflate( bytesWrittenToOutput = dictionaryEndIndex } - while (bytesWrittenToOutput < copyEndIndex) { - buffer[bytesWrittenToOutput] = buffer[bytesWrittenToOutput - matchDistance] - bytesWrittenToOutput++ + val remainingMatchLength = copyEndIndex - bytesWrittenToOutput + if (remainingMatchLength > 0) { + copyMatch(buffer, bytesWrittenToOutput, matchDistance, remainingMatchLength) + bytesWrittenToOutput = copyEndIndex } lastBitPosition = currentBitPosition } @@ -393,22 +431,66 @@ internal fun inflate( } while (!isFinalBlock) - return workingBuffer!!.copyOfRange(0, bytesWrittenToOutput) + return workingBuffer.copyOfRange(0, bytesWrittenToOutput) +} + +internal fun copyMatch( + buffer: ByteArray, + destinationOffset: Int, + distance: Int, + length: Int, +) { + if (distance == 1) { + buffer.fill(buffer[destinationOffset - 1], destinationOffset, destinationOffset + length) + return + } + + if (distance >= length) { + buffer.copyInto( + destination = buffer, + destinationOffset = destinationOffset, + startIndex = destinationOffset - distance, + endIndex = destinationOffset - distance + length, + ) + return + } + + buffer.copyInto( + destination = buffer, + destinationOffset = destinationOffset, + startIndex = destinationOffset - distance, + endIndex = destinationOffset, + ) + + var copied = distance + while (copied < length) { + val copyLength = minOf(copied, length - copied) + buffer.copyInto( + destination = buffer, + destinationOffset = destinationOffset + copied, + startIndex = destinationOffset, + endIndex = destinationOffset + copyLength, + ) + copied += copyLength + } } internal fun deflate( data: ByteArray, level: Int, - compressionLevel: Int, + hashBits: Int, prefixSize: Int, postfixSize: Int, state: DeflateState ): ByteArray { val dataSize = state.inputEndIndex.takeIf { it != 0 } ?: data.size // Heuristic: dataSize + 1/8th of dataSize (for expansion) + 256 (for tree/header overhead) + 5 per block - val bufferMargin = (dataSize shr 3) + 256 + 5 * (1 + (dataSize / 7000)) - val output = ByteArray(prefixSize + dataSize + bufferMargin + postfixSize) - val writeBuffer = ByteArray(output.size - prefixSize - postfixSize) + val bufferMargin = (dataSize.toLong() shr 3) + 256L + 5L * (1L + dataSize / 7_000L) + val writeBufferSize = dataSize.toLong() + bufferMargin + if (writeBufferSize > Int.MAX_VALUE.toLong()) { + throw FlateError(FlateErrorCode.INPUT_TOO_LARGE) + } + val writeBuffer = ByteArray(writeBufferSize.toInt()) val isLastBlock = state.isLastChunk var bitPosition: Long = (state.bitBuffer and 7).toLong() @@ -416,114 +498,192 @@ internal fun deflate( if (bitPosition != 0L) { writeBuffer[0] = (state.bitBuffer shr 3).toByte() } - val option = DEFLATE_OPTIONS[level - 1] - val niceLength = option shr 13 - val chainLength = option and 8191 - val mask = (1 shl compressionLevel) - 1 - val prev = state.prev ?: ShortArray(32768) + val levelOptions = DEFLATE_LEVELS[level] + val mask = (1 shl hashBits) - 1 + val prevSize = if (isLastBlock) minOf(MATCH_DISTANCE_MASK + 1, dataSize) else MATCH_DISTANCE_MASK + 1 + val prev = state.prev ?: ShortArray(prevSize) val head = state.head ?: ShortArray(mask + 1) - val baseShift1 = ceil(compressionLevel / 3.0).toInt() - val baseShift2 = 2 * baseShift1 + val hashShift = (hashBits + 2) / 3 - val symbols = IntArray(65536) + val symbols = IntArray(minOf(65536, dataSize)) val literalFrequencies = IntArray(288) val distanceFrequencies = IntArray(32) - var literalCount = 0 + var matchCount = 0 var extraBits = 0 var i = state.inputOffset var symbolIndex = 0 var waitIndex = state.waitIndex var blockStart = maxOf(state.inputOffset, waitIndex) + var pendingMatch = 0 + + if (levelOptions.usesCostAwareParsing) { + while (i < waitIndex && i + 2 < dataSize) { + val hashValue = deflateHash(data, i, hashShift, mask) + val iMod = i and MATCH_DISTANCE_MASK + prev[iMod] = head[hashValue] + head[hashValue] = iMod.toShort() + i++ + } + i = maxOf(i, waitIndex) + blockStart = i + + val costWindowSize = minOf(COST_AWARE_WINDOW_SIZE, maxOf(1, dataSize - i)) + val matches = IntArray(costWindowSize) + val costs = IntArray(costWindowSize + 1) + val choices = IntArray(costWindowSize) + var hashedUntil = i + + while (i < dataSize) { + val hashEnd = minOf(i, dataSize - 2) + while (hashedUntil < hashEnd) { + val hashValue = deflateHash(data, hashedUntil, hashShift, mask) + val hashIndex = hashedUntil and MATCH_DISTANCE_MASK + prev[hashIndex] = head[hashValue] + head[hashValue] = hashIndex.toShort() + hashedUntil++ + } - while (i + 2 < dataSize) { - val hashValue = - ((data[i].toInt() and 0xFF) xor ((data[i + 1].toInt() and 0xFF) shl baseShift1) xor ((data[i + 2].toInt() and 0xFF) shl baseShift2)) and mask - var iMod = i and 32767 - var pIMod = head[hashValue].toInt() and 0xFFFF - prev[iMod] = pIMod.toShort() - head[hashValue] = iMod.toShort() - - if (waitIndex <= i) { - val remaining = dataSize - i - if ((literalCount > 7000 || symbolIndex > 24576) && (remaining > 423 || !isLastBlock)) { - bitPosition = writeBlock( - data, writeBuffer, false, symbols, literalFrequencies, distanceFrequencies, - extraBits, symbolIndex, blockStart, i - blockStart, bitPosition - ) - symbolIndex = 0 - literalCount = 0 - extraBits = 0 - blockStart = i - literalFrequencies.fill(0, 0, 286) - distanceFrequencies.fill(0, 0, 30) + val windowStart = i + val windowEnd = minOf(dataSize, windowStart + costWindowSize) + val windowSize = windowEnd - windowStart + matches.fill(0, 0, windowSize) + + var scanIndex = windowStart + while (scanIndex < windowEnd) { + if (scanIndex + 2 >= dataSize) break + val hashValue = deflateHash(data, scanIndex, hashShift, mask) + val scanIndexMod = scanIndex and MATCH_DISTANCE_MASK + val previousIndex = head[hashValue].toInt() and 0xFFFF + prev[scanIndexMod] = previousIndex.toShort() + head[hashValue] = scanIndexMod.toShort() + matches[scanIndex - windowStart] = + findLongestMatch(data, dataSize, scanIndex, previousIndex, prev, levelOptions) + scanIndex++ } + hashedUntil = maxOf(hashedUntil, scanIndex) - var length = 2 - var distance = 0 - var currentChain = chainLength - var diff = (iMod - pIMod) and 32767 - - if (remaining > 2 && data[i] == data[i - diff] && data[i + 1] == data[i - diff + 1] && data[i + 2] == data[i - diff + 2]) { - val maxN = minOf(niceLength, remaining) - 1 - val maxD = minOf(32767, i) - val maxLength = minOf(258, remaining) - - while (diff <= maxD && --currentChain != 0 && iMod != pIMod) { - if (data[i + length] == data[i + length - diff] && - data[i] == data[i - diff] && - data[i + 1] == data[i + 1 - diff] - ) { - var newLength = 2 - while (newLength < maxLength && data[i + newLength] == data[i + newLength - diff]) { - newLength++ - } - if (newLength > length) { - length = newLength - distance = diff - if (newLength > maxN) break - - // Optimized minMatchDiff loop: stop early if no improvement possible - val minMatchDiff = minOf(diff, newLength - 2) - if (minMatchDiff > 0) { - var maxDiff = 0 - for (j in 0 until minMatchDiff) { - val tI = (i - diff + j) and 32767 - val pTI = prev[tI].toInt() and 0xFFFF - val cD = (tI - pTI) and 32767 - if (cD > maxDiff) { - maxDiff = cD - pIMod = tI - // Early exit if we found the maximum possible distance - if (maxDiff >= maxD) break - } - } - } - } - } - iMod = pIMod - pIMod = prev[iMod].toInt() and 0xFFFF - diff += (iMod - pIMod) and 32767 + chooseCostAwarePath(data, windowStart, windowEnd, matches, costs, choices) + + while (i < windowEnd) { + val remaining = dataSize - i + if (shouldFlushBlock(matchCount, symbolIndex, remaining, isLastBlock)) { + bitPosition = writeBlock( + data, writeBuffer, false, symbols, literalFrequencies, distanceFrequencies, + extraBits, symbolIndex, blockStart, i - blockStart, bitPosition + ) + symbolIndex = 0 + matchCount = 0 + extraBits = 0 + blockStart = i + literalFrequencies.fill(0, 0, 286) + distanceFrequencies.fill(0, 0, 30) + } + + val match = matches[i - windowStart] + val length = choices[i - windowStart] + if (length == 1) { + symbols[symbolIndex++] = data[i].toInt() and 0xFF + ++literalFrequencies[data[i].toInt() and 0xFF] + } else { + val distance = match and MATCH_DISTANCE_MASK + symbols[symbolIndex++] = + 268435456 or + (FIXED_LENGTH_REVERSE_LOOKUP[length] shl 18) or + FIXED_DISTANCE_REVERSE_LOOKUP[distance] + val lenIndex = FIXED_LENGTH_REVERSE_LOOKUP[length] and 31 + val distIndex = FIXED_DISTANCE_REVERSE_LOOKUP[distance] and 31 + extraBits += + (FIXED_LENGTH_EXTRA_BITS[lenIndex].toInt() and 0xFF) + + (FIXED_DISTANCE_EXTRA_BITS[distIndex].toInt() and 0xFF) + ++literalFrequencies[257 + lenIndex] + ++distanceFrequencies[distIndex] + ++matchCount } + i += length } + } + waitIndex = i + } else { + var hashWindow = if (i + 2 < dataSize) deflateHashWindow(data, i) else 0 + while (i + 2 < dataSize) { + val hashValue = deflateHash(hashWindow, hashShift, mask) + val iMod = i and MATCH_DISTANCE_MASK + val pIMod = head[hashValue].toInt() and 0xFFFF + prev[iMod] = pIMod.toShort() + head[hashValue] = iMod.toShort() + + if (waitIndex <= i) { + val remaining = dataSize - i + if (shouldFlushBlock(matchCount, symbolIndex, remaining, isLastBlock)) { + bitPosition = writeBlock( + data, writeBuffer, false, symbols, literalFrequencies, distanceFrequencies, + extraBits, symbolIndex, blockStart, i - blockStart, bitPosition + ) + symbolIndex = 0 + matchCount = 0 + extraBits = 0 + blockStart = i + literalFrequencies.fill(0, 0, 286) + distanceFrequencies.fill(0, 0, 30) + } - if (distance != 0) { - symbols[symbolIndex++] = 268435456 or (FIXED_LENGTH_REVERSE_LOOKUP[length] shl 18) or FIXED_DISTANCE_REVERSE_LOOKUP[distance] - val lenIndex = FIXED_LENGTH_REVERSE_LOOKUP[length] and 31 - val distIndex = FIXED_DISTANCE_REVERSE_LOOKUP[distance] and 31 - extraBits += (FIXED_LENGTH_EXTRA_BITS[lenIndex].toInt() and 0xFF) + (FIXED_DISTANCE_EXTRA_BITS[distIndex].toInt() and 0xFF) - ++literalFrequencies[257 + lenIndex] - ++distanceFrequencies[distIndex] - waitIndex = i + length - ++literalCount - } else { - symbols[symbolIndex++] = data[i].toInt() and 0xFF - ++literalFrequencies[data[i].toInt() and 0xFF] + val match = if (pendingMatch != 0) { + pendingMatch.also { pendingMatch = 0 } + } else { + findLongestMatch(data, dataSize, i, pIMod, prev, levelOptions) + } + val length = match ushr MATCH_DISTANCE_BITS + val distance = match and MATCH_DISTANCE_MASK + + val nextMatch = if (shouldSearchLazyMatch(length, levelOptions.maxLazyLength, remaining)) { + val nextIndex = i + 1 + val nextHashWindow = updateDeflateHashWindow(hashWindow, data[i + 3]) + val nextHash = deflateHash(nextHashWindow, hashShift, mask) + findLongestMatch( + data, + dataSize, + nextIndex, + head[nextHash].toInt() and 0xFFFF, + prev, + levelOptions, + minimumLength = length, + ) + } else { + 0 + } + + if ((nextMatch ushr MATCH_DISTANCE_BITS) > length) { + pendingMatch = nextMatch + symbols[symbolIndex++] = data[i].toInt() and 0xFF + ++literalFrequencies[data[i].toInt() and 0xFF] + } else if (distance != 0) { + symbols[symbolIndex++] = + 268435456 or + (FIXED_LENGTH_REVERSE_LOOKUP[length] shl 18) or + FIXED_DISTANCE_REVERSE_LOOKUP[distance] + val lenIndex = FIXED_LENGTH_REVERSE_LOOKUP[length] and 31 + val distIndex = FIXED_DISTANCE_REVERSE_LOOKUP[distance] and 31 + extraBits += + (FIXED_LENGTH_EXTRA_BITS[lenIndex].toInt() and 0xFF) + + (FIXED_DISTANCE_EXTRA_BITS[distIndex].toInt() and 0xFF) + ++literalFrequencies[257 + lenIndex] + ++distanceFrequencies[distIndex] + waitIndex = i + length + ++matchCount + } else { + symbols[symbolIndex++] = data[i].toInt() and 0xFF + ++literalFrequencies[data[i].toInt() and 0xFF] + } + } + if (i + 3 < dataSize) { + hashWindow = updateDeflateHashWindow(hashWindow, data[i + 3]) } + i++ } - i++ + + i = maxOf(i, waitIndex) } - i = maxOf(i, waitIndex) while (i < dataSize) { symbols[symbolIndex++] = data[i].toInt() and 0xFF literalFrequencies[data[i].toInt() and 0xFF]++ @@ -557,13 +717,24 @@ internal fun deflate( } state.inputOffset = dataSize } - writeBuffer.copyInto(output, destinationOffset = prefixSize) - return output.sliceArray(0 until prefixSize + shiftToNextByte(bitPosition) + postfixSize) + val compressedSize = shiftToNextByte(bitPosition) + val outputSize = prefixSize.toLong() + compressedSize.toLong() + postfixSize.toLong() + if (outputSize > Int.MAX_VALUE.toLong()) { + throw FlateError(FlateErrorCode.INPUT_TOO_LARGE) + } + val output = ByteArray(outputSize.toInt()) + writeBuffer.copyInto( + output, + destinationOffset = prefixSize, + startIndex = 0, + endIndex = compressedSize, + ) + return output } internal fun deflateWithOptions( inputData: ByteArray, - type: CompressionType = RAW(), + type: CompressionType = Raw(), prefixSize: Int, suffixSize: Int, deflateState: DeflateState? = null @@ -571,27 +742,18 @@ internal fun deflateWithOptions( var workingState = deflateState var workingData = inputData - val level = when (type) { - is RAW -> type.level - is GZIP -> type.level - is ZLIB -> type.level - } - val mem = when (type) { - is RAW -> type.mem - is GZIP -> type.mem - is ZLIB -> type.mem - } + val level = type.level val dictionary = when (type) { - is RAW -> type.dictionary - is GZIP -> type.dictionary - is ZLIB -> type.dictionary + is Raw -> type.dictionary + is Gzip -> null + is Zlib -> type.dictionary } if (workingState == null) { workingState = DeflateState(isLastChunk = true) if (dictionary != null) { - val combinedData = ByteArray(dictionary.size + inputData.size) + val combinedData = ByteArray(checkedDeflateInputSize(dictionary.size, inputData.size)) dictionary.copyInto(combinedData, destinationOffset = 0) @@ -602,28 +764,165 @@ internal fun deflateWithOptions( } } - // Cap hash table size per compression level for better CPU cache utilization. - // Lower levels search few chain links and don't need large tables. - val maxHashBitsForLevel = when (level) { - 0, 1 -> 12 // 4K entries = 8KB (L1 cache) - 2, 3 -> 13 // 8K entries = 16KB (L1 cache) - 4, 5 -> 14 // 16K entries = 32KB (L1 cache) - 6, 7 -> 15 // 32K entries = 64KB (L2 cache) - 8 -> 16 // 64K entries = 128KB (L2 cache) - else -> 20 // 1M entries (level 9: max quality, current default) - } - val memoryUsage = if (workingState.isLastChunk && mem == 8) { - minOf(maxHashBitsForLevel, ceil(max(8.0, min(13.0, ln(workingData.size.toDouble()))) * 1.5).toInt()) + val maxHashBitsForLevel = DEFLATE_LEVELS[level].maxHashBits + val hashBits = if (deflateState == null) { + val inputHashBits = 32 - (workingData.size.coerceAtLeast(1) - 1).countLeadingZeroBits() + minOf(maxHashBitsForLevel, maxOf(12, inputHashBits)) } else { - mem + 12 + maxHashBitsForLevel } return deflate( workingData, level, - memoryUsage, + hashBits, prefixSize, suffixSize, workingState ) -} \ No newline at end of file +} + +internal fun validateInflateInputSize(sourceLength: Int) { + if (sourceLength > (Int.MAX_VALUE - 64) / 8) { + throw FlateError(FlateErrorCode.INPUT_TOO_LARGE) + } +} + +internal fun checkedDeflateInputSize(dictionarySize: Int, inputSize: Int): Int { + val combinedSize = dictionarySize.toLong() + inputSize.toLong() + if (combinedSize > Int.MAX_VALUE.toLong()) { + throw FlateError(FlateErrorCode.INPUT_TOO_LARGE) + } + return combinedSize.toInt() +} + +internal fun validateCodeLengthEntry(codeLength: Int, availableBits: Int, maxBits: Int) { + if (codeLength == 0) { + if (availableBits < maxBits) { + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) + } + throw FlateError(FlateErrorCode.INVALID_HUFFMAN_TREE) + } + if (codeLength > availableBits) { + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) + } +} + +internal fun validateCodeLengthTree(codeLengths: ByteArray, maxBits: Int) { + if (maxBits == 0 || !validateHuffmanCodeLengths(codeLengths, maxBits)) { + throw FlateError(FlateErrorCode.INVALID_HUFFMAN_TREE) + } +} + +internal fun shouldFlushBlock( + matchCount: Int, + symbolCount: Int, + remaining: Int, + isLastBlock: Boolean, +): Boolean { + return (matchCount > 7_000 || symbolCount > 24_576) && (remaining > 423 || !isLastBlock) +} + +internal fun hasThreeByteMatch(data: ByteArray, index: Int, distance: Int, remaining: Int): Boolean { + return remaining > 2 && + data[index] == data[index - distance] && + data[index + 1] == data[index - distance + 1] && + data[index + 2] == data[index - distance + 2] +} + +internal fun shouldSearchLazyMatch(length: Int, maxLazyLength: Int, remaining: Int): Boolean { + return maxLazyLength > 0 && length in 3 until maxLazyLength && remaining > length + 1 +} + +private fun deflateHash(data: ByteArray, index: Int, hashShift: Int, mask: Int): Int { + return ((data[index].toInt() and 0xFF) xor + ((data[index + 1].toInt() and 0xFF) shl hashShift) xor + ((data[index + 2].toInt() and 0xFF) shl (2 * hashShift))) and mask +} + +private fun deflateHashWindow(data: ByteArray, index: Int): Int { + return (data[index].toInt() and 0xFF) or + ((data[index + 1].toInt() and 0xFF) shl 8) or + ((data[index + 2].toInt() and 0xFF) shl 16) +} + +private fun updateDeflateHashWindow(window: Int, nextByte: Byte): Int { + return (window ushr 8) or ((nextByte.toInt() and 0xFF) shl 16) +} + +private fun deflateHash(window: Int, hashShift: Int, mask: Int): Int { + return ((window and 0xFF) xor + (((window ushr 8) and 0xFF) shl hashShift) xor + ((window ushr 16) shl (2 * hashShift))) and mask +} + +private fun findLongestMatch( + data: ByteArray, + dataSize: Int, + index: Int, + previousIndex: Int, + previous: ShortArray, + level: DeflateLevel, + minimumLength: Int = 2, +): Int { + val remaining = dataSize - index + var currentIndex = index and MATCH_DISTANCE_MASK + var candidateIndex = previousIndex + var distance = (currentIndex - candidateIndex) and MATCH_DISTANCE_MASK + if (!hasThreeByteMatch(data, index, distance, remaining)) return 0 + + val niceLength = minOf(level.niceLength, remaining) + val maxDistance = minOf(MATCH_DISTANCE_MASK, index) + val maxLength = minOf(MAX_MATCH_LENGTH, remaining) + var remainingChain = level.chainLength + if (level.goodMatchLength > 0 && minimumLength >= level.goodMatchLength) { + remainingChain = maxOf(1, remainingChain shr 2) + } + var bestLength = minimumLength + var bestDistance = 0 + + while (distance <= maxDistance && --remainingChain != 0 && currentIndex != candidateIndex) { + if (data[index + bestLength] == data[index + bestLength - distance] && + data[index] == data[index - distance] && + data[index + 1] == data[index + 1 - distance] + ) { + var candidateLength = 2 + while ( + candidateLength < maxLength && + data[index + candidateLength] == data[index + candidateLength - distance] + ) { + candidateLength++ + } + if (candidateLength > bestLength) { + bestLength = candidateLength + bestDistance = distance + if (candidateLength >= niceLength) break + + val matchSpan = minOf(distance, candidateLength - 2) + var largestPreviousDistance = 0 + for (offset in 0 until matchSpan) { + val matchIndex = (index - distance + offset) and MATCH_DISTANCE_MASK + val previousMatchIndex = previous[matchIndex].toInt() and 0xFFFF + val previousDistance = (matchIndex - previousMatchIndex) and MATCH_DISTANCE_MASK + if (previousDistance > largestPreviousDistance) { + largestPreviousDistance = previousDistance + candidateIndex = matchIndex + if (largestPreviousDistance >= maxDistance) break + } + } + } + } + currentIndex = candidateIndex + candidateIndex = previous[currentIndex].toInt() and 0xFFFF + distance += (currentIndex - candidateIndex) and MATCH_DISTANCE_MASK + } + + // The extra distance bits make a far three-byte match costlier than three literals in most blocks. + if (bestLength == 3 && bestDistance > MAX_DISTANCE_FOR_THREE_BYTE_MATCH) return 0 + return (bestLength shl MATCH_DISTANCE_BITS) or bestDistance +} + +internal const val MATCH_DISTANCE_BITS = 15 +internal const val MATCH_DISTANCE_MASK = 32767 +private const val MAX_MATCH_LENGTH = 258 +private const val MAX_DISTANCE_FOR_THREE_BYTE_MATCH = 4096 diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/CompressionTypes.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/compression/CompressionTypes.kt similarity index 53% rename from kflate/src/commonMain/kotlin/com/rafambn/kflate/CompressionTypes.kt rename to kflate/src/commonMain/kotlin/com/rafambn/kflate/compression/CompressionTypes.kt index aea73ca..c3f087a 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/CompressionTypes.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/compression/CompressionTypes.kt @@ -1,7 +1,10 @@ - -package com.rafambn.kflate +package com.rafambn.kflate.compression import com.rafambn.kflate.util.toIsoStringBytes +import kotlin.collections.iterator +import kotlin.time.Instant + +private const val MAX_GZIP_TIMESTAMP = 0xFFFF_FFFFL /** * Base interface for compression configuration options. @@ -11,7 +14,10 @@ sealed interface CompressionType { * The level of compression to use, ranging from 0-9. * * 0 will store the data without compression. - * 1 is fastest but compresses the worst, 9 is slowest but compresses the best. + * Levels 1-3 greedily select matches with progressively larger search budgets. + * Levels 4-8 also look one byte ahead before accepting short matches. + * Level 9 uses bounded cost-aware parsing to minimize the estimated number of DEFLATE bits. + * Level 1 is usually fastest, while level 9 usually produces the smallest output. * The default level is 6. * * Typically, binary data benefits much more from higher values than text data. @@ -23,39 +29,14 @@ sealed interface CompressionType { * - become 320 kB with level 9 in 100ms */ val level: Int - - /** - * The memory level to use, ranging from 0-12. Increasing this increases speed and compression ratio at the cost of memory. - * - * Note that this is exponential: while level 0 uses 4 kB, level 4 uses 64 kB, level 8 uses 1 MB, and level 12 uses 16 MB. - * It is recommended not to lower the value below 4, since that tends to hurt performance. - * In addition, values above 8 tend to help very little on most data and can even hurt performance. - * - * The default value is automatically determined based on the size of the input data. - */ - val mem: Int - - /** - * A buffer containing common byte sequences in the input data that can be used to significantly improve compression ratios. - * - * Dictionaries should be 32kB or smaller and include strings or byte sequences likely to appear in the input. - * The decompressor must supply the same dictionary as the compressor to extract the original data. - * - * Dictionaries only improve aggregate compression ratio when reused across multiple small inputs. They should typically not be used otherwise. - * - * Avoid using dictionaries with GZIP and ZIP to maximize software compatibility. - */ - val dictionary: ByteArray? } -data class RAW( +data class Raw( override val level: Int = 6, - override val mem: Int = 8, - override val dictionary: ByteArray? = null + val dictionary: ByteArray? = null ) : CompressionType { init { require(level in 0..9) { "level must be in range 0..9, but was $level" } - require(mem in 0..12) { "mem must be in range 0..12, but was $mem" } dictionary?.let { require(it.size <= 32768) { "dictionary must be 32kB or smaller, but was ${it.size} bytes" } } @@ -65,10 +46,9 @@ data class RAW( if (this === other) return true if (other == null || this::class != other::class) return false - other as RAW + other as Raw if (level != other.level) return false - if (mem != other.mem) return false if (!dictionary.contentEquals(other.dictionary)) return false return true @@ -76,44 +56,51 @@ data class RAW( override fun hashCode(): Int { var result = level - result = 31 * result + mem result = 31 * result + (dictionary?.contentHashCode() ?: 0) return result } } -data class GZIP( +data class Gzip( override val level: Int = 6, - override val mem: Int = 8, - override val dictionary: ByteArray? = null, val filename: String? = null, - val mtime: Any? = null, + val mtime: Instant? = null, val comment: String? = null, val extraFields: Map? = null, val includeHeaderCrc: Boolean = false ) : CompressionType { init { require(level in 0..9) { "level must be in range 0..9, but was $level" } - require(mem in 0..12) { "mem must be in range 0..12, but was $mem" } - dictionary?.let { - require(it.size <= 32768) { "dictionary must be 32kB or smaller, but was ${it.size} bytes" } - } - filename?.let { - require(it.length <= 65535) { "Filename cannot exceed 65535 bytes" } - it.toIsoStringBytes() - } - comment?.let { - require(it.length <= 65535) { "Comment cannot exceed 65535 bytes" } - it.toIsoStringBytes() + require(mtime == null || mtime.epochSeconds in 0..MAX_GZIP_TIMESTAMP) { + "mtime must fit the unsigned 32-bit GZIP timestamp field" } + validateHeaderText(filename, "filename") + validateHeaderText(comment, "comment") extraFields?.let { fields -> - var totalXlen = 0 + var totalSize = 0L for ((key, data) in fields) { - require(key.length == 2) { "Extra field ID must be exactly 2 bytes, got: '$key'" } - require(data.size <= 65535) { "Extra field data cannot exceed 65535 bytes" } - totalXlen += 4 + data.size // 4 bytes header (ID + length) + data + val keyBytes = key.toIsoStringBytes() + require(keyBytes.size == 2) { + "Extra field ID must be exactly 2 ISO-8859-1 bytes, got: '$key'" + } + require(keyBytes[1] != 0.toByte()) { + "Extra field ID second byte is reserved and cannot be zero" + } + require(data.size <= 65_535) { "Extra field data cannot exceed 65535 bytes" } + totalSize += 4 + data.size + require(totalSize <= 65_535) { + "Total extra fields size (XLEN) cannot exceed 65535 bytes, got: $totalSize" + } } - require(totalXlen <= 65535) { "Total extra fields size (XLEN) cannot exceed 65535 bytes, got: $totalXlen" } + } + } + + private fun validateHeaderText(value: String?, fieldName: String) { + if (value == null) return + require('\u0000' !in value) { "$fieldName cannot contain a NUL character" } + val bytes = value.toIsoStringBytes() + require(bytes.size <= 65_535) { + "$fieldName cannot exceed 65535 bytes, but was ${bytes.size} bytes" } } @@ -121,11 +108,9 @@ data class GZIP( if (this === other) return true if (other == null || this::class != other::class) return false - other as GZIP + other as Gzip if (level != other.level) return false - if (mem != other.mem) return false - if (!dictionary.contentEquals(other.dictionary)) return false if (filename != other.filename) return false if (mtime != other.mtime) return false if (comment != other.comment) return false @@ -137,8 +122,6 @@ data class GZIP( override fun hashCode(): Int { var result = level - result = 31 * result + mem - result = 31 * result + (dictionary?.contentHashCode() ?: 0) result = 31 * result + (filename?.hashCode() ?: 0) result = 31 * result + (mtime?.hashCode() ?: 0) result = 31 * result + (comment?.hashCode() ?: 0) @@ -148,14 +131,12 @@ data class GZIP( } } -data class ZLIB( +data class Zlib( override val level: Int = 6, - override val mem: Int = 8, - override val dictionary: ByteArray? = null + val dictionary: ByteArray? = null ) : CompressionType { init { require(level in 0..9) { "level must be in range 0..9, but was $level" } - require(mem in 0..12) { "mem must be in range 0..12, but was $mem" } dictionary?.let { require(it.size <= 32768) { "dictionary must be 32kB or smaller, but was ${it.size} bytes" } } @@ -165,10 +146,9 @@ data class ZLIB( if (this === other) return true if (other == null || this::class != other::class) return false - other as ZLIB + other as Zlib if (level != other.level) return false - if (mem != other.mem) return false if (!dictionary.contentEquals(other.dictionary)) return false return true @@ -176,7 +156,6 @@ data class ZLIB( override fun hashCode(): Int { var result = level - result = 31 * result + mem result = 31 * result + (dictionary?.contentHashCode() ?: 0) return result } diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/compression/GzipCompressor.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/compression/GzipCompressor.kt index 23bc07b..334ae79 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/compression/GzipCompressor.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/compression/GzipCompressor.kt @@ -1,6 +1,5 @@ package com.rafambn.kflate.compression -import com.rafambn.kflate.GZIP import com.rafambn.kflate.algorithm.deflateWithOptions import com.rafambn.kflate.checksum.Crc32Checksum import com.rafambn.kflate.format.getGzipHeaderSize @@ -15,9 +14,8 @@ import kotlinx.io.RawSource import kotlinx.io.Sink import kotlinx.io.Source import kotlinx.io.buffered -import kotlinx.io.write -internal fun compressGzip(data: ByteArray, type: GZIP): ByteArray { +internal fun compressGzip(data: ByteArray, type: Gzip): ByteArray { val crc = Crc32Checksum() val dataLength = data.size crc.update(data) @@ -29,7 +27,7 @@ internal fun compressGzip(data: ByteArray, type: GZIP): ByteArray { return deflatedData } -internal fun compressStreamGzip(type: GZIP, source: RawSource, sink: RawSink) { +internal fun compressStreamGzip(type: Gzip, source: RawSource, sink: RawSink) { val bufferedSource = source.buffered() val bufferedSink = sink.buffered() @@ -54,18 +52,13 @@ internal fun compressStreamGzip(type: GZIP, source: RawSource, sink: RawSink) { } private fun deflateStream( - type: GZIP, + type: Gzip, source: Source, sink: Sink, - onInput: ((ByteArray) -> Unit)? + onInput: (ByteArray) -> Unit, ) { - val dictionary = type.dictionary - val state = DeflateState(isLastChunk = false) - var inputBuffer = dictionary ?: ByteArray(0) - if (dictionary != null) { - state.waitIndex = dictionary.size - } + var inputBuffer = ByteArray(0) val readBuffer = ByteArray(STREAM_CHUNK_SIZE) while (true) { @@ -73,23 +66,16 @@ private fun deflateStream( if (read == -1) { break } - if (read == 0) { - continue - } val chunk = readBuffer.copyOfRange(0, read) - onInput?.invoke(chunk) + onInput(chunk) inputBuffer = appendBytes(inputBuffer, chunk, chunk.size) state.isLastChunk = false val compressed = deflateWithOptions(inputBuffer, type, 0, 0, state) - if (compressed.isNotEmpty()) { - sink.write(compressed) - } + sink.write(compressed) inputBuffer = trimDeflateInput(inputBuffer, state) } state.isLastChunk = true val finalOutput = deflateWithOptions(inputBuffer, type, 0, 0, state) - if (finalOutput.isNotEmpty()) { - sink.write(finalOutput) - } + sink.write(finalOutput) } diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/compression/RawCompressor.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/compression/RawCompressor.kt index 5a901e9..40442bc 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/compression/RawCompressor.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/compression/RawCompressor.kt @@ -1,6 +1,5 @@ package com.rafambn.kflate.compression -import com.rafambn.kflate.RAW import com.rafambn.kflate.algorithm.deflateWithOptions import com.rafambn.kflate.streaming.DeflateState import com.rafambn.kflate.streaming.appendBytes @@ -11,26 +10,24 @@ import kotlinx.io.RawSource import kotlinx.io.Sink import kotlinx.io.Source import kotlinx.io.buffered -import kotlinx.io.write -internal fun compressRaw(data: ByteArray, type: RAW): ByteArray { +internal fun compressRaw(data: ByteArray, type: Raw): ByteArray { return deflateWithOptions(data, type, 0, 0) } -internal fun compressStreamRaw(type: RAW, source: RawSource, sink: RawSink) { +internal fun compressStreamRaw(type: Raw, source: RawSource, sink: RawSink) { val bufferedSource = source.buffered() val bufferedSink = sink.buffered() - deflateStream(type, bufferedSource, bufferedSink, null) + deflateStream(type, bufferedSource, bufferedSink) bufferedSink.flush() } private fun deflateStream( - type: RAW, + type: Raw, source: Source, sink: Sink, - onInput: ((ByteArray) -> Unit)? ) { val dictionary = type.dictionary @@ -46,23 +43,15 @@ private fun deflateStream( if (read == -1) { break } - if (read == 0) { - continue - } val chunk = readBuffer.copyOfRange(0, read) - onInput?.invoke(chunk) inputBuffer = appendBytes(inputBuffer, chunk, chunk.size) state.isLastChunk = false val compressed = deflateWithOptions(inputBuffer, type, 0, 0, state) - if (compressed.isNotEmpty()) { - sink.write(compressed) - } + sink.write(compressed) inputBuffer = trimDeflateInput(inputBuffer, state) } state.isLastChunk = true val finalOutput = deflateWithOptions(inputBuffer, type, 0, 0, state) - if (finalOutput.isNotEmpty()) { - sink.write(finalOutput) - } + sink.write(finalOutput) } diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/compression/ZlibCompressor.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/compression/ZlibCompressor.kt index f6a2a09..3abd9cc 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/compression/ZlibCompressor.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/compression/ZlibCompressor.kt @@ -1,6 +1,5 @@ package com.rafambn.kflate.compression -import com.rafambn.kflate.ZLIB import com.rafambn.kflate.algorithm.deflateWithOptions import com.rafambn.kflate.checksum.Adler32Checksum import com.rafambn.kflate.format.writeZlibHeader @@ -14,9 +13,8 @@ import kotlinx.io.RawSource import kotlinx.io.Sink import kotlinx.io.Source import kotlinx.io.buffered -import kotlinx.io.write -internal fun compressZlib(data: ByteArray, type: ZLIB): ByteArray { +internal fun compressZlib(data: ByteArray, type: Zlib): ByteArray { val adler = Adler32Checksum() adler.update(data) val deflatedData = deflateWithOptions(data, type, if (type.dictionary != null) 6 else 2, 4) @@ -26,7 +24,7 @@ internal fun compressZlib(data: ByteArray, type: ZLIB): ByteArray { return deflatedData } -internal fun compressStreamZlib(type: ZLIB, source: RawSource, sink: RawSink) { +internal fun compressStreamZlib(type: Zlib, source: RawSource, sink: RawSink) { val bufferedSource = source.buffered() val bufferedSink = sink.buffered() @@ -48,10 +46,10 @@ internal fun compressStreamZlib(type: ZLIB, source: RawSource, sink: RawSink) { } private fun deflateStream( - type: ZLIB, + type: Zlib, source: Source, sink: Sink, - onInput: ((ByteArray) -> Unit)? + onInput: (ByteArray) -> Unit, ) { val dictionary = type.dictionary @@ -67,23 +65,16 @@ private fun deflateStream( if (read == -1) { break } - if (read == 0) { - continue - } val chunk = readBuffer.copyOfRange(0, read) - onInput?.invoke(chunk) + onInput(chunk) inputBuffer = appendBytes(inputBuffer, chunk, chunk.size) state.isLastChunk = false val compressed = deflateWithOptions(inputBuffer, type, 0, 0, state) - if (compressed.isNotEmpty()) { - sink.write(compressed) - } + sink.write(compressed) inputBuffer = trimDeflateInput(inputBuffer, state) } state.isLastChunk = true val finalOutput = deflateWithOptions(inputBuffer, type, 0, 0, state) - if (finalOutput.isNotEmpty()) { - sink.write(finalOutput) - } + sink.write(finalOutput) } diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/decompression/DecompressionTypes.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/decompression/DecompressionTypes.kt new file mode 100644 index 0000000..2fb9518 --- /dev/null +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/decompression/DecompressionTypes.kt @@ -0,0 +1,78 @@ +package com.rafambn.kflate.decompression + +/** + * Base interface for decompression configuration options. + */ +sealed interface DecompressionType { + val maxOutputSize: Int? +} + +data class Raw( + val dictionary: ByteArray? = null, + override val maxOutputSize: Int? = null +) : DecompressionType { + init { + dictionary?.let { + require(it.size <= 32768) { "dictionary must be 32kB or smaller, but was ${it.size} bytes" } + } + require(maxOutputSize == null || maxOutputSize >= 0) { + "maxOutputSize must be non-negative, but was $maxOutputSize" + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as Raw + + if (!dictionary.contentEquals(other.dictionary)) return false + if (maxOutputSize != other.maxOutputSize) return false + + return true + } + + override fun hashCode(): Int { + return 31 * (dictionary?.contentHashCode() ?: 0) + (maxOutputSize ?: 0) + } +} + +data class Gzip( + override val maxOutputSize: Int? = null +) : DecompressionType { + init { + require(maxOutputSize == null || maxOutputSize >= 0) { + "maxOutputSize must be non-negative, but was $maxOutputSize" + } + } +} + +data class Zlib( + val dictionary: ByteArray? = null, + override val maxOutputSize: Int? = null +) : DecompressionType { + init { + dictionary?.let { + require(it.size <= 32768) { "dictionary must be 32kB or smaller, but was ${it.size} bytes" } + } + require(maxOutputSize == null || maxOutputSize >= 0) { + "maxOutputSize must be non-negative, but was $maxOutputSize" + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as Zlib + + if (!dictionary.contentEquals(other.dictionary)) return false + if (maxOutputSize != other.maxOutputSize) return false + + return true + } + + override fun hashCode(): Int { + return 31 * (dictionary?.contentHashCode() ?: 0) + (maxOutputSize ?: 0) + } +} diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/decompression/GzipDecompressor.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/decompression/GzipDecompressor.kt index f84af6d..8a7ef28 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/decompression/GzipDecompressor.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/decompression/GzipDecompressor.kt @@ -1,10 +1,8 @@ package com.rafambn.kflate.decompression -import com.rafambn.kflate.Gzip import com.rafambn.kflate.checksum.Crc32Checksum import com.rafambn.kflate.error.FlateErrorCode -import com.rafambn.kflate.error.createFlateError -import com.rafambn.kflate.format.GzipMemberResult +import com.rafambn.kflate.error.FlateError import com.rafambn.kflate.format.processSingleGzipMember import com.rafambn.kflate.format.writeGzipStart import com.rafambn.kflate.streaming.STREAM_CHUNK_SIZE @@ -15,51 +13,50 @@ import com.rafambn.kflate.streaming.updateHistory import com.rafambn.kflate.util.readFourBytes import kotlinx.io.RawSink import kotlinx.io.RawSource -import kotlinx.io.Sink -import kotlinx.io.Source import kotlinx.io.buffered -import kotlinx.io.write internal fun decompressGzip(data: ByteArray, type: Gzip): ByteArray { if (data.size < 20) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } val decompressedChunks = mutableListOf() var currentPosition = 0 + var totalOutputSize = 0L + var remainingOutputSize = type.maxOutputSize ?: Int.MAX_VALUE while (currentPosition < data.size) { // Check if enough bytes for header if (currentPosition + 10 > data.size) { - createFlateError(FlateErrorCode.TRAILING_GARBAGE) + throw FlateError(FlateErrorCode.TRAILING_GARBAGE) } // Validate gzip magic bytes if ((data[currentPosition].toInt() and 0xFF) != 31 || (data[currentPosition + 1].toInt() and 0xFF) != 139 || (data[currentPosition + 2].toInt() and 0xFF) != 8) { - createFlateError(FlateErrorCode.TRAILING_GARBAGE) + throw FlateError(FlateErrorCode.TRAILING_GARBAGE) } // Process member - val result = processSingleGzipMember(data, currentPosition, type.dictionary) + val result = processSingleGzipMember( + data, + currentPosition, + remainingOutputSize, + ) decompressedChunks.add(result.decompressed) + totalOutputSize += result.decompressed.size.toLong() + remainingOutputSize -= result.decompressed.size currentPosition += result.bytesConsumed } - // Validate at least one member processed - if (decompressedChunks.isEmpty()) { - createFlateError(FlateErrorCode.INVALID_HEADER) - } - // Return single member as-is if (decompressedChunks.size == 1) { return decompressedChunks[0] } // Concatenate multiple members - val totalSize = decompressedChunks.sumOf { it.size } - val result = ByteArray(totalSize) + val result = ByteArray(totalOutputSize.toInt()) var offset = 0 for (chunk in decompressedChunks) { chunk.copyInto(result, destinationOffset = offset) @@ -75,34 +72,28 @@ internal fun decompressStreamGzip(type: Gzip, source: RawSource, sink: RawSink) val readBuffer = ByteArray(STREAM_CHUNK_SIZE) var inputBuffer = ByteArray(0) - var sourceExhausted = false var headerParsed = false var awaitingTrailer = false var inflateState = InflateState(validationMode = 0) - var history = type.dictionary ?: ByteArray(0) + var history = ByteArray(0) var crc = Crc32Checksum() var uncompressedSize = 0L var members = 0 + var remainingOutputSize = type.maxOutputSize while (true) { + val read = bufferedSource.readAtMostTo(readBuffer) + val sourceExhausted = read == -1 if (!sourceExhausted) { - val read = bufferedSource.readAtMostTo(readBuffer) - if (read == -1) { - sourceExhausted = true - } else if (read > 0) { - inputBuffer = appendBytes(inputBuffer, readBuffer, read) - } + inputBuffer = appendBytes(inputBuffer, readBuffer, read) } if (!headerParsed) { if (inputBuffer.isEmpty()) { - if (sourceExhausted) { - if (members == 0) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) - } + if (members > 0) { break } - continue + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } try { val headerSize = writeGzipStart(inputBuffer, 0) @@ -110,7 +101,7 @@ internal fun decompressStreamGzip(type: Gzip, source: RawSource, sink: RawSink) headerParsed = true awaitingTrailer = false inflateState = InflateState(validationMode = 0) - history = type.dictionary ?: ByteArray(0) + history = ByteArray(0) crc = Crc32Checksum() uncompressedSize = 0L members++ @@ -118,27 +109,33 @@ internal fun decompressStreamGzip(type: Gzip, source: RawSource, sink: RawSink) if (error.code == FlateErrorCode.UNEXPECTED_EOF && !sourceExhausted) { continue } - if (members > 0) { - createFlateError(FlateErrorCode.TRAILING_GARBAGE) - } - throw error + throw FlateError( + if (members > 0) FlateErrorCode.TRAILING_GARBAGE else error.code, + ) } } - if (headerParsed && !awaitingTrailer) { + if (!awaitingTrailer) { if (inputBuffer.isEmpty()) { if (sourceExhausted) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } continue } inflateState.outputOffset = 0 - val output = inflateStreamChunk(inputBuffer, inflateState, history, sourceExhausted) ?: continue + val output = inflateStreamChunk( + inputBuffer, + inflateState, + history, + sourceExhausted, + remainingOutputSize, + ) ?: continue if (output.isNotEmpty()) { bufferedSink.write(output) crc.update(output) uncompressedSize += output.size.toLong() + remainingOutputSize = remainingOutputSize?.minus(output.size) history = updateHistory(history, output) } @@ -151,7 +148,7 @@ internal fun decompressStreamGzip(type: Gzip, source: RawSource, sink: RawSink) inputBuffer = inputBuffer.copyOfRange(consumedBytes, inputBuffer.size) inflateState.inputBitPosition = bitRemainder } else if (sourceExhausted) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } } } @@ -160,31 +157,24 @@ internal fun decompressStreamGzip(type: Gzip, source: RawSource, sink: RawSink) val alignedBytes = (inflateState.inputBitPosition + 7) / 8 if (inputBuffer.size < alignedBytes + 8) { if (sourceExhausted) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } continue } val storedCrc = readFourBytes(inputBuffer, alignedBytes).toInt() val storedISize = readFourBytes(inputBuffer, alignedBytes + 4) if (crc.getChecksum() != storedCrc) { - createFlateError(FlateErrorCode.CRC_MISMATCH) + throw FlateError(FlateErrorCode.CRC_MISMATCH) } if ((uncompressedSize and 0xFFFFFFFFL) != storedISize) { - createFlateError(FlateErrorCode.ISIZE_MISMATCH) + throw FlateError(FlateErrorCode.ISIZE_MISMATCH) } inputBuffer = inputBuffer.copyOfRange(alignedBytes + 8, inputBuffer.size) headerParsed = false awaitingTrailer = false - if (sourceExhausted && inputBuffer.isEmpty()) { - break - } } } - if (members == 0) { - createFlateError(FlateErrorCode.INVALID_HEADER) - } - bufferedSink.flush() } diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/decompression/RawDecompressor.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/decompression/RawDecompressor.kt index 8b3ce29..812d5c0 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/decompression/RawDecompressor.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/decompression/RawDecompressor.kt @@ -1,9 +1,8 @@ package com.rafambn.kflate.decompression -import com.rafambn.kflate.Raw import com.rafambn.kflate.algorithm.inflate import com.rafambn.kflate.error.FlateErrorCode -import com.rafambn.kflate.error.createFlateError +import com.rafambn.kflate.error.FlateError import com.rafambn.kflate.streaming.STREAM_CHUNK_SIZE import com.rafambn.kflate.streaming.InflateState import com.rafambn.kflate.streaming.appendBytes @@ -11,13 +10,10 @@ import com.rafambn.kflate.streaming.inflateStreamChunk import com.rafambn.kflate.streaming.updateHistory import kotlinx.io.RawSink import kotlinx.io.RawSource -import kotlinx.io.Sink -import kotlinx.io.Source import kotlinx.io.buffered -import kotlinx.io.write internal fun decompressRaw(data: ByteArray, type: Raw): ByteArray { - return inflate(data, InflateState(validationMode = 2), null, type.dictionary) + return inflate(data, InflateState(validationMode = 2), type.dictionary, type.maxOutputSize) } internal fun decompressStreamRaw(type: Raw, source: RawSource, sink: RawSink) { @@ -28,35 +24,31 @@ internal fun decompressStreamRaw(type: Raw, source: RawSource, sink: RawSink) { var history = type.dictionary ?: ByteArray(0) val readBuffer = ByteArray(STREAM_CHUNK_SIZE) var inputBuffer = ByteArray(0) - var sourceExhausted = false - var sawInput = false + var remainingOutputSize = type.maxOutputSize while (true) { + val read = bufferedSource.readAtMostTo(readBuffer) + val sourceExhausted = read == -1 if (!sourceExhausted) { - val read = bufferedSource.readAtMostTo(readBuffer) - if (read == -1) { - sourceExhausted = true - } else if (read > 0) { - sawInput = true - inputBuffer = appendBytes(inputBuffer, readBuffer, read) - } + inputBuffer = appendBytes(inputBuffer, readBuffer, read) } if (inputBuffer.isEmpty()) { - if (sourceExhausted) { - if (!sawInput) { - return - } - createFlateError(FlateErrorCode.UNEXPECTED_EOF) - } - continue + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } state.outputOffset = 0 - val output = inflateStreamChunk(inputBuffer, state, history, sourceExhausted) ?: continue + val output = inflateStreamChunk( + inputBuffer, + state, + history, + sourceExhausted, + remainingOutputSize, + ) ?: continue if (output.isNotEmpty()) { bufferedSink.write(output) history = updateHistory(history, output) + remainingOutputSize = remainingOutputSize?.minus(output.size) } if (state.isFinalBlock && state.literalMap == null) { @@ -69,7 +61,7 @@ internal fun decompressStreamRaw(type: Raw, source: RawSource, sink: RawSink) { inputBuffer = inputBuffer.copyOfRange(consumedBytes, inputBuffer.size) state.inputBitPosition = bitRemainder } else if (sourceExhausted) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } } diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/decompression/ZlibDecompressor.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/decompression/ZlibDecompressor.kt index 57f866e..907cad2 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/decompression/ZlibDecompressor.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/decompression/ZlibDecompressor.kt @@ -1,10 +1,9 @@ package com.rafambn.kflate.decompression -import com.rafambn.kflate.Zlib import com.rafambn.kflate.algorithm.inflate import com.rafambn.kflate.checksum.Adler32Checksum import com.rafambn.kflate.error.FlateErrorCode -import com.rafambn.kflate.error.createFlateError +import com.rafambn.kflate.error.FlateError import com.rafambn.kflate.format.writeZlibStart import com.rafambn.kflate.streaming.STREAM_CHUNK_SIZE import com.rafambn.kflate.streaming.InflateState @@ -14,14 +13,11 @@ import com.rafambn.kflate.streaming.updateHistory import com.rafambn.kflate.util.readFourBytesBE import kotlinx.io.RawSink import kotlinx.io.RawSource -import kotlinx.io.Sink -import kotlinx.io.Source import kotlinx.io.buffered -import kotlinx.io.write internal fun decompressZlib(data: ByteArray, type: Zlib): ByteArray { if (data.size < 6) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } val start = writeZlibStart(data, type.dictionary != null, type.dictionary) @@ -32,8 +28,8 @@ internal fun decompressZlib(data: ByteArray, type: Zlib): ByteArray { val decompressedData = inflate( inputData, InflateState(validationMode = 2), - null, - type.dictionary + type.dictionary, + type.maxOutputSize, ) val computedAdler32 = Adler32Checksum().apply { @@ -41,7 +37,7 @@ internal fun decompressZlib(data: ByteArray, type: Zlib): ByteArray { }.getChecksum() if (computedAdler32 != storedAdler32) { - createFlateError(FlateErrorCode.CHECKSUM_MISMATCH) + throw FlateError(FlateErrorCode.CHECKSUM_MISMATCH) } return decompressedData @@ -56,26 +52,20 @@ internal fun decompressStreamZlib(type: Zlib, source: RawSource, sink: RawSink) val adler = Adler32Checksum() val readBuffer = ByteArray(STREAM_CHUNK_SIZE) var inputBuffer = ByteArray(0) - var sourceExhausted = false var headerParsed = false var awaitingTrailer = false + var remainingOutputSize = type.maxOutputSize while (true) { + val read = bufferedSource.readAtMostTo(readBuffer) + val sourceExhausted = read == -1 if (!sourceExhausted) { - val read = bufferedSource.readAtMostTo(readBuffer) - if (read == -1) { - sourceExhausted = true - } else if (read > 0) { - inputBuffer = appendBytes(inputBuffer, readBuffer, read) - } + inputBuffer = appendBytes(inputBuffer, readBuffer, read) } if (!headerParsed) { if (inputBuffer.isEmpty()) { - if (sourceExhausted) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) - } - continue + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } try { val headerSize = writeZlibStart(inputBuffer, type.dictionary != null, type.dictionary) @@ -92,17 +82,24 @@ internal fun decompressStreamZlib(type: Zlib, source: RawSource, sink: RawSink) if (!awaitingTrailer) { if (inputBuffer.isEmpty()) { if (sourceExhausted) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } continue } state.outputOffset = 0 - val output = inflateStreamChunk(inputBuffer, state, history, sourceExhausted) ?: continue + val output = inflateStreamChunk( + inputBuffer, + state, + history, + sourceExhausted, + remainingOutputSize, + ) ?: continue if (output.isNotEmpty()) { bufferedSink.write(output) adler.update(output) history = updateHistory(history, output) + remainingOutputSize = remainingOutputSize?.minus(output.size) } if (state.isFinalBlock && state.literalMap == null) { @@ -114,7 +111,7 @@ internal fun decompressStreamZlib(type: Zlib, source: RawSource, sink: RawSink) inputBuffer = inputBuffer.copyOfRange(consumedBytes, inputBuffer.size) state.inputBitPosition = bitRemainder } else if (sourceExhausted) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } } } @@ -123,13 +120,13 @@ internal fun decompressStreamZlib(type: Zlib, source: RawSource, sink: RawSink) val alignedBytes = (state.inputBitPosition + 7) / 8 if (inputBuffer.size < alignedBytes + 4) { if (sourceExhausted) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } continue } val storedAdler = readFourBytesBE(inputBuffer, alignedBytes) if (adler.getChecksum() != storedAdler) { - createFlateError(FlateErrorCode.CHECKSUM_MISMATCH) + throw FlateError(FlateErrorCode.CHECKSUM_MISMATCH) } break } diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/error/FlateError.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/error/FlateError.kt index 35d3472..c60f72e 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/error/FlateError.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/error/FlateError.kt @@ -1,7 +1,4 @@ package com.rafambn.kflate.error +/** Error reported when compressed data is invalid or exceeds a configured limit. */ class FlateError(val code: FlateErrorCode) : Exception(code.message) - -fun createFlateError(errorCode: FlateErrorCode): Nothing { - throw FlateError(errorCode) -} diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/error/FlateErrorCode.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/error/FlateErrorCode.kt index 17203bd..96237bc 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/error/FlateErrorCode.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/error/FlateErrorCode.kt @@ -11,5 +11,6 @@ enum class FlateErrorCode(val code: Int, val message: String) { CRC_MISMATCH(7, "CRC32 checksum mismatch - data corruption detected"), TRAILING_GARBAGE(8, "trailing garbage after gzip member"), INVALID_HUFFMAN_TREE(9, "invalid Huffman tree - oversubscribed or incomplete"), + INPUT_TOO_LARGE(10, "input is too large for this implementation"), + OUTPUT_LIMIT_EXCEEDED(11, "decompressed output exceeds the configured limit"), } - diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/format/GzipMemberResult.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/format/GzipMemberResult.kt new file mode 100644 index 0000000..3de338f --- /dev/null +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/format/GzipMemberResult.kt @@ -0,0 +1,17 @@ +package com.rafambn.kflate.format + +internal data class GzipMemberResult( + val decompressed: ByteArray, + val bytesConsumed: Int, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is GzipMemberResult) return false + if (!decompressed.contentEquals(other.decompressed)) return false + return bytesConsumed == other.bytesConsumed + } + + override fun hashCode(): Int { + return 31 * decompressed.contentHashCode() + bytesConsumed + } +} diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/format/GzipUtils.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/format/GzipUtils.kt index f26935c..fbb4c1d 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/format/GzipUtils.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/format/GzipUtils.kt @@ -2,17 +2,15 @@ package com.rafambn.kflate.format -import com.rafambn.kflate.GZIP +import com.rafambn.kflate.compression.Gzip import com.rafambn.kflate.algorithm.inflate -import com.rafambn.kflate.checksum.CRC32_TABLE import com.rafambn.kflate.checksum.Crc32Checksum import com.rafambn.kflate.error.FlateErrorCode -import com.rafambn.kflate.error.createFlateError +import com.rafambn.kflate.error.FlateError import com.rafambn.kflate.streaming.InflateState import com.rafambn.kflate.util.readFourBytes import com.rafambn.kflate.util.toIsoStringBytes import com.rafambn.kflate.util.writeBytes -import kotlin.math.floor import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -24,21 +22,14 @@ internal fun computeGzipHeaderCrc16(data: ByteArray, start: Int, end: Int): Int } internal fun buildExtraFields(extraFields: Map): ByteArray { - // Each subfield: SI1 (1 byte) + SI2 (1 byte) + LEN (2 bytes LE) + data - val totalSize = extraFields.values.sumOf { it.size + 4 } - // RFC 1952: XLEN is a 2-byte little-endian value, so total extra field size must fit in 16 bits - require(totalSize <= 65535) { - "Total extra field size ($totalSize bytes) exceeds maximum XLEN of 65535 bytes" - } + val totalSize = getGzipExtraFieldsSize(extraFields) val output = ByteArray(totalSize) var offset = 0 for ((key, data) in extraFields) { - require(key.length == 2) { "Extra field ID must be exactly 2 bytes, got: '$key'" } - require(data.size <= 65535) { "Extra field data cannot exceed 65535 bytes" } - - output[offset] = key[0].code.toByte() // SI1 - output[offset + 1] = key[1].code.toByte() // SI2 + val keyBytes = key.toIsoStringBytes() + output[offset] = keyBytes[0] // SI1 + output[offset + 1] = keyBytes[1] // SI2 output[offset + 2] = (data.size and 0xFF).toByte() // LEN low byte output[offset + 3] = (data.size shr 8).toByte() // LEN high byte data.copyInto(output, offset + 4) @@ -48,7 +39,24 @@ internal fun buildExtraFields(extraFields: Map): ByteArray { return output } -internal fun writeGzipHeader(output: ByteArray, options: GZIP) { +private fun getGzipExtraFieldsSize(extraFields: Map): Int { + var totalSize = 0L + for ((key, data) in extraFields) { + val keyBytes = key.toIsoStringBytes() + require(keyBytes.size == 2) { "Extra field ID must be exactly 2 bytes, got: '$key'" } + require(keyBytes[1] != 0.toByte()) { + "Extra field ID second byte is reserved and cannot be zero" + } + require(data.size <= 65_535) { "Extra field data cannot exceed 65535 bytes" } + totalSize += 4 + data.size + require(totalSize <= 65_535) { + "Total extra field size ($totalSize bytes) exceeds maximum XLEN of 65535 bytes" + } + } + return totalSize.toInt() +} + +internal fun writeGzipHeader(output: ByteArray, options: Gzip) { output[0] = 31 output[1] = -117 // 139 as signed byte output[2] = 8 @@ -68,16 +76,11 @@ internal fun writeGzipHeader(output: ByteArray, options: GZIP) { }.toByte() output[9] = -1 // 255 as signed byte - val mtime = options.mtime - val timeInMillis = when (mtime) { - is Number -> mtime.toLong() - is String -> mtime.toLongOrNull() ?: Clock.System.now().toEpochMilliseconds() - else -> Clock.System.now().toEpochMilliseconds() + val timestamp = options.mtime?.epochSeconds ?: Clock.System.now().epochSeconds + if (timestamp != 0L) { + writeBytes(output, 4, timestamp) } - if (timeInMillis != 0L) - writeBytes(output, 4, floor(timeInMillis / 1000.0).toLong()) - var headerOffset = 10 // FEXTRA: Write extra subfields @@ -116,34 +119,30 @@ internal fun writeGzipHeader(output: ByteArray, options: GZIP) { headerOffset += 2 } - val calculatedSize = getGzipHeaderSize(options) - require(headerOffset <= calculatedSize) { - "Header size mismatch: calculated=$calculatedSize, actual=$headerOffset" - } } internal fun writeGzipStart(data: ByteArray, startOffset: Int = 0): Int { if (startOffset + 10 > data.size) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } if ((data[startOffset].toInt() and 0xFF) != 31 || (data[startOffset + 1].toInt() and 0xFF) != 139 || (data[startOffset + 2].toInt() and 0xFF) != 8) { - createFlateError(FlateErrorCode.INVALID_HEADER) + throw FlateError(FlateErrorCode.INVALID_HEADER) } val flags = data[startOffset + 3].toInt() and 0xFF if ((flags and 0xE0) != 0) { // Check reserved bits 5, 6, 7 - createFlateError(FlateErrorCode.INVALID_HEADER) + throw FlateError(FlateErrorCode.INVALID_HEADER) } var headerSize = 10 // FEXTRA if ((flags and 4) != 0) { if (startOffset + headerSize + 2 > data.size) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } val xlen = (data[startOffset + headerSize].toInt() and 0xFF) or ((data[startOffset + headerSize + 1].toInt() and 0xFF) shl 8) headerSize += 2 if (startOffset + headerSize + xlen > data.size) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } headerSize += xlen } @@ -152,7 +151,7 @@ internal fun writeGzipStart(data: ByteArray, startOffset: Int = 0): Int { if ((flags and 8) != 0) { while (true) { if (startOffset + headerSize >= data.size) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } if (data[startOffset + headerSize++].toInt() == 0) { break @@ -164,7 +163,7 @@ internal fun writeGzipStart(data: ByteArray, startOffset: Int = 0): Int { if ((flags and 16) != 0) { while (true) { if (startOffset + headerSize >= data.size) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } if (data[startOffset + headerSize++].toInt() == 0) { break @@ -175,12 +174,12 @@ internal fun writeGzipStart(data: ByteArray, startOffset: Int = 0): Int { // FHCRC if ((flags and 2) != 0) { if (startOffset + headerSize + 2 > data.size) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } val computedCrc = computeGzipHeaderCrc16(data, startOffset, startOffset + headerSize) val storedCrc = (data[startOffset + headerSize].toInt() and 0xFF) or ((data[startOffset + headerSize + 1].toInt() and 0xFF) shl 8) if (computedCrc != storedCrc) { - createFlateError(FlateErrorCode.INVALID_HEADER) + throw FlateError(FlateErrorCode.INVALID_HEADER) } headerSize += 2 } @@ -192,14 +191,11 @@ internal fun getGzipUncompressedSize(data: ByteArray): Long { return readFourBytes(data, length - 4) } -internal fun getGzipHeaderSize(options: GZIP): Int { +internal fun getGzipHeaderSize(options: Gzip): Int { var size = 10 options.extraFields?.let { fields -> - size += 2 - for ((_, data) in fields) { - size += 4 + data.size - } + size += 2 + getGzipExtraFieldsSize(fields) } options.filename?.let { @@ -217,30 +213,14 @@ internal fun getGzipHeaderSize(options: GZIP): Int { return size } -internal data class GzipMemberResult( - val decompressed: ByteArray, - val bytesConsumed: Int -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is GzipMemberResult) return false - if (!decompressed.contentEquals(other.decompressed)) return false - return bytesConsumed == other.bytesConsumed - } - - override fun hashCode(): Int { - return 31 * decompressed.contentHashCode() + bytesConsumed - } -} - internal fun processSingleGzipMember( data: ByteArray, startOffset: Int, - dictionary: ByteArray? = null + maxOutputSize: Int? = null, ): GzipMemberResult { // Validate minimum size: 10 bytes header + at least 2 bytes compressed data + 8 bytes trailer (CRC32 + ISIZE) if (startOffset + 20 > data.size) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } // Parse header @@ -250,7 +230,7 @@ internal fun processSingleGzipMember( // Inflate with state tracking val inflateState = InflateState(validationMode = 2) inflateState.inputBitPosition = compressedDataStart * 8 - val decompressed = inflate(data, inflateState, null, dictionary) + val decompressed = inflate(data, inflateState, maxOutputSize = maxOutputSize) // Calculate bytes consumed by inflate val bitsConsumed = inflateState.inputBitPosition - (compressedDataStart * 8) @@ -259,7 +239,7 @@ internal fun processSingleGzipMember( // Validate trailer val trailerStart = compressedDataStart + bytesConsumedByInflate if (trailerStart + 8 > data.size) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } // Validate CRC32 @@ -267,13 +247,13 @@ internal fun processSingleGzipMember( val crc = Crc32Checksum() crc.update(decompressed) if (crc.getChecksum() != storedCrc32) { - createFlateError(FlateErrorCode.CRC_MISMATCH) + throw FlateError(FlateErrorCode.CRC_MISMATCH) } // Validate ISIZE val storedISize = readFourBytes(data, trailerStart + 4) if ((decompressed.size.toLong() and 0xFFFFFFFFL) != storedISize) { - createFlateError(FlateErrorCode.ISIZE_MISMATCH) + throw FlateError(FlateErrorCode.ISIZE_MISMATCH) } val totalBytesConsumed = trailerStart + 8 - startOffset diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/format/ZlibUtils.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/format/ZlibUtils.kt index d9ff98f..8f152ca 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/format/ZlibUtils.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/format/ZlibUtils.kt @@ -1,14 +1,14 @@ package com.rafambn.kflate.format -import com.rafambn.kflate.ZLIB +import com.rafambn.kflate.compression.Zlib import com.rafambn.kflate.checksum.Adler32Checksum import com.rafambn.kflate.error.FlateErrorCode -import com.rafambn.kflate.error.createFlateError +import com.rafambn.kflate.error.FlateError import com.rafambn.kflate.util.readFourBytesBE import com.rafambn.kflate.util.writeBytesBE -internal fun writeZlibHeader(output: ByteArray, options: ZLIB) { +internal fun writeZlibHeader(output: ByteArray, options: Zlib) { val level = options.level val compressionLevelFlag = when { level == 0 -> 0 @@ -30,24 +30,27 @@ internal fun writeZlibHeader(output: ByteArray, options: ZLIB) { } internal fun writeZlibStart(data: ByteArray, hasDictionary: Boolean, dictionary: ByteArray? = null): Int { + if (data.size < 2) { + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) + } val cmf = data[0].toInt() and 0xFF val flg = data[1].toInt() and 0xFF if ((cmf and 15) != 8 || (cmf ushr 4) > 7 || ((cmf shl 8 or flg) % 31 != 0)) - createFlateError(FlateErrorCode.INVALID_HEADER) + throw FlateError(FlateErrorCode.INVALID_HEADER) val needsDictionary = (flg and 32) != 0 if (needsDictionary != hasDictionary) - createFlateError(FlateErrorCode.INVALID_HEADER) + throw FlateError(FlateErrorCode.INVALID_HEADER) val headerSize = (if (needsDictionary) 4 else 0) + 2 // Validate DICTID if FDICT is set if (needsDictionary) { if (data.size < headerSize) { - createFlateError(FlateErrorCode.UNEXPECTED_EOF) + throw FlateError(FlateErrorCode.UNEXPECTED_EOF) } if (dictionary == null) { - createFlateError(FlateErrorCode.INVALID_HEADER) + throw FlateError(FlateErrorCode.INVALID_HEADER) } val storedDictId = readFourBytesBE(data, 2) @@ -56,7 +59,7 @@ internal fun writeZlibStart(data: ByteArray, hasDictionary: Boolean, dictionary: }.getChecksum() if (storedDictId != computedDictId) { - createFlateError(FlateErrorCode.CHECKSUM_MISMATCH) + throw FlateError(FlateErrorCode.CHECKSUM_MISMATCH) } } diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/Constants.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/Constants.kt index b3cdf04..145d783 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/Constants.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/Constants.kt @@ -62,5 +62,3 @@ internal val FIXED_DISTANCE_MAP = createHuffmanTree(FIXED_DISTANCE_TREE, 5, fals internal val FIXED_LENGTH_REVERSE_MAP = createHuffmanTree(FIXED_LENGTH_TREE, 9, true) internal val FIXED_DISTANCE_REVERSE_MAP = createHuffmanTree(FIXED_DISTANCE_TREE, 5, true) - -internal val DEFLATE_OPTIONS = intArrayOf(65540, 131080, 131088, 131104, 262176, 1048704, 1048832, 2114560, 2117632) diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/HuffmanCode.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/HuffmanCode.kt index 775b1ec..427985e 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/HuffmanCode.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/HuffmanCode.kt @@ -1,28 +1,5 @@ package com.rafambn.kflate.huffman -internal data class HuffmanTable( - val baseLengths: ShortArray, - val reverseLookup: IntArray -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as HuffmanTable - - if (!baseLengths.contentEquals(other.baseLengths)) return false - if (!reverseLookup.contentEquals(other.reverseLookup)) return false - - return true - } - - override fun hashCode(): Int { - var result = baseLengths.contentHashCode() - result = 31 * result + reverseLookup.contentHashCode() - return result - } -} - internal fun generateHuffmanTable(extraBits: ByteArray, startValue: Int): HuffmanTable { val baseLengths = ShortArray(31) var currentStart = startValue @@ -128,7 +105,8 @@ internal fun validateHuffmanCodeLengths(codeLengths: ByteArray, maxBits: Int): B // Validate using code space tracking (units = 2^maxBits) var codeSpace = 1 shl maxBits - for (bitLength in 1..maxBits) { + var bitLength = 1 + while (bitLength <= maxBits) { val count = lengthCounts[bitLength] if (count > 0) { // Each code at this length uses 2^(maxBits - bitLength) units @@ -137,39 +115,13 @@ internal fun validateHuffmanCodeLengths(codeLengths: ByteArray, maxBits: Int): B if (codeSpace < 0) return false // Oversubscribed } + bitLength++ } // Valid only if all space used (complete tree) return codeSpace == 0 } -internal data class HuffmanNode( - val symbol: Int, - val frequency: Int, - var leftChild: HuffmanNode? = null, - var rightChild: HuffmanNode? = null -) - -internal data class HuffmanTreeResult(val tree: ByteArray, val maxBits: Int) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as HuffmanTreeResult - - if (maxBits != other.maxBits) return false - if (!tree.contentEquals(other.tree)) return false - - return true - } - - override fun hashCode(): Int { - var result = maxBits - result = 31 * result + tree.contentHashCode() - return result - } -} - internal fun buildHuffmanTreeFromFrequencies(frequencies: IntArray, maxBits: Int): HuffmanTreeResult { val nodes = mutableListOf() for (i in frequencies.indices) { @@ -190,7 +142,7 @@ internal fun buildHuffmanTreeFromFrequencies(frequencies: IntArray, maxBits: Int return HuffmanTreeResult(codeLengths, 1) } - val maxSymbol = originalNodes.maxOf { it.symbol } + val maxSymbol = originalNodes.last().symbol val codeLengths = IntArray(maxSymbol + 1) nodes.sortBy { it.frequency } @@ -235,15 +187,11 @@ internal fun buildHuffmanTreeFromFrequencies(frequencies: IntArray, maxBits: Int .thenBy { it.frequency }) var i = 0 - for (nodeIndex in 0 until nodeCount) { - val symbol = originalNodes[nodeIndex].symbol - if (codeLengths[symbol] > maxBits) { - debt += cost - (1 shl (currentMaxBits - codeLengths[symbol])) - codeLengths[symbol] = maxBits - } else { - i = nodeIndex - break - } + while (codeLengths[originalNodes[i].symbol] > maxBits) { + val symbol = originalNodes[i].symbol + debt += cost - (1 shl (currentMaxBits - codeLengths[symbol])) + codeLengths[symbol] = maxBits + i++ } debt = debt shr costShift @@ -259,7 +207,7 @@ internal fun buildHuffmanTreeFromFrequencies(frequencies: IntArray, maxBits: Int } i = nodeCount - 1 - while (i >= 0 && debt != 0) { + while (debt != 0) { val symbol = originalNodes[i].symbol if (codeLengths[symbol] == maxBits) { codeLengths[symbol]-- diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/HuffmanNode.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/HuffmanNode.kt new file mode 100644 index 0000000..3beda54 --- /dev/null +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/HuffmanNode.kt @@ -0,0 +1,8 @@ +package com.rafambn.kflate.huffman + +internal data class HuffmanNode( + val symbol: Int, + val frequency: Int, + var leftChild: HuffmanNode? = null, + var rightChild: HuffmanNode? = null, +) diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/HuffmanTable.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/HuffmanTable.kt new file mode 100644 index 0000000..cb075bf --- /dev/null +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/HuffmanTable.kt @@ -0,0 +1,24 @@ +package com.rafambn.kflate.huffman + +internal data class HuffmanTable( + val baseLengths: ShortArray, + val reverseLookup: IntArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as HuffmanTable + + if (!baseLengths.contentEquals(other.baseLengths)) return false + if (!reverseLookup.contentEquals(other.reverseLookup)) return false + + return true + } + + override fun hashCode(): Int { + var result = baseLengths.contentHashCode() + result = 31 * result + reverseLookup.contentHashCode() + return result + } +} diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/HuffmanTreeResult.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/HuffmanTreeResult.kt new file mode 100644 index 0000000..8f54470 --- /dev/null +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/huffman/HuffmanTreeResult.kt @@ -0,0 +1,24 @@ +package com.rafambn.kflate.huffman + +internal data class HuffmanTreeResult( + val tree: ByteArray, + val maxBits: Int, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as HuffmanTreeResult + + if (maxBits != other.maxBits) return false + if (!tree.contentEquals(other.tree)) return false + + return true + } + + override fun hashCode(): Int { + var result = maxBits + result = 31 * result + tree.contentHashCode() + return result + } +} diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/streaming/StreamingSupport.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/streaming/StreamingSupport.kt index b939f71..b3c7e0e 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/streaming/StreamingSupport.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/streaming/StreamingSupport.kt @@ -46,13 +46,14 @@ internal fun inflateStreamChunk( input: ByteArray, state: InflateState, history: ByteArray, - sourceExhausted: Boolean + sourceExhausted: Boolean, + maxOutputSize: Int?, ): ByteArray? { val snapshot = state.copy() return try { - inflate(input, state, null, history) + inflate(input, state, history, maxOutputSize) } catch (e: com.rafambn.kflate.error.FlateError) { - if (!sourceExhausted) { + if (e.code == com.rafambn.kflate.error.FlateErrorCode.UNEXPECTED_EOF && !sourceExhausted) { restoreInflateState(state, snapshot) null } else { diff --git a/kflate/src/commonMain/kotlin/com/rafambn/kflate/util/BitUtils.kt b/kflate/src/commonMain/kotlin/com/rafambn/kflate/util/BitUtils.kt index 3f5e6bf..55d22c7 100644 --- a/kflate/src/commonMain/kotlin/com/rafambn/kflate/util/BitUtils.kt +++ b/kflate/src/commonMain/kotlin/com/rafambn/kflate/util/BitUtils.kt @@ -132,12 +132,9 @@ internal fun writeBlock( } val (codeLengthTree, maxCodeLengthBits) = buildHuffmanTreeFromFrequencies(codeLengthFrequencies, 7) - var numCodeLengthCodes = 19 - while (numCodeLengthCodes > 4 && codeLengthTree[CODE_LENGTH_INDEX_MAP[numCodeLengthCodes - 1].toInt()].toInt() == 0) { - numCodeLengthCodes-- - } + val numCodeLengthCodes = countCodeLengthCodes(codeLengthTree) - val fixedBlockLength = (blockLength + 5) shl 3 + val storedBlockLength = storedBlockBitLength(blockLength, bitPosition) val fixedTypedLength = calculateCodeLength(literalFrequencies, FIXED_LENGTH_TREE) + calculateCodeLength(distanceFrequencies, FIXED_DISTANCE_TREE) + extraBits val dynamicTypedLength = calculateCodeLength(literalFrequencies, dynamicLiteralTree) + @@ -145,7 +142,7 @@ internal fun writeBlock( calculateCodeLength(codeLengthFrequencies, codeLengthTree) + 2 * codeLengthFrequencies[16] + 3 * codeLengthFrequencies[17] + 7 * codeLengthFrequencies[18] - if (blockStart >= 0 && fixedBlockLength <= fixedTypedLength && fixedBlockLength <= dynamicTypedLength) { + if (shouldUseStoredBlock(blockStart, storedBlockLength, fixedTypedLength, dynamicTypedLength)) { return writeFixedBlock(output, currentBitPosition, data.sliceArray(blockStart until blockStart + blockLength)) } @@ -254,3 +251,34 @@ internal fun writeBytesBE(data: ByteArray, offset: Int, value: Int) { data[offset + 2] = ((value shr 8) and 0xFF).toByte() data[offset + 3] = (value and 0xFF).toByte() } + +internal fun countCodeLengthCodes(codeLengthTree: ByteArray): Int { + var count = 19 + while (count > 4 && codeLengthTree[CODE_LENGTH_INDEX_MAP[count - 1].toInt()].toInt() == 0) { + count-- + } + return count +} + +internal fun shouldUseStoredBlock( + blockStart: Int, + storedLength: Int, + fixedLength: Int, + dynamicLength: Int, +): Boolean { + return blockStart >= 0 && storedLength <= fixedLength && storedLength <= dynamicLength +} + +/** + * Returns the stored block cost after the three bit block header. + * + * The header itself is present in every block type, while a stored block adds + * padding before its four byte length header. Keeping the common header out of + * this value lets it be compared directly with the fixed and dynamic token + * costs. + */ +internal fun storedBlockBitLength(blockLength: Int, bitPosition: Long): Int { + val headerEnd = bitPosition + 3L + val padding = ((8L - (headerEnd and 7L)) and 7L).toInt() + return (blockLength shl 3) + 32 + padding +} diff --git a/kflate/src/commonTest/kotlin/PerformanceComparisonTest.kt b/kflate/src/commonTest/kotlin/PerformanceComparisonTest.kt deleted file mode 100644 index 9924591..0000000 --- a/kflate/src/commonTest/kotlin/PerformanceComparisonTest.kt +++ /dev/null @@ -1,234 +0,0 @@ -package com.rafambn.kflate.performance - -import com.rafambn.kflate.KFlate -import com.rafambn.kflate.RAW -import com.rafambn.kflate.Raw -import dev.karmakrafts.kompress.Deflater -import dev.karmakrafts.kompress.Inflater -import io.matthewnelson.kmp.file.SysTempDir -import io.matthewnelson.kmp.file.canonicalFile2 -import io.matthewnelson.kmp.file.readBytes -import io.matthewnelson.kmp.file.resolve -import io.matthewnelson.kmp.file.writeBytes -import kotlin.math.pow -import kotlin.test.Test -import kotlin.time.DurationUnit -import kotlin.time.measureTime -import kotlinx.datetime.Clock -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime - -fun formatDecimal(value: Double, decimals: Int = 2): String { - val multiplier = 10.0.pow(decimals.toDouble()).toLong() - val rounded = ((value * multiplier).toLong().toDouble() / multiplier) - return rounded.toString() -} - -fun formatBytes(bytes: Long): String { - return when { - bytes >= 1024 * 1024 * 1024 -> "${formatDecimal(bytes / (1024.0 * 1024 * 1024))}GB" - bytes >= 1024 * 1024 -> "${formatDecimal(bytes / (1024.0 * 1024))}MB" - bytes >= 1024 -> "${formatDecimal(bytes / 1024.0)}KB" - else -> "${bytes}B" - } -} - -data class CompressionBenchmark( - val fileName: String, - val originalSize: Long, - val kflateCompressedSize: Long, - val kompressCompressedSize: Long, - val kflateAvgCompressionTimeMs: Double, - val kompressAvgCompressionTimeMs: Double, - val kflateAvgDecompressionTimeMs: Double, - val kompressAvgDecompressionTimeMs: Double, - val kflateCompressionRatio: Double, - val kompressCompressionRatio: Double, - val sizeDifference: Long, - val sizeDifferencePercent: Double, - val testDate: String -) { - fun toReadable(): String = """ - Test Date: $testDate - File: $fileName - Original Size: ${formatBytes(originalSize)} - - KFlate: - Compressed Size: ${formatBytes(kflateCompressedSize)} (${formatDecimal(kflateCompressionRatio)}%) - Avg Compression Time (10 iterations): ${formatDecimal(kflateAvgCompressionTimeMs)}ms - Avg Decompression Time (10 iterations): ${formatDecimal(kflateAvgDecompressionTimeMs)}ms - - Kompress: - Compressed Size: ${formatBytes(kompressCompressedSize)} (${formatDecimal(kompressCompressionRatio)}%) - Avg Compression Time (10 iterations): ${formatDecimal(kompressAvgCompressionTimeMs)}ms - Avg Decompression Time (10 iterations): ${formatDecimal(kompressAvgDecompressionTimeMs)}ms - - Comparison: - Size Difference: ${formatBytes(sizeDifference)} (${formatDecimal(sizeDifferencePercent)}%) - KFlate is ${if (sizeDifference < 0) "LARGER" else "SMALLER"} than Kompress - - """.trimIndent() -} - -class PerformanceComparisonTest { - - private val testFilesPath = "/mnt/Arquivos/MyProjects/KFlate/kflate/src/commonTest/resources" - private val resultsPath = "/mnt/Arquivos/MyProjects/KFlate/performance" - private val iterations = 10 - - @Test - fun compressionBenchmark() { - val results = mutableListOf() - val testDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).toString() - - println("\n=== KFlate vs Kompress Compression Benchmark ===") - println("File: Sunrise.bmp") - println("Test Date: $testDate") - println("Iterations: $iterations\n") - - val filePath = "$testFilesPath/Sunrise.bmp" - val data = try { - println("teste1") - SysTempDir - .resolve(filePath) - .canonicalFile2() - .readBytes() - } catch (e: Exception) { - println("teste fail") - println("Failed to load Sunrise.bmp: ${e.message}") - return - } - - val originalSize = data.size.toLong() - println("Original Size: ${formatBytes(originalSize)}\n") - - // Warmup iterations (excluded from timing) - println("Warming up KFlate (3 iterations - excluded from results)...") - repeat(3) { - KFlate.compress(data, RAW()) - val compressed = KFlate.compress(data, RAW()) - KFlate.decompress(compressed, Raw()) - } - - println("Warming up Kompress (3 iterations - excluded from results)...") - repeat(3) { - Deflater.deflate(data) - val compressed = Deflater.deflate(data) - Inflater.inflate(compressed) - } - - // Test KFlate compression/decompression multiple times - val kflateCompressionTimes = mutableListOf() - val kflateDecompressionTimes = mutableListOf() - var kflateCompressed: ByteArray? = null - - println("\nTesting KFlate ($iterations iterations)...") - repeat(iterations) { iteration -> - var compressed: ByteArray - val compTime = measureTime { - compressed = KFlate.compress(data, RAW()) - }.toDouble(DurationUnit.MILLISECONDS) - kflateCompressionTimes.add(compTime) - - if (iteration == 0) { - kflateCompressed = compressed - } - - var decompressed: ByteArray - val decompTime = measureTime { - decompressed = KFlate.decompress(compressed, Raw()) - }.toDouble(DurationUnit.MILLISECONDS) - kflateDecompressionTimes.add(decompTime) - - require(decompressed.contentEquals(data)) { - "KFlate decompression verification failed at iteration $iteration" - } - - println(" Iteration ${iteration + 1}: Compress=${formatDecimal(compTime)}ms, Decompress=${formatDecimal(decompTime)}ms") - } - - // Test Kompress compression/decompression multiple times - val kompressCompressionTimes = mutableListOf() - val kompressDecompressionTimes = mutableListOf() - var kompressCompressed: ByteArray? = null - - println("\nTesting Kompress ($iterations iterations)...") - repeat(iterations) { iteration -> - var compressed: ByteArray - val compTime = measureTime { - compressed = Deflater.deflate(data) - }.toDouble(DurationUnit.MILLISECONDS) - kompressCompressionTimes.add(compTime) - - if (iteration == 0) { - kompressCompressed = compressed - } - - var decompressed: ByteArray - val decompTime = measureTime { - decompressed = Inflater.inflate(compressed) - }.toDouble(DurationUnit.MILLISECONDS) - kompressDecompressionTimes.add(decompTime) - - require(decompressed.contentEquals(data)) { - "Kompress decompression verification failed at iteration $iteration" - } - - println(" Iteration ${iteration + 1}: Compress=${formatDecimal(compTime)}ms, Decompress=${formatDecimal(decompTime)}ms") - } - - // Calculate averages - val kflateAvgCompression = kflateCompressionTimes.average() - val kflateAvgDecompression = kflateDecompressionTimes.average() - val kompressAvgCompression = kompressCompressionTimes.average() - val kompressAvgDecompression = kompressDecompressionTimes.average() - - val kflateSize = kflateCompressed!!.size.toLong() - val kompressSize = kompressCompressed!!.size.toLong() - - val benchmark = CompressionBenchmark( - fileName = "Sunrise.bmp", - originalSize = originalSize, - kflateCompressedSize = kflateSize, - kompressCompressedSize = kompressSize, - kflateAvgCompressionTimeMs = kflateAvgCompression, - kompressAvgCompressionTimeMs = kompressAvgCompression, - kflateAvgDecompressionTimeMs = kflateAvgDecompression, - kompressAvgDecompressionTimeMs = kompressAvgDecompression, - kflateCompressionRatio = (kflateSize.toDouble() / originalSize) * 100, - kompressCompressionRatio = (kompressSize.toDouble() / originalSize) * 100, - sizeDifference = kompressSize - kflateSize, - sizeDifferencePercent = ((kompressSize - kflateSize).toDouble() / kompressSize) * 100, - testDate = testDate - ) - - results.add(benchmark) - - println("\n${"=".repeat(70)}") - println(benchmark.toReadable()) - println("${"=".repeat(70)}\n") - - writeResultsToFile(results) - - println("Benchmark complete!") - println(" CSV results: $resultsPath/compression_results.csv") - println(" Readable results: $resultsPath/compression_results.txt") - } - - private fun writeResultsToFile(results: List) { - val readableContent = buildString { - appendLine("=== KFlate vs Kompress Compression Benchmark Results ===\n") - results.forEach { benchmark -> - appendLine(benchmark.toReadable()) - appendLine("─".repeat(70)) - } - } - - try { - val txtFile = SysTempDir.resolve("$resultsPath/compression_$PLATFORM_NAME.txt").canonicalFile2() - txtFile.writeBytes(excl = null, readableContent.encodeToByteArray()) - } catch (e: Exception) { - println("Failed to write readable results file: ${e.message}") - } - } -} diff --git a/kflate/src/commonTest/kotlin/PlatformInfo.kt b/kflate/src/commonTest/kotlin/PlatformInfo.kt deleted file mode 100644 index d9ad670..0000000 --- a/kflate/src/commonTest/kotlin/PlatformInfo.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.rafambn.kflate.performance - -expect val PLATFORM_NAME: String diff --git a/kflate/src/commonTest/resources/Sunrise.bmp b/kflate/src/commonTest/resources/Sunrise.bmp deleted file mode 100644 index d586866..0000000 Binary files a/kflate/src/commonTest/resources/Sunrise.bmp and /dev/null differ diff --git a/kflate/src/commonTest/resources/model3D b/kflate/src/commonTest/resources/model3D deleted file mode 100644 index 3fc4064..0000000 Binary files a/kflate/src/commonTest/resources/model3D and /dev/null differ diff --git a/kflate/src/jvmTest/kotlin/AlgorithmCoverageTest.kt b/kflate/src/jvmTest/kotlin/AlgorithmCoverageTest.kt new file mode 100644 index 0000000..42860f8 --- /dev/null +++ b/kflate/src/jvmTest/kotlin/AlgorithmCoverageTest.kt @@ -0,0 +1,424 @@ +package com.rafambn.kflate + +import com.rafambn.kflate.algorithm.COST_AWARE_WINDOW_SIZE +import com.rafambn.kflate.algorithm.DEFLATE_LEVELS +import com.rafambn.kflate.algorithm.MATCH_DISTANCE_BITS +import com.rafambn.kflate.algorithm.checkedDeflateInputSize +import com.rafambn.kflate.algorithm.chooseCostAwarePath +import com.rafambn.kflate.algorithm.deflate +import com.rafambn.kflate.algorithm.deflateWithOptions +import com.rafambn.kflate.algorithm.fixedLiteralBitCost +import com.rafambn.kflate.algorithm.fixedMatchBitCost +import com.rafambn.kflate.algorithm.hasThreeByteMatch +import com.rafambn.kflate.algorithm.inflate +import com.rafambn.kflate.algorithm.shouldFlushBlock +import com.rafambn.kflate.algorithm.shouldSearchLazyMatch +import com.rafambn.kflate.algorithm.validateCodeLengthEntry +import com.rafambn.kflate.algorithm.validateCodeLengthTree +import com.rafambn.kflate.algorithm.validateInflateInputSize +import com.rafambn.kflate.compression.Raw as CompressionRaw +import com.rafambn.kflate.decompression.Raw as DecompressionRaw +import com.rafambn.kflate.error.FlateError +import com.rafambn.kflate.error.FlateErrorCode +import com.rafambn.kflate.huffman.buildHuffmanTreeFromFrequencies +import com.rafambn.kflate.huffman.generateLengthCodes +import com.rafambn.kflate.huffman.validateHuffmanCodeLengths +import com.rafambn.kflate.streaming.DeflateState +import com.rafambn.kflate.streaming.InflateState +import java.io.ByteArrayOutputStream +import java.util.zip.Deflater +import java.util.zip.Inflater +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class AlgorithmCoverageTest { + + @Test + fun inflateCoversStoredStateAndSizeBoundaries() { + val finished = InflateState(isFinalBlock = true) + assertContentEquals(byteArrayOf(), inflate(byteArrayOf(1), finished)) + assertContentEquals(byteArrayOf(), inflate(byteArrayOf(), InflateState())) + assertFailsWith { + inflate(byteArrayOf(), InflateState(validationMode = 2)) + } + + val incompleteHeader = InflateState(inputBitPosition = 7) + assertContentEquals(byteArrayOf(), inflate(byteArrayOf(0), incompleteHeader)) + assertFailsWith { + inflate(byteArrayOf(0), InflateState(inputBitPosition = 7, validationMode = 2)) + } + + val incompleteStoredHeader = byteArrayOf(1) + assertContentEquals(byteArrayOf(), inflate(incompleteStoredHeader, InflateState())) + assertFailsWith { + inflate(incompleteStoredHeader, InflateState(validationMode = 2)) + } + + val incompleteStoredData = byteArrayOf(1, 2, 0, -3, -1, 42) + assertContentEquals(byteArrayOf(), inflate(incompleteStoredData, InflateState())) + assertFailsWith { + inflate(incompleteStoredData, InflateState(validationMode = 2)) + } + + val oneStoredByte = byteArrayOf(1, 1, 0, -2, -1, 42) + assertContentEquals(byteArrayOf(42), inflate(oneStoredByte, InflateState(validationMode = 2))) + val overflowState = InflateState(outputOffset = Int.MAX_VALUE) + val overflow = assertFailsWith { + inflate(oneStoredByte, overflowState) + } + assertEquals(FlateErrorCode.OUTPUT_LIMIT_EXCEEDED, overflow.code) + } + + @Test + fun malformedDeflateInputsFailWithDomainErrors() { + for (value in 0..255) { + exerciseMalformed(byteArrayOf(value.toByte())) + } + + var random = 0x6d2b79f5 + repeat(20_000) { + random = random xor (random shl 13) + random = random xor (random ushr 17) + random = random xor (random shl 5) + val size = 2 + (random ushr 1) % 47 + val data = ByteArray(size) + for (index in data.indices) { + random = random xor (random shl 13) + random = random xor (random ushr 17) + random = random xor (random shl 5) + data[index] = random.toByte() + } + exerciseMalformed(data) + } + } + + @Test + fun validDeflatePrefixesExerciseResumableInflate() { + val inputs = listOf( + ByteArray(4_096), + ByteArray(4_096) { it.toByte() }, + "abcdefghij".repeat(1_000).encodeToByteArray(), + ) + + for (input in inputs) { + val compressed = deflateWithJava(input) + assertContentEquals(input, inflate(compressed, InflateState(validationMode = 2))) + for (end in 1..compressed.size) { + try { + inflate(compressed.copyOf(end), InflateState(), maxOutputSize = input.size) + } catch (_: FlateError) { + // A prefix can already prove corruption, while most prefixes preserve resumable state. + } + } + + for (index in 0 until minOf(compressed.size, 96)) { + for (value in 0..255) { + val mutated = compressed.copyOf() + mutated[index] = value.toByte() + exerciseMalformed(mutated) + } + } + } + } + + @Test + fun storedHuffmanStateRejectsIncompleteLiteralAndDistanceCodes() { + val incompleteLiteral = InflateState( + literalMap = ShortArray(4), + distanceMap = ShortArray(2), + literalMaxBits = 2, + distanceMaxBits = 1, + inputBitPosition = 7, + validationMode = 2, + ) + assertEquals( + FlateErrorCode.UNEXPECTED_EOF, + assertFailsWith { inflate(byteArrayOf(0), incompleteLiteral) }.code, + ) + assertContentEquals( + byteArrayOf(), + inflate(byteArrayOf(0), incompleteLiteral.copy(validationMode = 0)), + ) + assertEquals( + FlateErrorCode.INVALID_LENGTH_LITERAL, + assertFailsWith { + inflate( + byteArrayOf(0), + incompleteLiteral.copy(inputBitPosition = 0, literalMaxBits = 1), + ) + }.code, + ) + + val lengthCode = ((257 shl 4) or 1).toShort() + val incompleteDistance = InflateState( + literalMap = shortArrayOf(lengthCode, lengthCode), + distanceMap = ShortArray(256), + literalMaxBits = 1, + distanceMaxBits = 8, + validationMode = 2, + ) + assertEquals( + FlateErrorCode.UNEXPECTED_EOF, + assertFailsWith { inflate(byteArrayOf(0), incompleteDistance) }.code, + ) + assertContentEquals( + byteArrayOf(), + inflate(byteArrayOf(0), incompleteDistance.copy(validationMode = 0)), + ) + + val invalidDistance = InflateState( + literalMap = shortArrayOf(lengthCode, lengthCode), + distanceMap = ShortArray(2), + literalMaxBits = 1, + distanceMaxBits = 1, + validationMode = 2, + ) + assertEquals( + FlateErrorCode.INVALID_DISTANCE, + assertFailsWith { inflate(byteArrayOf(0), invalidDistance) }.code, + ) + } + + @Test + fun deflateCoversExplicitStateAndOverflowChecks() { + assertTrue(deflateWithOptions(byteArrayOf(1), prefixSize = 0, suffixSize = 0).isNotEmpty()) + + val boundedState = DeflateState(inputEndIndex = 1, isLastChunk = true) + assertTrue(deflate(byteArrayOf(1, 2), 0, 12, 0, 0, boundedState).isNotEmpty()) + + val inputTooLarge = assertFailsWith { + deflate(byteArrayOf(), 0, 12, 0, 0, DeflateState(inputEndIndex = Int.MAX_VALUE)) + } + assertEquals(FlateErrorCode.INPUT_TOO_LARGE, inputTooLarge.code) + + val outputTooLarge = assertFailsWith { + deflate(byteArrayOf(), 0, 12, Int.MAX_VALUE, 1, DeflateState(isLastChunk = true)) + } + assertEquals(FlateErrorCode.INPUT_TOO_LARGE, outputTooLarge.code) + + assertTrue(deflate(byteArrayOf(), 0, 12, 0, 0, DeflateState()).isEmpty()) + validateInflateInputSize((Int.MAX_VALUE - 64) / 8) + assertFailsWith { + validateInflateInputSize((Int.MAX_VALUE - 64) / 8 + 1) + } + assertEquals(12, checkedDeflateInputSize(5, 7)) + assertFailsWith { checkedDeflateInputSize(Int.MAX_VALUE, 1) } + assertTrue(deflate(ByteArray(65_536), 0, 12, 0, 0, DeflateState(isLastChunk = true)).isNotEmpty()) + + assertTrue(shouldFlushBlock(7_001, 0, 424, true)) + assertTrue(shouldFlushBlock(0, 24_577, 0, false)) + assertTrue(!shouldFlushBlock(7_000, 24_576, 424, false)) + assertTrue(!shouldFlushBlock(7_001, 0, 423, true)) + + val matchData = byteArrayOf(1, 2, 3, 1, 2, 3) + assertTrue(hasThreeByteMatch(matchData, 3, 3, 3)) + assertTrue(!hasThreeByteMatch(matchData, 3, 3, 2)) + assertTrue(!hasThreeByteMatch(byteArrayOf(1, 2, 3, 0, 2, 3), 3, 3, 3)) + assertTrue(!hasThreeByteMatch(byteArrayOf(1, 2, 3, 1, 0, 3), 3, 3, 3)) + assertTrue(!hasThreeByteMatch(byteArrayOf(1, 2, 3, 1, 2, 0), 3, 3, 3)) + } + + @Test + fun compressionLevelsIncreaseSearchEffort() { + val compressedLevels = DEFLATE_LEVELS.drop(1) + + assertEquals(9, compressedLevels.size) + assertTrue(compressedLevels.zipWithNext().all { (lower, higher) -> + lower.niceLength <= higher.niceLength + }) + assertTrue(compressedLevels.zipWithNext().all { (lower, higher) -> + lower.chainLength <= higher.chainLength + }) + assertTrue(compressedLevels.dropLast(1).zipWithNext().all { (lower, higher) -> + lower.maxLazyLength <= higher.maxLazyLength + }) + assertTrue(compressedLevels.zipWithNext().all { (lower, higher) -> + lower.maxHashBits <= higher.maxHashBits + }) + assertEquals(0, DEFLATE_LEVELS[3].maxLazyLength) + assertTrue(DEFLATE_LEVELS[4].maxLazyLength > 0) + assertEquals(8, DEFLATE_LEVELS[6].goodMatchLength) + assertEquals(32, DEFLATE_LEVELS[8].goodMatchLength) + assertTrue(!DEFLATE_LEVELS[8].usesCostAwareParsing) + assertTrue(DEFLATE_LEVELS[9].usesCostAwareParsing) + + assertTrue(shouldSearchLazyMatch(length = 3, maxLazyLength = 4, remaining = 5)) + assertTrue(!shouldSearchLazyMatch(length = 2, maxLazyLength = 4, remaining = 5)) + assertTrue(!shouldSearchLazyMatch(length = 4, maxLazyLength = 4, remaining = 5)) + assertTrue(!shouldSearchLazyMatch(length = 3, maxLazyLength = 0, remaining = 5)) + assertTrue(!shouldSearchLazyMatch(length = 3, maxLazyLength = 4, remaining = 4)) + } + + @Test + fun lazyLevelsRoundTripThresholdAndWindowInputs() { + val inputs = listOf( + ("abcdefgh".repeat(256) + "abcdefgX" + "abcdefgh".repeat(256)).encodeToByteArray(), + ("0123456789abcdef".repeat(2_100) + "01234567").encodeToByteArray(), + ) + + for (level in 4..8) { + for (input in inputs) { + val compressed = KFlate.compress(input, CompressionRaw(level = level)) + assertContentEquals(input, inflateWithJava(compressed)) + } + } + } + + @Test + fun costAwareParsingChoosesTheCheapestTokenPath() { + assertEquals(8, fixedLiteralBitCost(42)) + assertEquals(9, fixedLiteralBitCost(200)) + assertEquals(12, fixedMatchBitCost(length = 3, distance = 1)) + assertEquals(25, fixedMatchBitCost(length = 3, distance = 32_767)) + + val data = ByteArray(5) + val matches = IntArray(data.size) + val costs = IntArray(data.size + 1) + val choices = IntArray(data.size) + + matches[0] = (3 shl MATCH_DISTANCE_BITS) or 1 + matches[1] = (4 shl MATCH_DISTANCE_BITS) or 32_767 + chooseCostAwarePath(data, 0, data.size, matches, costs, choices) + assertEquals(3, choices[0]) + + matches[0] = (3 shl MATCH_DISTANCE_BITS) or 32_767 + matches[1] = (4 shl MATCH_DISTANCE_BITS) or 1 + chooseCostAwarePath(data, 0, data.size, matches, costs, choices) + assertEquals(1, choices[0]) + assertEquals(4, choices[1]) + } + + @Test + fun costAwareLevelRoundTripsWindowsAndDictionaries() { + val multipleWindows = ByteArray(COST_AWARE_WINDOW_SIZE * 2 + 3) + val compressedWindows = KFlate.compress(multipleWindows, CompressionRaw(level = 9)) + assertContentEquals(multipleWindows, KFlate.decompress(compressedWindows, DecompressionRaw())) + + val random = java.util.Random(9) + val incompressible = ByteArray(COST_AWARE_WINDOW_SIZE + 3) { random.nextInt().toByte() } + val compressedRandom = KFlate.compress(incompressible, CompressionRaw(level = 9)) + assertContentEquals(incompressible, KFlate.decompress(compressedRandom, DecompressionRaw())) + + val dictionary = "cost-aware dictionary".repeat(100).encodeToByteArray() + val payload = "dictionary-backed payload".repeat(100).encodeToByteArray() + val compressedPayload = KFlate.compress(payload, CompressionRaw(level = 9, dictionary = dictionary)) + assertContentEquals( + payload, + KFlate.decompress(compressedPayload, DecompressionRaw(dictionary = dictionary)), + ) + + val compressedEmpty = KFlate.compress(byteArrayOf(), CompressionRaw(level = 9, dictionary = dictionary)) + assertContentEquals( + byteArrayOf(), + KFlate.decompress(compressedEmpty, DecompressionRaw(dictionary = dictionary)), + ) + } + + @Test + fun huffmanConstructionCoversEmptyAndDepthLimitedTrees() { + assertTrue(validateHuffmanCodeLengths(byteArrayOf(2, 2, 2, 2), 3)) + assertEquals(0, buildHuffmanTreeFromFrequencies(IntArray(4), 3).maxBits) + assertEquals(1, buildHuffmanTreeFromFrequencies(intArrayOf(0, 7), 3).maxBits) + + val limited = buildHuffmanTreeFromFrequencies( + intArrayOf(1, 1, 2, 3, 5, 8, 13, 21), + 3, + ) + assertEquals(3, limited.maxBits) + assertEquals(8, limited.tree.size) + + buildHuffmanTreeFromFrequencies(intArrayOf(21, 13, 8, 5, 3, 2, 1, 1), 3) + buildHuffmanTreeFromFrequencies(intArrayOf(1, 100, 100, 100, 100, 100, 100, 100), 3) + + var random = 0x1234abcd + repeat(10_000) { + val frequencies = IntArray(8) { + random = random * 1_103_515_245 + 12_345 + (random ushr 1) % 1_000 + 1 + } + buildHuffmanTreeFromFrequencies(frequencies, 3) + } + + val (emptyCodes, maxSymbol) = generateLengthCodes(ByteArray(3)) + assertTrue(emptyCodes.isEmpty()) + assertEquals(0, maxSymbol) + } + + @Test + fun codeLengthEntryValidationDistinguishesTruncationFromInvalidTrees() { + validateCodeLengthEntry(1, 1, 1) + assertEquals( + FlateErrorCode.UNEXPECTED_EOF, + assertFailsWith { validateCodeLengthEntry(2, 1, 2) }.code, + ) + assertEquals( + FlateErrorCode.UNEXPECTED_EOF, + assertFailsWith { validateCodeLengthEntry(0, 1, 2) }.code, + ) + assertEquals( + FlateErrorCode.INVALID_HUFFMAN_TREE, + assertFailsWith { validateCodeLengthEntry(0, 2, 2) }.code, + ) + + validateCodeLengthTree(byteArrayOf(1, 1), 1) + assertEquals( + FlateErrorCode.INVALID_HUFFMAN_TREE, + assertFailsWith { validateCodeLengthTree(ByteArray(19), 0) }.code, + ) + assertEquals( + FlateErrorCode.INVALID_HUFFMAN_TREE, + assertFailsWith { validateCodeLengthTree(byteArrayOf(1, 1, 1), 1) }.code, + ) + } + + private fun exerciseMalformed(data: ByteArray) { + for (validationMode in 0..2 step 2) { + try { + inflate( + data, + InflateState(validationMode = validationMode), + maxOutputSize = 64, + ) + } catch (_: FlateError) { + // Malformed compressed bytes are expected to fail with a domain error. + } + } + } + + private fun deflateWithJava(input: ByteArray): ByteArray { + val deflater = Deflater(6, true) + return try { + deflater.setInput(input) + deflater.finish() + val output = ByteArray(input.size + 512) + output.copyOf(deflater.deflate(output)) + } finally { + deflater.end() + } + } + + private fun inflateWithJava(input: ByteArray): ByteArray { + val inflater = Inflater(true) + return try { + inflater.setInput(input) + val output = ByteArrayOutputStream() + val buffer = ByteArray(8_192) + while (!inflater.finished()) { + val count = inflater.inflate(buffer) + if (count == 0) { + check(!inflater.needsInput() && !inflater.needsDictionary()) { + "Java Inflater stopped before finishing" + } + } else { + output.write(buffer, 0, count) + } + } + output.toByteArray() + } finally { + inflater.end() + } + } +} diff --git a/kflate/src/jvmTest/kotlin/BlockingValidityTest.kt b/kflate/src/jvmTest/kotlin/BlockingValidityTest.kt index 5306d06..1fae4ce 100644 --- a/kflate/src/jvmTest/kotlin/BlockingValidityTest.kt +++ b/kflate/src/jvmTest/kotlin/BlockingValidityTest.kt @@ -1,6 +1,12 @@ package com.rafambn.kflate +import com.rafambn.kflate.compression.Gzip as CompressionGzip +import com.rafambn.kflate.compression.Raw as CompressionRaw +import com.rafambn.kflate.compression.Zlib as CompressionZlib +import com.rafambn.kflate.decompression.Gzip as DecompressionGzip +import com.rafambn.kflate.decompression.Raw as DecompressionRaw +import com.rafambn.kflate.decompression.Zlib as DecompressionZlib import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.util.zip.* @@ -12,17 +18,11 @@ class BlockingValidityTest { private val testFiles = listOf( "model3D", "text", - "Rainier.bmp", - "Maltese.bmp", - "Sunrise.bmp", "simpleText", ) private val expectedFileSizes = mapOf( - "Maltese.bmp" to 16427390, "text" to 1232923, - "Rainier.bmp" to 6220854, - "Sunrise.bmp" to 52344054, "model3D" to 2478, "simpleText" to 100, ) @@ -55,7 +55,7 @@ class BlockingValidityTest { for (fileName in testFiles) { val originalData = readResourceFile(fileName) - val compressedData = KFlate.compress(originalData, RAW()) + val compressedData = KFlate.compress(originalData, CompressionRaw()) val inflater = Inflater(true) val inputStream = ByteArrayInputStream(compressedData) @@ -86,7 +86,7 @@ class BlockingValidityTest { val compressedData = outputStream.toByteArray() - val decompressedData = KFlate.decompress(compressedData, Raw()) + val decompressedData = KFlate.decompress(compressedData, DecompressionRaw()) assertContentEquals(originalData, decompressedData, "Failed on file: $fileName") @@ -101,7 +101,7 @@ class BlockingValidityTest { for (fileName in testFiles) { val originalData = readResourceFile(fileName) - val compressedData = KFlate.compress(originalData, GZIP()) + val compressedData = KFlate.compress(originalData, CompressionGzip()) val inputStream = ByteArrayInputStream(compressedData) val gzipInputStream = GZIPInputStream(inputStream) @@ -128,7 +128,7 @@ class BlockingValidityTest { val compressedData = outputStream.toByteArray() - val decompressedData = KFlate.decompress(compressedData, Gzip()) + val decompressedData = KFlate.decompress(compressedData, DecompressionGzip()) assertContentEquals(originalData, decompressedData, "Failed on file: $fileName") } @@ -140,22 +140,22 @@ class BlockingValidityTest { val testData = readResourceFile("simpleText") // Test level 0-1: should set XFL = 4 (max speed) - val compressed0 = KFlate.compress(testData, GZIP(level = 0)) + val compressed0 = KFlate.compress(testData, CompressionGzip(level = 0)) assert(compressed0[8] == 4.toByte()) { "Level 0 should set XFL = 4 (max speed)" } - val compressed1 = KFlate.compress(testData, GZIP(level = 1)) + val compressed1 = KFlate.compress(testData, CompressionGzip(level = 1)) assert(compressed1[8] == 4.toByte()) { "Level 1 should set XFL = 4 (max speed)" } // Test levels 2-8: should set XFL = 0 (default) for (level in 2..8) { - val compressed = KFlate.compress(testData, GZIP(level = level)) + val compressed = KFlate.compress(testData, CompressionGzip(level = level)) assert(compressed[8] == 0.toByte()) { "Level $level should set XFL = 0 (default), but got ${compressed[8]}" } } // Test level 9: should set XFL = 2 (max compression) - val compressed9 = KFlate.compress(testData, GZIP(level = 9)) + val compressed9 = KFlate.compress(testData, CompressionGzip(level = 9)) assert(compressed9[8] == 2.toByte()) { "Level 9 should set XFL = 2 (max compression)" } } @@ -166,7 +166,7 @@ class BlockingValidityTest { for (fileName in testFiles) { val originalData = readResourceFile(fileName) - val compressedData = KFlate.compress(originalData, ZLIB()) + val compressedData = KFlate.compress(originalData, CompressionZlib()) val inflater = Inflater() val inputStream = ByteArrayInputStream(compressedData) @@ -197,7 +197,7 @@ class BlockingValidityTest { val compressedData = outputStream.toByteArray() - val decompressedData = KFlate.decompress(compressedData, Zlib()) + val decompressedData = KFlate.decompress(compressedData, DecompressionZlib()) assertContentEquals(originalData, decompressedData, "Failed on file: $fileName") @@ -212,10 +212,10 @@ class BlockingValidityTest { val originalData = readResourceFile("simpleText") // Compress with KFlate (includes valid ADLER32) - val compressedData = KFlate.compress(originalData, ZLIB()) + val compressedData = KFlate.compress(originalData, CompressionZlib()) // Should decompress successfully without throwing error - val decompressedData = KFlate.decompress(compressedData, Zlib()) + val decompressedData = KFlate.decompress(compressedData, DecompressionZlib()) assertContentEquals(originalData, decompressedData) } @@ -225,7 +225,7 @@ class BlockingValidityTest { val originalData = readResourceFile("simpleText") // Compress with KFlate - val compressedData = KFlate.compress(originalData, ZLIB()).toMutableList() + val compressedData = KFlate.compress(originalData, CompressionZlib()).toMutableList() // Corrupt the ADLER32 checksum (last 4 bytes) val checksumStartIndex = compressedData.size - 4 @@ -233,7 +233,7 @@ class BlockingValidityTest { // Should throw error due to checksum mismatch try { - KFlate.decompress(compressedData.toByteArray(), Zlib()) + KFlate.decompress(compressedData.toByteArray(), DecompressionZlib()) assert(false) { "Expected checksum validation error but none was thrown" } } catch (e: Exception) { assert(e.message?.contains("checksum", ignoreCase = true) == true) { @@ -247,7 +247,7 @@ class BlockingValidityTest { val originalData = readResourceFile("simpleText") // Compress with KFlate - val compressedData = KFlate.compress(originalData, ZLIB()).toMutableList() + val compressedData = KFlate.compress(originalData, CompressionZlib()).toMutableList() // Corrupt the compressed data (not the checksum) // Skip header (2 bytes) and corrupt somewhere in the middle @@ -257,7 +257,7 @@ class BlockingValidityTest { // Should throw error due to checksum mismatch try { - KFlate.decompress(compressedData.toByteArray(), Zlib()) + KFlate.decompress(compressedData.toByteArray(), DecompressionZlib()) assert(false) { "Expected checksum validation error but none was thrown" } } catch (e: Exception) { assert(e.message?.contains("checksum", ignoreCase = true) == true) { @@ -276,7 +276,7 @@ class BlockingValidityTest { ) // Should decompress successfully - val decompressedData = KFlate.decompress(emptyData, Zlib()) + val decompressedData = KFlate.decompress(emptyData, DecompressionZlib()) assertContentEquals(ByteArray(0), decompressedData) } diff --git a/kflate/src/jvmTest/kotlin/BufferSizeValidationTest.kt b/kflate/src/jvmTest/kotlin/BufferSizeValidationTest.kt deleted file mode 100644 index 7d36f64..0000000 --- a/kflate/src/jvmTest/kotlin/BufferSizeValidationTest.kt +++ /dev/null @@ -1,41 +0,0 @@ - -package com.rafambn.kflate - -import kotlin.test.Test -import kotlin.test.assertContentEquals -import kotlin.test.assertFailsWith - -class BufferSizeValidationTest { - - @Test - fun testMemoryLevelValidation() { - // Should fail if mem < 0 - assertFailsWith { - RAW(mem = -1) - } - - // Should fail if mem > 12 - assertFailsWith { - RAW(mem = 13) - } - - // Should succeed if mem is in valid range - RAW(mem = 0) - RAW(mem = 8) - RAW(mem = 12) - } - - @Test - fun testCompressionWithDifferentMemoryLevels() { - val originalData = "This is a test string that will be compressed with different memory levels. ".repeat(10).toByteArray() - - val memLevels = listOf(0, 4, 8, 12) - - for (memLevel in memLevels) { - val type = RAW(mem = memLevel) - val compressed = KFlate.compress(originalData, type) - val decompressed = KFlate.decompress(compressed, Raw()) - assertContentEquals(originalData, decompressed, "Failed for mem $memLevel") - } - } -} diff --git a/kflate/src/jvmTest/kotlin/DecompressionOutputLimitTest.kt b/kflate/src/jvmTest/kotlin/DecompressionOutputLimitTest.kt new file mode 100644 index 0000000..df60838 --- /dev/null +++ b/kflate/src/jvmTest/kotlin/DecompressionOutputLimitTest.kt @@ -0,0 +1,174 @@ +package com.rafambn.kflate + +import com.rafambn.kflate.compression.Gzip as CompressionGzip +import com.rafambn.kflate.compression.Raw as CompressionRaw +import com.rafambn.kflate.compression.Zlib as CompressionZlib +import com.rafambn.kflate.decompression.DecompressionType +import com.rafambn.kflate.decompression.Gzip as DecompressionGzip +import com.rafambn.kflate.decompression.Raw as DecompressionRaw +import com.rafambn.kflate.decompression.Zlib as DecompressionZlib +import com.rafambn.kflate.error.FlateError +import com.rafambn.kflate.error.FlateErrorCode +import kotlinx.io.Buffer +import kotlinx.io.readByteArray +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class DecompressionOutputLimitTest { + @Test + fun blockingLimitIsEnforcedForEveryFormat() { + val original = ByteArray(4_096) + + assertOutputLimit( + KFlate.compress(original, CompressionRaw()), + DecompressionRaw(maxOutputSize = 1_024), + ) + assertOutputLimit( + KFlate.compress(original, CompressionGzip()), + DecompressionGzip(maxOutputSize = 1_024), + ) + assertOutputLimit( + KFlate.compress(original, CompressionZlib()), + DecompressionZlib(maxOutputSize = 1_024), + ) + } + + @Test + fun streamingLimitIsEnforcedForEveryFormat() { + val original = ByteArray(4_096) + + assertStreamingOutputLimit( + KFlate.compress(original, CompressionRaw()), + DecompressionRaw(maxOutputSize = 1_024), + 1_024, + ) + assertStreamingOutputLimit( + KFlate.compress(original, CompressionGzip()), + DecompressionGzip(maxOutputSize = 1_024), + 1_024, + ) + assertStreamingOutputLimit( + KFlate.compress(original, CompressionZlib()), + DecompressionZlib(maxOutputSize = 1_024), + 1_024, + ) + } + + @Test + fun exactAndZeroLimitsAreAccepted() { + val original = ByteArray(4_096) { (it and 7).toByte() } + val raw = KFlate.compress(original, CompressionRaw()) + val gzip = KFlate.compress(original, CompressionGzip()) + val zlib = KFlate.compress(original, CompressionZlib()) + + assertContentEquals( + original, + KFlate.decompress(raw, DecompressionRaw(maxOutputSize = original.size)), + ) + assertContentEquals( + original, + KFlate.decompress(gzip, DecompressionGzip(maxOutputSize = original.size)), + ) + assertContentEquals( + original, + KFlate.decompress(zlib, DecompressionZlib(maxOutputSize = original.size)), + ) + assertStreamingRoundTrip(original, raw, DecompressionRaw(maxOutputSize = original.size)) + assertStreamingRoundTrip(original, gzip, DecompressionGzip(maxOutputSize = original.size)) + assertStreamingRoundTrip(original, zlib, DecompressionZlib(maxOutputSize = original.size)) + + val empty = ByteArray(0) + val emptyRaw = KFlate.compress(empty, CompressionRaw()) + val emptyGzip = KFlate.compress(empty, CompressionGzip()) + val emptyZlib = KFlate.compress(empty, CompressionZlib()) + + assertContentEquals(empty, KFlate.decompress(emptyRaw, DecompressionRaw(maxOutputSize = 0))) + assertContentEquals(empty, KFlate.decompress(emptyGzip, DecompressionGzip(maxOutputSize = 0))) + assertContentEquals(empty, KFlate.decompress(emptyZlib, DecompressionZlib(maxOutputSize = 0))) + assertStreamingRoundTrip(empty, emptyRaw, DecompressionRaw(maxOutputSize = 0)) + assertStreamingRoundTrip(empty, emptyGzip, DecompressionGzip(maxOutputSize = 0)) + assertStreamingRoundTrip(empty, emptyZlib, DecompressionZlib(maxOutputSize = 0)) + } + + @Test + fun concatenatedGzipMembersShareOneLimit() { + val first = KFlate.compress(ByteArray(700) { 1 }, CompressionGzip()) + val second = KFlate.compress(ByteArray(700) { 2 }, CompressionGzip()) + val compressed = first + second + + assertOutputLimit(compressed, DecompressionGzip(maxOutputSize = 1_000)) + assertStreamingOutputLimit(compressed, DecompressionGzip(maxOutputSize = 1_000), 1_000) + } + + @Test + fun dictionariesRespectTheLimit() { + val dictionary = "shared dictionary content".repeat(100).encodeToByteArray() + val original = "shared dictionary content plus payload".repeat(200).encodeToByteArray() + val raw = KFlate.compress(original, CompressionRaw(dictionary = dictionary)) + val zlib = KFlate.compress(original, CompressionZlib(dictionary = dictionary)) + val rawExact = DecompressionRaw(dictionary = dictionary, maxOutputSize = original.size) + val zlibExact = DecompressionZlib(dictionary = dictionary, maxOutputSize = original.size) + + assertContentEquals(original, KFlate.decompress(raw, rawExact)) + assertContentEquals(original, KFlate.decompress(zlib, zlibExact)) + assertStreamingRoundTrip(original, raw, rawExact) + assertStreamingRoundTrip(original, zlib, zlibExact) + + val rawTooSmall = DecompressionRaw(dictionary = dictionary, maxOutputSize = original.size - 1) + val zlibTooSmall = DecompressionZlib(dictionary = dictionary, maxOutputSize = original.size - 1) + assertOutputLimit(raw, rawTooSmall) + assertOutputLimit(zlib, zlibTooSmall) + assertStreamingOutputLimit(raw, rawTooSmall, original.size - 1) + assertStreamingOutputLimit(zlib, zlibTooSmall, original.size - 1) + } + + @Test + fun rejectsNegativeLimits() { + assertFailsWith { DecompressionRaw(maxOutputSize = -1) } + assertFailsWith { DecompressionGzip(maxOutputSize = -1) } + assertFailsWith { DecompressionZlib(maxOutputSize = -1) } + } + + private fun assertOutputLimit(compressed: ByteArray, type: DecompressionType) { + val error = assertFailsWith { + KFlate.decompress(compressed, type) + } + assertEquals(FlateErrorCode.OUTPUT_LIMIT_EXCEEDED, error.code) + } + + private fun assertStreamingOutputLimit( + compressed: ByteArray, + type: DecompressionType, + maxOutputSize: Int, + ) { + val output = Buffer() + val error = assertFailsWith { + KFlate.decompress( + type, + Buffer().apply { write(compressed) }, + output, + ) + } + + assertEquals(FlateErrorCode.OUTPUT_LIMIT_EXCEEDED, error.code) + assertTrue(output.size <= maxOutputSize.toLong()) + } + + private fun assertStreamingRoundTrip( + original: ByteArray, + compressed: ByteArray, + type: DecompressionType, + ) { + val output = Buffer() + KFlate.decompress( + type, + Buffer().apply { write(compressed) }, + output, + ) + + assertContentEquals(original, output.readByteArray()) + } +} diff --git a/kflate/src/jvmTest/kotlin/DeflateScratchSizingTest.kt b/kflate/src/jvmTest/kotlin/DeflateScratchSizingTest.kt new file mode 100644 index 0000000..aee1a53 --- /dev/null +++ b/kflate/src/jvmTest/kotlin/DeflateScratchSizingTest.kt @@ -0,0 +1,64 @@ +package com.rafambn.kflate + +import com.rafambn.kflate.algorithm.MATCH_DISTANCE_MASK +import com.rafambn.kflate.algorithm.deflate +import com.rafambn.kflate.compression.Raw as CompressionRaw +import com.rafambn.kflate.decompression.Raw as DecompressionRaw +import com.rafambn.kflate.streaming.DeflateState +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class DeflateScratchSizingTest { + + @Test + fun tinyAndEmptyFinalInputsRoundTripAtEveryCompressionLevel() { + val inputs = listOf(ByteArray(0), byteArrayOf(1, 2, 3)) + + for (level in 1..9) { + for (input in inputs) { + val compressed = KFlate.compress(input, CompressionRaw(level = level)) + assertContentEquals( + input, + KFlate.decompress(compressed, DecompressionRaw()), + "level $level", + ) + } + } + } + + @Test + fun finalDictionaryInputRoundTripsWithSmallScratchBuffers() { + val dictionary = "shared prefix ".repeat(8).encodeToByteArray() + val input = "shared prefix payload".encodeToByteArray() + + for (level in listOf(1, 6, 9)) { + val compressed = KFlate.compress( + input, + CompressionRaw(level = level, dictionary = dictionary), + ) + assertContentEquals( + input, + KFlate.decompress(compressed, DecompressionRaw(dictionary = dictionary)), + "level $level", + ) + } + } + + @Test + fun nonFinalStateRetainsTheFullHistoryRingAndReusesItOnFinalCall() { + val state = DeflateState(isLastChunk = false) + val input = byteArrayOf(1, 2, 3) + + deflate(input, level = 6, hashBits = 12, prefixSize = 0, postfixSize = 0, state = state) + + val history = state.prev + assertEquals(MATCH_DISTANCE_MASK + 1, history!!.size) + + state.isLastChunk = true + deflate(input, level = 6, hashBits = 12, prefixSize = 0, postfixSize = 0, state = state) + + assertSame(history, state.prev) + } +} diff --git a/kflate/src/jvmTest/kotlin/DynamicHuffmanRepeatCodeValidationTest.kt b/kflate/src/jvmTest/kotlin/DynamicHuffmanRepeatCodeValidationTest.kt index c650d8f..6aee3d1 100644 --- a/kflate/src/jvmTest/kotlin/DynamicHuffmanRepeatCodeValidationTest.kt +++ b/kflate/src/jvmTest/kotlin/DynamicHuffmanRepeatCodeValidationTest.kt @@ -1,11 +1,11 @@ package com.rafambn.kflate +import com.rafambn.kflate.decompression.Raw import java.io.ByteArrayOutputStream import java.util.zip.Deflater import java.util.zip.DeflaterOutputStream import kotlin.test.Test -import kotlin.test.assertFailsWith class DynamicHuffmanRepeatCodeValidationTest { diff --git a/kflate/src/jvmTest/kotlin/EmptyRawInputTest.kt b/kflate/src/jvmTest/kotlin/EmptyRawInputTest.kt new file mode 100644 index 0000000..79f9631 --- /dev/null +++ b/kflate/src/jvmTest/kotlin/EmptyRawInputTest.kt @@ -0,0 +1,33 @@ +package com.rafambn.kflate + +import com.rafambn.kflate.compression.Raw as CompressionRaw +import com.rafambn.kflate.decompression.Raw as DecompressionRaw +import com.rafambn.kflate.error.FlateError +import com.rafambn.kflate.error.FlateErrorCode +import kotlinx.io.Buffer +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class EmptyRawInputTest { + @Test + fun rejectsEmptyCompressedInput() { + val blockingError = assertFailsWith { + KFlate.decompress(ByteArray(0), DecompressionRaw()) + } + assertEquals(FlateErrorCode.UNEXPECTED_EOF, blockingError.code) + + val streamingError = assertFailsWith { + KFlate.decompress(DecompressionRaw(), Buffer(), Buffer()) + } + assertEquals(FlateErrorCode.UNEXPECTED_EOF, streamingError.code) + } + + @Test + fun compressedEmptyPayloadStillRoundTrips() { + val compressed = KFlate.compress(ByteArray(0), CompressionRaw()) + + assertContentEquals(ByteArray(0), KFlate.decompress(compressed, DecompressionRaw())) + } +} diff --git a/kflate/src/jvmTest/kotlin/GzipConcatenatedMembersTest.kt b/kflate/src/jvmTest/kotlin/GzipConcatenatedMembersTest.kt index a75570f..15ede91 100644 --- a/kflate/src/jvmTest/kotlin/GzipConcatenatedMembersTest.kt +++ b/kflate/src/jvmTest/kotlin/GzipConcatenatedMembersTest.kt @@ -1,6 +1,8 @@ package com.rafambn.kflate +import com.rafambn.kflate.compression.Gzip as CompressionGzip +import com.rafambn.kflate.decompression.Gzip as DecompressionGzip import com.rafambn.kflate.error.FlateError import com.rafambn.kflate.error.FlateErrorCode import kotlin.test.Test @@ -12,9 +14,9 @@ class GzipConcatenatedMembersTest { @Test fun `Gzip decompress should handle single member (backward compatibility)`() { val originalData = "Hello KFlate World!".encodeToByteArray() - val compressed = KFlate.compress(originalData, GZIP()) + val compressed = KFlate.compress(originalData, CompressionGzip()) - val decompressed = KFlate.decompress(compressed, Gzip()) + val decompressed = KFlate.decompress(compressed, DecompressionGzip()) assertEquals(originalData.toList(), decompressed.toList()) } @@ -23,15 +25,15 @@ class GzipConcatenatedMembersTest { val data1 = "Hello ".encodeToByteArray() val data2 = "World!".encodeToByteArray() - val compressed1 = KFlate.compress(data1, GZIP()) - val compressed2 = KFlate.compress(data2, GZIP()) + val compressed1 = KFlate.compress(data1, CompressionGzip()) + val compressed2 = KFlate.compress(data2, CompressionGzip()) // Concatenate the two compressed members val concatenated = ByteArray(compressed1.size + compressed2.size) compressed1.copyInto(concatenated, 0) compressed2.copyInto(concatenated, compressed1.size) - val decompressed = KFlate.decompress(concatenated, Gzip()) + val decompressed = KFlate.decompress(concatenated, DecompressionGzip()) val expected = data1 + data2 assertEquals(expected.toList(), decompressed.toList()) } @@ -42,9 +44,9 @@ class GzipConcatenatedMembersTest { val data2 = "Second ".encodeToByteArray() val data3 = "Third".encodeToByteArray() - val compressed1 = KFlate.compress(data1, GZIP()) - val compressed2 = KFlate.compress(data2, GZIP()) - val compressed3 = KFlate.compress(data3, GZIP()) + val compressed1 = KFlate.compress(data1, CompressionGzip()) + val compressed2 = KFlate.compress(data2, CompressionGzip()) + val compressed3 = KFlate.compress(data3, CompressionGzip()) // Concatenate all three compressed members val concatenated = ByteArray(compressed1.size + compressed2.size + compressed3.size) @@ -52,7 +54,7 @@ class GzipConcatenatedMembersTest { compressed2.copyInto(concatenated, compressed1.size) compressed3.copyInto(concatenated, compressed1.size + compressed2.size) - val decompressed = KFlate.decompress(concatenated, Gzip()) + val decompressed = KFlate.decompress(concatenated, DecompressionGzip()) val expected = data1 + data2 + data3 assertEquals(expected.toList(), decompressed.toList()) } @@ -60,7 +62,7 @@ class GzipConcatenatedMembersTest { @Test fun `Gzip decompress should reject trailing garbage`() { val originalData = "Hello KFlate World!".encodeToByteArray() - val compressed = KFlate.compress(originalData, GZIP()) + val compressed = KFlate.compress(originalData, CompressionGzip()) // Add garbage bytes at the end val withGarbage = ByteArray(compressed.size + 5) @@ -71,7 +73,7 @@ class GzipConcatenatedMembersTest { } try { - KFlate.decompress(withGarbage, Gzip()) + KFlate.decompress(withGarbage, DecompressionGzip()) fail("Should have thrown FlateError for trailing garbage") } catch (e: FlateError) { assertEquals(FlateErrorCode.TRAILING_GARBAGE, e.code) @@ -85,10 +87,10 @@ class GzipConcatenatedMembersTest { @Test fun `Gzip decompress should reject incomplete member`() { val originalData = "Hello KFlate World!".encodeToByteArray() - val compressed = KFlate.compress(originalData, GZIP()) + val compressed = KFlate.compress(originalData, CompressionGzip()) val data1 = "Start ".encodeToByteArray() - val compressed1 = KFlate.compress(data1, GZIP()) + val compressed1 = KFlate.compress(data1, CompressionGzip()) // Create concatenated data with incomplete second member val concatenated = ByteArray(compressed1.size + 5) @@ -101,7 +103,7 @@ class GzipConcatenatedMembersTest { concatenated[compressed1.size + 4] = 0.toByte() try { - KFlate.decompress(concatenated, Gzip()) + KFlate.decompress(concatenated, DecompressionGzip()) fail("Should have thrown FlateError for incomplete member") } catch (e: FlateError) { // Should be either TRAILING_GARBAGE or UNEXPECTED_EOF @@ -117,15 +119,15 @@ class GzipConcatenatedMembersTest { fun `Gzip decompress should handle empty member concatenation`() { val data1 = "".encodeToByteArray() val data2 = "Content".encodeToByteArray() - val compressed1 = KFlate.compress(data1, GZIP()) - val compressed2 = KFlate.compress(data2, GZIP()) + val compressed1 = KFlate.compress(data1, CompressionGzip()) + val compressed2 = KFlate.compress(data2, CompressionGzip()) // Concatenate empty member with content member val concatenated = ByteArray(compressed1.size + compressed2.size) compressed1.copyInto(concatenated, 0) compressed2.copyInto(concatenated, compressed1.size) - val decompressed = KFlate.decompress(concatenated, Gzip()) + val decompressed = KFlate.decompress(concatenated, DecompressionGzip()) val expected = data1 + data2 assertEquals(expected.toList(), decompressed.toList()) } @@ -133,7 +135,7 @@ class GzipConcatenatedMembersTest { @Test fun `Gzip decompress should reject invalid magic bytes in second member`() { val data1 = "First ".encodeToByteArray() - val compressed1 = KFlate.compress(data1, GZIP()) + val compressed1 = KFlate.compress(data1, CompressionGzip()) // Create invalid second member with wrong magic bytes val invalidMember = ByteArray(20) @@ -146,7 +148,7 @@ class GzipConcatenatedMembersTest { invalidMember.copyInto(concatenated, compressed1.size) try { - KFlate.decompress(concatenated, Gzip()) + KFlate.decompress(concatenated, DecompressionGzip()) fail("Should have thrown FlateError for invalid magic bytes") } catch (e: FlateError) { assertEquals(FlateErrorCode.TRAILING_GARBAGE, e.code) @@ -162,9 +164,9 @@ class GzipConcatenatedMembersTest { val data1 = "AAA".encodeToByteArray() val data2 = "BBB".encodeToByteArray() val data3 = "CCC".encodeToByteArray() - val compressed1 = KFlate.compress(data1, GZIP()) - val compressed2 = KFlate.compress(data2, GZIP()) - val compressed3 = KFlate.compress(data3, GZIP()) + val compressed1 = KFlate.compress(data1, CompressionGzip()) + val compressed2 = KFlate.compress(data2, CompressionGzip()) + val compressed3 = KFlate.compress(data3, CompressionGzip()) // Concatenate in specific order val concatenated = ByteArray(compressed1.size + compressed2.size + compressed3.size) @@ -172,7 +174,7 @@ class GzipConcatenatedMembersTest { compressed2.copyInto(concatenated, compressed1.size) compressed3.copyInto(concatenated, compressed1.size + compressed2.size) - val decompressed = KFlate.decompress(concatenated, Gzip()) + val decompressed = KFlate.decompress(concatenated, DecompressionGzip()) val expected = "AAABBBCCC".encodeToByteArray() assertEquals(expected.toList(), decompressed.toList()) } @@ -181,14 +183,14 @@ class GzipConcatenatedMembersTest { fun `Gzip decompress should handle large concatenated members`() { val data1 = "A".repeat(1000).encodeToByteArray() val data2 = "B".repeat(2000).encodeToByteArray() - val compressed1 = KFlate.compress(data1, GZIP()) - val compressed2 = KFlate.compress(data2, GZIP()) + val compressed1 = KFlate.compress(data1, CompressionGzip()) + val compressed2 = KFlate.compress(data2, CompressionGzip()) val concatenated = ByteArray(compressed1.size + compressed2.size) compressed1.copyInto(concatenated, 0) compressed2.copyInto(concatenated, compressed1.size) - val decompressed = KFlate.decompress(concatenated, Gzip()) + val decompressed = KFlate.decompress(concatenated, DecompressionGzip()) val expected = data1 + data2 assertEquals(expected.toList(), decompressed.toList()) } diff --git a/kflate/src/jvmTest/kotlin/GzipDecompressionOptionsTest.kt b/kflate/src/jvmTest/kotlin/GzipDecompressionOptionsTest.kt new file mode 100644 index 0000000..0a5a9a8 --- /dev/null +++ b/kflate/src/jvmTest/kotlin/GzipDecompressionOptionsTest.kt @@ -0,0 +1,18 @@ +package com.rafambn.kflate + +import com.rafambn.kflate.decompression.Gzip +import kotlin.test.Test +import kotlin.test.assertEquals + +class GzipDecompressionOptionsTest { + + @Test + fun testEquivalentInstancesHaveValueSemantics() { + val first = Gzip() + val second = Gzip() + + assertEquals(first, second) + assertEquals(first.hashCode(), second.hashCode()) + assertEquals(1, setOf(first, second).size) + } +} diff --git a/kflate/src/jvmTest/kotlin/GzipEncodingTest.kt b/kflate/src/jvmTest/kotlin/GzipEncodingTest.kt index eb1f3f2..9dc94a9 100644 --- a/kflate/src/jvmTest/kotlin/GzipEncodingTest.kt +++ b/kflate/src/jvmTest/kotlin/GzipEncodingTest.kt @@ -1,5 +1,6 @@ package com.rafambn.kflate +import com.rafambn.kflate.compression.Gzip import com.rafambn.kflate.format.getGzipHeaderSize import com.rafambn.kflate.format.writeGzipHeader import kotlin.test.Test @@ -10,7 +11,7 @@ class GzipEncodingTest { @Test fun testValidAscii() { - val gzip = GZIP(filename = "test.txt", comment = "Just a test") + val gzip = Gzip(filename = "test.txt", comment = "Just a test") assertEquals("test.txt", gzip.filename) assertEquals("Just a test", gzip.comment) } @@ -20,7 +21,7 @@ class GzipEncodingTest { // \u00E9 is 'é' (233), \u00F1 is 'ñ' (241) val filename = "t\u00E9st.txt" val comment = "Se\u00F1or" - val gzip = GZIP(filename = filename, comment = comment) + val gzip = Gzip(filename = filename, comment = comment) assertEquals(filename, gzip.filename) assertEquals(comment, gzip.comment) } @@ -29,7 +30,7 @@ class GzipEncodingTest { fun testInvalidFilename() { // \u0100 is 256, just outside ISO-8859-1 assertFailsWith { - GZIP(filename = "test\u0100.txt") + Gzip(filename = "test\u0100.txt") } } @@ -37,7 +38,7 @@ class GzipEncodingTest { fun testInvalidComment() { // \u2603 is Snowman assertFailsWith { - GZIP(comment = "Snowman \u2603") + Gzip(comment = "Snowman \u2603") } } @@ -45,7 +46,7 @@ class GzipEncodingTest { @Test fun testHeaderGenerationWithIso() { val filename = "caf\u00E9.txt" // café.txt - val gzip = GZIP(filename = filename) + val gzip = Gzip(filename = filename) // Calculate expected size // 10 bytes header diff --git a/kflate/src/jvmTest/kotlin/GzipFhcrcValidationTest.kt b/kflate/src/jvmTest/kotlin/GzipFhcrcValidationTest.kt index ff46744..95cc993 100644 --- a/kflate/src/jvmTest/kotlin/GzipFhcrcValidationTest.kt +++ b/kflate/src/jvmTest/kotlin/GzipFhcrcValidationTest.kt @@ -1,5 +1,7 @@ package com.rafambn.kflate +import com.rafambn.kflate.compression.Gzip as CompressionGzip +import com.rafambn.kflate.decompression.Gzip as DecompressionGzip import com.rafambn.kflate.error.FlateError import com.rafambn.kflate.error.FlateErrorCode import com.rafambn.kflate.format.getGzipHeaderSize @@ -13,17 +15,17 @@ class GzipFhcrcValidationTest { @Test fun testValidFhcrc() { val data = "Hello World".encodeToByteArray() - val gzip = GZIP(includeHeaderCrc = true) + val gzip = CompressionGzip(includeHeaderCrc = true) val compressed = KFlate.compress(data, gzip) - val decompressed = KFlate.decompress(compressed, Gzip()) + val decompressed = KFlate.decompress(compressed, DecompressionGzip()) assertEquals("Hello World", decompressed.decodeToString()) } @Test fun testCorruptedHeaderWithFhcrc() { val data = "Hello World".encodeToByteArray() - val gzip = GZIP(includeHeaderCrc = true) + val gzip = CompressionGzip(includeHeaderCrc = true) val compressed = KFlate.compress(data, gzip).toMutableList() // Corrupt a byte in the header (e.g., byte 9 is OS, byte 8 is XFL) @@ -31,14 +33,14 @@ class GzipFhcrcValidationTest { compressed[5] = (compressed[5] + 1).toByte() assertFailsWith { - KFlate.decompress(compressed.toByteArray(), Gzip()) + KFlate.decompress(compressed.toByteArray(), DecompressionGzip()) } } @Test fun testCorruptedFhcrcValue() { val data = "Hello World".encodeToByteArray() - val gzip = GZIP(includeHeaderCrc = true) + val gzip = CompressionGzip(includeHeaderCrc = true) val compressed = KFlate.compress(data, gzip).toMutableList() // The FHCRC is the last 2 bytes of the header. @@ -52,14 +54,14 @@ class GzipFhcrcValidationTest { compressed[11] = (compressed[11] + 1).toByte() assertFailsWith { - KFlate.decompress(compressed.toByteArray(), Gzip()) + KFlate.decompress(compressed.toByteArray(), DecompressionGzip()) } } @Test fun testTruncatedFhcrc() { val data = "Hello World".encodeToByteArray() - val gzip = GZIP(includeHeaderCrc = true) + val gzip = CompressionGzip(includeHeaderCrc = true) val compressed = KFlate.compress(data, gzip) // Truncate the last byte of the FHCRC @@ -71,7 +73,7 @@ class GzipFhcrcValidationTest { val truncated = compressed.copyOfRange(0, 11) val error = assertFailsWith { - KFlate.decompress(truncated, Gzip()) + KFlate.decompress(truncated, DecompressionGzip()) } assertEquals(FlateErrorCode.UNEXPECTED_EOF, error.code) } diff --git a/kflate/src/jvmTest/kotlin/GzipMetadataValidationTest.kt b/kflate/src/jvmTest/kotlin/GzipMetadataValidationTest.kt new file mode 100644 index 0000000..731099f --- /dev/null +++ b/kflate/src/jvmTest/kotlin/GzipMetadataValidationTest.kt @@ -0,0 +1,96 @@ +package com.rafambn.kflate + +import com.rafambn.kflate.compression.Gzip +import com.rafambn.kflate.format.buildExtraFields +import kotlinx.io.Buffer +import kotlinx.io.readByteArray +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.time.Instant + +class GzipMetadataValidationTest { + @Test + fun rejectsNulTerminatedMetadataFromCallers() { + assertFailsWith { + Gzip(filename = "file\u0000name") + } + assertFailsWith { + Gzip(comment = "comment\u0000suffix") + } + } + + @Test + fun rejectsInvalidExtraFieldIds() { + assertFailsWith { + Gzip(extraFields = mapOf("A€" to byteArrayOf(1))) + } + assertFailsWith { + Gzip(extraFields = mapOf("A\u0000" to byteArrayOf(1))) + } + } + + @Test + fun rejectsExtraFieldSizeOverflow() { + val data = ByteArray(65_535) + val fields = HashMap(32_767) + for (first in 0..255) { + for (second in 1..255) { + fields[String(charArrayOf(first.toChar(), second.toChar()))] = data + if (fields.size == 32_767) break + } + if (fields.size == 32_767) break + } + + assertFailsWith { + Gzip(extraFields = fields) + } + assertFailsWith { + buildExtraFields(fields) + } + } + + @Test + fun validatesAndWritesUnsignedTimestamp() { + assertFailsWith { + Gzip(mtime = Instant.fromEpochSeconds(-1)) + } + assertFailsWith { + Gzip(mtime = Instant.fromEpochSeconds(0x1_0000_0000L)) + } + + val compressed = KFlate.compress( + byteArrayOf(1), + Gzip(mtime = Instant.fromEpochSeconds(1)), + ) + + assertEquals(1, compressed[4].toInt()) + assertEquals(0, compressed[5].toInt()) + assertEquals(0, compressed[6].toInt()) + assertEquals(0, compressed[7].toInt()) + } + + @Test + fun writesTimestampBoundaries() { + assertMtimeBytes(Instant.fromEpochSeconds(0), byteArrayOf(0, 0, 0, 0)) + assertMtimeBytes(Instant.fromEpochSeconds(1, 999_999_999), byteArrayOf(1, 0, 0, 0)) + assertMtimeBytes(Instant.fromEpochSeconds(0xFFFF_FFFFL), byteArrayOf(-1, -1, -1, -1)) + } + + @Test + fun writesUnsignedTimestampWhenStreaming() { + val source = Buffer().apply { write(byteArrayOf(1)) } + val sink = Buffer() + + KFlate.compress(Gzip(mtime = Instant.fromEpochSeconds(0xFFFF_FFFFL)), source, sink) + + assertContentEquals(byteArrayOf(-1, -1, -1, -1), sink.readByteArray().copyOfRange(4, 8)) + } + + private fun assertMtimeBytes(mtime: Instant, expected: ByteArray) { + val compressed = KFlate.compress(byteArrayOf(1), Gzip(mtime = mtime)) + + assertContentEquals(expected, compressed.copyOfRange(4, 8)) + } +} diff --git a/kflate/src/jvmTest/kotlin/GzipOptionalFieldsTest.kt b/kflate/src/jvmTest/kotlin/GzipOptionalFieldsTest.kt index 4c4d390..fcc948a 100644 --- a/kflate/src/jvmTest/kotlin/GzipOptionalFieldsTest.kt +++ b/kflate/src/jvmTest/kotlin/GzipOptionalFieldsTest.kt @@ -1,5 +1,7 @@ package com.rafambn.kflate +import com.rafambn.kflate.compression.Gzip as CompressionGzip +import com.rafambn.kflate.decompression.Gzip as DecompressionGzip import com.rafambn.kflate.format.getGzipHeaderSize import com.rafambn.kflate.format.writeGzipStart import java.io.ByteArrayInputStream @@ -15,37 +17,37 @@ class GzipOptionalFieldsTest { @Test fun testHeaderSizeCalculationNoOptionalFields() { - val gzip = GZIP() + val gzip = CompressionGzip() assertEquals(10, getGzipHeaderSize(gzip)) } @Test fun testHeaderSizeCalculationFilenameOnly() { - val gzip = GZIP(filename = "test.txt") + val gzip = CompressionGzip(filename = "test.txt") assertEquals(19, getGzipHeaderSize(gzip)) } @Test fun testHeaderSizeCalculationCommentOnly() { - val gzip = GZIP(comment = "note") + val gzip = CompressionGzip(comment = "note") assertEquals(15, getGzipHeaderSize(gzip)) } @Test fun testHeaderSizeCalculationExtraFieldsOnly() { - val gzip = GZIP(extraFields = mapOf("AB" to byteArrayOf(1, 2, 3))) + val gzip = CompressionGzip(extraFields = mapOf("AB" to byteArrayOf(1, 2, 3))) assertEquals(19, getGzipHeaderSize(gzip)) } @Test fun testHeaderSizeCalculationHeaderCrcOnly() { - val gzip = GZIP(includeHeaderCrc = true) + val gzip = CompressionGzip(includeHeaderCrc = true) assertEquals(12, getGzipHeaderSize(gzip)) } @Test fun testHeaderSizeCalculationAllOptionalFields() { - val gzip = GZIP( + val gzip = CompressionGzip( filename = "file.txt", comment = "comment", extraFields = mapOf("XX" to byteArrayOf(1, 2)), @@ -56,7 +58,7 @@ class GzipOptionalFieldsTest { @Test fun testHeaderSizeMatchesActualHeader() { - val gzip = GZIP( + val gzip = CompressionGzip( filename = "file.txt", comment = "comment", extraFields = mapOf("XX" to byteArrayOf(1, 2)), @@ -72,7 +74,7 @@ class GzipOptionalFieldsTest { @Test fun testGzipOptionalFieldsRoundTripWithKFlate() { - val gzip = GZIP( + val gzip = CompressionGzip( filename = "file.txt", comment = "comment", extraFields = mapOf("XX" to byteArrayOf(1, 2, 3)), @@ -80,14 +82,14 @@ class GzipOptionalFieldsTest { ) val data = "optional-fields".encodeToByteArray() val compressed = KFlate.compress(data, gzip) - val decompressed = KFlate.decompress(compressed, Gzip()) + val decompressed = KFlate.decompress(compressed, DecompressionGzip()) assertContentEquals(data, decompressed) } @Test fun testGzipOptionalFieldsReadableByJavaGzip() { - val gzip = GZIP( + val gzip = CompressionGzip( filename = "file.txt", comment = "comment", extraFields = mapOf("XX" to byteArrayOf(1)), @@ -110,14 +112,14 @@ class GzipOptionalFieldsTest { // Create extra fields totaling exactly 65535 bytes (maximum allowed XLEN) // Single field with 65531 bytes of data: 4 bytes (SI1+SI2+LEN) + 65531 bytes = 65535 val maxData = ByteArray(65531) { it.toByte() } - val gzip = GZIP( + val gzip = CompressionGzip( extraFields = mapOf("AB" to maxData) ) val data = "test".encodeToByteArray() // Should not throw - this is a valid maximum XLEN val compressed = KFlate.compress(data, gzip) - val decompressed = KFlate.decompress(compressed, Gzip()) + val decompressed = KFlate.decompress(compressed, DecompressionGzip()) assertContentEquals(data, decompressed) } @@ -130,7 +132,7 @@ class GzipOptionalFieldsTest { // Should throw because total XLEN exceeds 65535 assertFailsWith { - GZIP(extraFields = mapOf("AB" to exceedData)) + CompressionGzip(extraFields = mapOf("AB" to exceedData)) } } @@ -156,12 +158,12 @@ class GzipOptionalFieldsTest { val fieldId = String(charArrayOf('A' + (i / 26), 'A' + (i % 26))) fields[fieldId] = ByteArray(651) { it.toByte() } } - val gzip = GZIP(extraFields = fields) + val gzip = CompressionGzip(extraFields = fields) val data = "test".encodeToByteArray() // Should not throw - total is 65500 bytes val compressed = KFlate.compress(data, gzip) - val decompressed = KFlate.decompress(compressed, Gzip()) + val decompressed = KFlate.decompress(compressed, DecompressionGzip()) assertContentEquals(data, decompressed) } @@ -178,7 +180,7 @@ class GzipOptionalFieldsTest { // Should throw because total XLEN exceeds 65535 assertFailsWith { - GZIP(extraFields = fields) + CompressionGzip(extraFields = fields) } } } diff --git a/kflate/src/jvmTest/kotlin/GzipTrailerValidationTest.kt b/kflate/src/jvmTest/kotlin/GzipTrailerValidationTest.kt index d2f8b3b..c76c0ac 100644 --- a/kflate/src/jvmTest/kotlin/GzipTrailerValidationTest.kt +++ b/kflate/src/jvmTest/kotlin/GzipTrailerValidationTest.kt @@ -1,5 +1,7 @@ package com.rafambn.kflate +import com.rafambn.kflate.compression.Gzip as CompressionGzip +import com.rafambn.kflate.decompression.Gzip as DecompressionGzip import com.rafambn.kflate.error.FlateError import com.rafambn.kflate.error.FlateErrorCode import kotlin.test.Test @@ -12,7 +14,7 @@ class GzipTrailerValidationTest { @Test fun `Gzip decompress should fail when CRC32 is invalid`() { val originalData = "Hello KFlate World!".encodeToByteArray() - val compressed = KFlate.compress(originalData, GZIP()) + val compressed = KFlate.compress(originalData, CompressionGzip()) // Corrupt CRC32 (located at index size-8 to size-5) // Gzip trailer: CRC32 (4 bytes) + ISIZE (4 bytes) @@ -21,7 +23,7 @@ class GzipTrailerValidationTest { corrupted[crcOffset] = (corrupted[crcOffset] + 1).toByte() try { - KFlate.decompress(corrupted, Gzip()) + KFlate.decompress(corrupted, DecompressionGzip()) // If we reach here, validation failed to catch the error fail("Should have thrown FlateError for invalid CRC32") } catch (e: FlateError) { @@ -37,7 +39,7 @@ class GzipTrailerValidationTest { @Test fun `Gzip decompress should fail when ISIZE is invalid`() { val originalData = "Hello KFlate World!".encodeToByteArray() - val compressed = KFlate.compress(originalData, GZIP()) + val compressed = KFlate.compress(originalData, CompressionGzip()) // Corrupt ISIZE (located at index size-4 to size-1) val corrupted = compressed.copyOf() @@ -46,7 +48,7 @@ class GzipTrailerValidationTest { corrupted[isizeOffset] = (corrupted[isizeOffset] + 1).toByte() try { - KFlate.decompress(corrupted, Gzip()) + KFlate.decompress(corrupted, DecompressionGzip()) fail("Should have thrown FlateError for invalid ISIZE") } catch (e: FlateError) { // Expected @@ -63,13 +65,13 @@ class GzipTrailerValidationTest { @Test fun `Gzip decompress should fail when trailer is truncated`() { val originalData = "Hello KFlate World!".encodeToByteArray() - val compressed = KFlate.compress(originalData, GZIP()) + val compressed = KFlate.compress(originalData, CompressionGzip()) // Truncate the last byte val truncated = compressed.copyOfRange(0, compressed.size - 1) try { - KFlate.decompress(truncated, Gzip()) + KFlate.decompress(truncated, DecompressionGzip()) fail("Should have thrown FlateError for truncated data") } catch (e: FlateError) { // Expected diff --git a/kflate/src/jvmTest/kotlin/HuffmanTreeValidationTest.kt b/kflate/src/jvmTest/kotlin/HuffmanTreeValidationTest.kt index d154f0f..db44786 100644 --- a/kflate/src/jvmTest/kotlin/HuffmanTreeValidationTest.kt +++ b/kflate/src/jvmTest/kotlin/HuffmanTreeValidationTest.kt @@ -1,12 +1,12 @@ package com.rafambn.kflate +import com.rafambn.kflate.decompression.Raw import com.rafambn.kflate.huffman.validateHuffmanCodeLengths import java.io.ByteArrayOutputStream import java.util.zip.Deflater import java.util.zip.DeflaterOutputStream import kotlin.test.Test -import kotlin.test.assertFailsWith class HuffmanTreeValidationTest { diff --git a/kflate/src/jvmTest/kotlin/InternalHelpersCoverageTest.kt b/kflate/src/jvmTest/kotlin/InternalHelpersCoverageTest.kt new file mode 100644 index 0000000..08779b6 --- /dev/null +++ b/kflate/src/jvmTest/kotlin/InternalHelpersCoverageTest.kt @@ -0,0 +1,318 @@ +package com.rafambn.kflate + +import com.rafambn.kflate.algorithm.inflate +import com.rafambn.kflate.compression.Gzip as CompressionGzip +import com.rafambn.kflate.decompression.Gzip as DecompressionGzip +import com.rafambn.kflate.error.FlateError +import com.rafambn.kflate.error.FlateErrorCode +import com.rafambn.kflate.format.buildExtraFields +import com.rafambn.kflate.format.getGzipHeaderSize +import com.rafambn.kflate.format.getGzipUncompressedSize +import com.rafambn.kflate.format.processSingleGzipMember +import com.rafambn.kflate.format.writeGzipHeader +import com.rafambn.kflate.format.writeGzipStart +import com.rafambn.kflate.format.writeZlibHeader +import com.rafambn.kflate.format.writeZlibStart +import com.rafambn.kflate.streaming.DeflateState +import com.rafambn.kflate.streaming.InflateState +import com.rafambn.kflate.streaming.STREAM_HISTORY_SIZE +import com.rafambn.kflate.streaming.appendBytes +import com.rafambn.kflate.streaming.inflateStreamChunk +import com.rafambn.kflate.streaming.trimDeflateInput +import com.rafambn.kflate.streaming.updateHistory +import com.rafambn.kflate.util.findMaxValue +import com.rafambn.kflate.util.countCodeLengthCodes +import com.rafambn.kflate.util.readBits +import com.rafambn.kflate.util.readBits16 +import com.rafambn.kflate.util.readEightBytes +import com.rafambn.kflate.util.readFourBytes +import com.rafambn.kflate.util.readFourBytesBE +import com.rafambn.kflate.util.readTwoBytes +import com.rafambn.kflate.util.shiftToNextByte +import com.rafambn.kflate.util.shouldUseStoredBlock +import com.rafambn.kflate.util.storedBlockBitLength +import com.rafambn.kflate.util.toIsoStringBytes +import com.rafambn.kflate.util.writeBits +import com.rafambn.kflate.util.writeBits16 +import com.rafambn.kflate.util.writeBlock +import com.rafambn.kflate.util.writeBytes +import com.rafambn.kflate.util.writeBytesBE +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Instant + +class InternalHelpersCoverageTest { + + @Test + fun bitAndByteHelpersCoverBoundaryReads() { + assertEquals(0, findMaxValue(byteArrayOf())) + assertEquals(255, findMaxValue(byteArrayOf(2, 1, -1, 3))) + assertContentEquals(byteArrayOf(0, -1), "\u0000\u00ff".toIsoStringBytes()) + assertFailsWith { "\u0100".toIsoStringBytes() } + + assertEquals(0, readBits(byteArrayOf(), 0, 0xff)) + assertEquals(0xab, readBits(byteArrayOf(0xab.toByte()), 0, 0xff)) + assertEquals(0x12, readBits(byteArrayOf(0x24), 1, 0xff)) + assertEquals(0, readBits16(byteArrayOf(), 0)) + assertEquals(0xab, readBits16(byteArrayOf(0xab.toByte()), 0)) + assertEquals(0xcdab, readBits16(byteArrayOf(0xab.toByte(), 0xcd.toByte()), 0)) + assertEquals(0x01cdab, readBits16(byteArrayOf(0xab.toByte(), 0xcd.toByte(), 1), 0)) + + assertEquals(2, shiftToNextByte(9)) + assertEquals(2, shiftToNextByte(9L)) + val bits = ByteArray(4) + writeBits(bits, 3, 0x1f) + writeBits16(bits, 9, 0x1234) + assertTrue(bits.any { it != 0.toByte() }) + + val littleEndian = ByteArray(8) + writeBytes(littleEndian, 0, 0x1234_5678) + writeBytes(littleEndian, 4, 0x0102_0304) + assertEquals(0x5678, readTwoBytes(littleEndian, 0)) + assertEquals(0x1234_5678, readFourBytes(littleEndian, 0)) + assertEquals(0x0102_0304_1234_5678, readEightBytes(littleEndian, 0)) + + val bigEndian = ByteArray(4) + writeBytesBE(bigEndian, 0, 0x1234_5678) + assertEquals(0x1234_5678, readFourBytesBE(bigEndian, 0)) + + assertEquals(4, countCodeLengthCodes(ByteArray(19))) + assertEquals(19, countCodeLengthCodes(ByteArray(19).also { it[15] = 1 })) + assertTrue(shouldUseStoredBlock(0, 1, 1, 1)) + assertTrue(!shouldUseStoredBlock(-1, 1, 1, 1)) + assertTrue(!shouldUseStoredBlock(0, 2, 1, 2)) + assertTrue(!shouldUseStoredBlock(0, 2, 2, 1)) + assertEquals(117, storedBlockBitLength(10, 0)) + assertEquals(112, storedBlockBitLength(10, 5)) + assertEquals(119, storedBlockBitLength(10, 6)) + assertTrue(shouldUseStoredBlock(0, storedBlockBitLength(10, 6), 119, 119)) + } + + @Test + fun blockWriterAccountsForStoredBlockAlignment() { + val data = ByteArray(26) { (230 + it).toByte() } + val symbols = IntArray(data.size) { 230 + it } + val literalFrequencies = IntArray(288) + for (symbol in symbols) { + literalFrequencies[symbol]++ + } + + val output = ByteArray(256) + writeBlock( + data = data, + output = output, + isFinal = true, + symbols = symbols, + literalFrequencies = literalFrequencies, + distanceFrequencies = IntArray(32), + extraBits = 0, + symbolCount = symbols.size, + blockStart = 0, + blockLength = data.size, + bitPosition = 5, + ) + + val blockType = (output[0].toInt() ushr 5) and 7 + assertEquals(1, blockType, "block type=$blockType") + } + + @Test + fun blockWriterPreservesWholeStreamStoredBlockSavings() { + val prefix = byteArrayOf(253.toByte(), 254.toByte(), 255.toByte()) + val payload = ByteArray(26) { (230 + it).toByte() } + val data = prefix + payload + val prefixSymbols = intArrayOf(253, 254, 255) + val payloadSymbols = IntArray(payload.size) { 230 + it } + + val prefixLiteralFrequencies = IntArray(288) + for (symbol in prefixSymbols) { + prefixLiteralFrequencies[symbol]++ + } + val payloadLiteralFrequencies = IntArray(288) + for (symbol in payloadSymbols) { + payloadLiteralFrequencies[symbol]++ + } + + val output = ByteArray(128) + val prefixEndBitPosition = writeBlock( + data = data, + output = output, + isFinal = false, + symbols = prefixSymbols, + literalFrequencies = prefixLiteralFrequencies, + distanceFrequencies = IntArray(32), + extraBits = 0, + symbolCount = prefixSymbols.size, + blockStart = 0, + blockLength = prefix.size, + bitPosition = 0, + ) + assertEquals(37L, prefixEndBitPosition) + + val finalEndBitPosition = writeBlock( + data = data, + output = output, + isFinal = true, + symbols = payloadSymbols, + literalFrequencies = payloadLiteralFrequencies, + distanceFrequencies = IntArray(32), + extraBits = 0, + symbolCount = payloadSymbols.size, + blockStart = prefix.size, + blockLength = payload.size, + bitPosition = prefixEndBitPosition, + ) + assertEquals(280L, finalEndBitPosition) + + val compressed = output.copyOf(shiftToNextByte(finalEndBitPosition)) + assertEquals(35, compressed.size) + assertContentEquals(data, inflate(compressed, InflateState(validationMode = 2))) + } + + @Test + fun streamingBuffersCoverBoundaryCases() { + val original = byteArrayOf(1, 2) + assertSame(original, appendBytes(original, byteArrayOf(3), 0)) + assertContentEquals(byteArrayOf(3), appendBytes(byteArrayOf(), byteArrayOf(3, 4), 1)) + assertContentEquals(byteArrayOf(1, 2, 3), appendBytes(original, byteArrayOf(3, 4), 1)) + + val unchangedState = DeflateState(inputOffset = STREAM_HISTORY_SIZE) + assertSame(original, trimDeflateInput(original, unchangedState)) + val tooShortState = DeflateState(inputOffset = STREAM_HISTORY_SIZE * 2) + assertSame(original, trimDeflateInput(original, tooShortState)) + + val input = ByteArray(STREAM_HISTORY_SIZE * 3) { it.toByte() } + val state = DeflateState( + inputOffset = STREAM_HISTORY_SIZE * 2 + 7, + inputEndIndex = STREAM_HISTORY_SIZE * 2 + 5, + waitIndex = STREAM_HISTORY_SIZE + 3, + ) + val trimmed = trimDeflateInput(input, state) + assertEquals(STREAM_HISTORY_SIZE * 2, trimmed.size) + assertEquals(STREAM_HISTORY_SIZE + 7, state.inputOffset) + assertEquals(STREAM_HISTORY_SIZE + 5, state.inputEndIndex) + assertEquals(3, state.waitIndex) + + val zeroEndState = DeflateState( + inputOffset = STREAM_HISTORY_SIZE * 2, + inputEndIndex = 0, + ) + trimDeflateInput(input, zeroEndState) + assertEquals(0, zeroEndState.inputEndIndex) + + val history = byteArrayOf(1, 2) + assertSame(history, updateHistory(history, byteArrayOf())) + assertContentEquals(byteArrayOf(1, 2, 3), updateHistory(history, byteArrayOf(3))) + assertContentEquals( + ByteArray(STREAM_HISTORY_SIZE) { 4 }, + updateHistory(history, ByteArray(STREAM_HISTORY_SIZE + 1) { 4 }), + ) + } + + @Test + fun incompleteStreamingInflateRestoresState() { + val state = InflateState(validationMode = 7, inputBitPosition = 3, outputOffset = 4) + + assertNull(inflateStreamChunk(byteArrayOf(), state, byteArrayOf(), false, null)) + assertEquals(3, state.inputBitPosition) + assertEquals(4, state.outputOffset) + assertEquals(7, state.validationMode) + + val invalidBlock = assertFailsWith { + inflateStreamChunk(byteArrayOf(6), InflateState(), byteArrayOf(), false, null) + } + assertEquals(FlateErrorCode.INVALID_BLOCK_TYPE, invalidBlock.code) + val exhausted = assertFailsWith { + inflateStreamChunk(byteArrayOf(), InflateState(validationMode = 2), byteArrayOf(), true, null) + } + assertEquals(FlateErrorCode.UNEXPECTED_EOF, exhausted.code) + } + + @Test + fun gzipHeaderParsingCoversEveryOptionalField() { + val options = CompressionGzip( + level = 9, + filename = "file", + mtime = Instant.fromEpochSeconds(0), + comment = "comment", + extraFields = mapOf("AB" to byteArrayOf(1, 2)), + includeHeaderCrc = true, + ) + val header = ByteArray(getGzipHeaderSize(options)) + writeGzipHeader(header, options) + assertEquals(header.size, writeGzipStart(header)) + + assertFailsWith { writeGzipStart(ByteArray(9)) } + for (index in 0..2) { + val invalid = ByteArray(10) + invalid[0] = 31 + invalid[1] = 139.toByte() + invalid[2] = 8 + invalid[index] = 0 + assertFailsWith { writeGzipStart(invalid) } + } + + val missingExtraLength = header.copyOf(11).also { it[3] = 4 } + assertFailsWith { writeGzipStart(missingExtraLength) } + val missingHeaderCrc = header.copyOf(11).also { it[3] = 2 } + assertFailsWith { writeGzipStart(missingHeaderCrc) } + + val badCrc = header.copyOf().also { it[it.lastIndex] = (it.last() + 1).toByte() } + assertFailsWith { writeGzipStart(badCrc) } + assertTrue(buildExtraFields(mapOf("AB" to byteArrayOf(1))).isNotEmpty()) + assertFailsWith { buildExtraFields(mapOf("A" to byteArrayOf())) } + assertFailsWith { buildExtraFields(mapOf("A\u0000" to byteArrayOf())) } + assertFailsWith { buildExtraFields(mapOf("AB" to ByteArray(65_536))) } + assertFailsWith { + buildExtraFields(mapOf("AB" to ByteArray(40_000), "CD" to ByteArray(40_000))) + } + } + + @Test + fun gzipMemberHelpersCoverDefaultsAndTrailerReads() { + val original = "member".encodeToByteArray() + val compressed = KFlate.compress(original, CompressionGzip()) + val member = processSingleGzipMember(compressed, 0) + + assertContentEquals(original, member.decompressed) + assertEquals(compressed.size, member.bytesConsumed) + assertEquals(original.size.toLong(), getGzipUncompressedSize(compressed)) + assertFailsWith { processSingleGzipMember(ByteArray(19), 0) } + assertFailsWith { + KFlate.decompress(compressed.copyOf(compressed.size - 7), DecompressionGzip()) + } + } + + @Test + fun zlibHeadersCoverCompressionFlagsAndValidation() { + for (level in listOf(0, 1, 6, 9)) { + val header = ByteArray(2) + writeZlibHeader(header, com.rafambn.kflate.compression.Zlib(level = level)) + assertEquals(2, writeZlibStart(header, false)) + } + + assertFailsWith { writeZlibStart(byteArrayOf(), false) } + assertFailsWith { writeZlibStart(byteArrayOf(0, 0), false) } + assertFailsWith { writeZlibStart(byteArrayOf(0x88.toByte(), 0), false) } + assertFailsWith { writeZlibStart(byteArrayOf(0x78, 0), false) } + + val dictionary = "dictionary".encodeToByteArray() + val header = ByteArray(6) + writeZlibHeader(header, com.rafambn.kflate.compression.Zlib(dictionary = dictionary)) + assertEquals(6, writeZlibStart(header, true, dictionary)) + assertFailsWith { writeZlibStart(header.copyOf(5), true, dictionary) } + assertFailsWith { writeZlibStart(header, true, null) } + assertFailsWith { writeZlibStart(header, true, byteArrayOf(1)) } + assertFailsWith { writeZlibStart(header, false) } + } + + @Test + fun exposesEveryErrorCodeNumber() { + assertEquals((0..11).toList(), FlateErrorCode.entries.map { it.code }) + } +} diff --git a/kflate/src/jvmTest/kotlin/MatchCopyTest.kt b/kflate/src/jvmTest/kotlin/MatchCopyTest.kt new file mode 100644 index 0000000..1f421e9 --- /dev/null +++ b/kflate/src/jvmTest/kotlin/MatchCopyTest.kt @@ -0,0 +1,47 @@ +package com.rafambn.kflate.algorithm + +import kotlin.test.Test +import kotlin.test.assertContentEquals + +class MatchCopyTest { + + @Test + fun copiesNonOverlappingMatchInOneBulkOperation() { + val buffer = ByteArray(16) + buffer[0] = 1 + buffer[1] = 2 + buffer[2] = 3 + buffer[3] = 4 + buffer[4] = 5 + + copyMatch(buffer, destinationOffset = 8, distance = 8, length = 5) + + assertContentEquals(byteArrayOf(1, 2, 3, 4, 5), buffer.copyOfRange(8, 13)) + assertContentEquals(byteArrayOf(0, 0, 0), buffer.copyOfRange(13, 16)) + } + + @Test + fun expandsOverlappingMatchWithoutReadingUnwrittenBytes() { + val buffer = ByteArray(16) + buffer[0] = 1 + buffer[1] = 2 + buffer[2] = 3 + + copyMatch(buffer, destinationOffset = 3, distance = 3, length = 13) + + assertContentEquals( + byteArrayOf(1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1), + buffer, + ) + } + + @Test + fun fillsSingleByteBackReferenceAcrossOverlap() { + val buffer = ByteArray(12) + buffer[0] = 7 + + copyMatch(buffer, destinationOffset = 1, distance = 1, length = 11) + + assertContentEquals(ByteArray(12) { 7 }, buffer) + } +} diff --git a/kflate/src/jvmTest/kotlin/PerformanceTest.kt b/kflate/src/jvmTest/kotlin/PerformanceTest.kt deleted file mode 100644 index fec93c7..0000000 --- a/kflate/src/jvmTest/kotlin/PerformanceTest.kt +++ /dev/null @@ -1,193 +0,0 @@ - -package com.rafambn.kflate - -import org.junit.Test -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.util.zip.* -import kotlin.system.measureTimeMillis - -class PerformanceTest { - - private val testFiles = listOf( - "model3D", - "text", - "Rainier.bmp", - "Maltese.bmp", - "Sunrise.bmp", - "simpleText", - ) - - private fun readResourceFile(fileName: String): ByteArray { - return javaClass.classLoader.getResourceAsStream(fileName)?.readBytes() - ?: throw IllegalArgumentException("Resource file not found: $fileName") - } - - - // FLATE TESTS - @Test - fun testFlateCompressPerformance() { - println("Flate Compress Performance") - println("File | KFlate Time | JVM Time") - println("--- | --- | ---") - for (fileName in testFiles) { - val originalData = readResourceFile(fileName) - - val kflateTime = measureTimeMillis { - KFlate.compress(originalData, RAW()) - } - - val jvmTime = measureTimeMillis { - val deflater = Deflater(6, true) - val outputStream = ByteArrayOutputStream() - val deflaterStream = DeflaterOutputStream(outputStream, deflater) - deflaterStream.write(originalData) - deflaterStream.finish() - deflaterStream.close() - outputStream.toByteArray() - } - println("$fileName | ${kflateTime}ms | ${jvmTime}ms") - } - } - - @Test - fun testFlateDecompressPerformance() { - println("\nFlate Decompress Performance") - println("File | KFlate Time | JVM Time") - println("--- | --- | ---") - for (fileName in testFiles) { - val originalData = readResourceFile(fileName) - val deflater = Deflater(6, true) - val outputStream = ByteArrayOutputStream() - val deflaterStream = DeflaterOutputStream(outputStream, deflater) - deflaterStream.write(originalData) - deflaterStream.finish() - deflaterStream.close() - val compressedData = outputStream.toByteArray() - - val kflateTime = measureTimeMillis { - KFlate.decompress(compressedData, Raw()) - } - - val jvmTime = measureTimeMillis { - val inflater = Inflater(true) - val inputStream = ByteArrayInputStream(compressedData) - val inflaterStream = InflaterInputStream(inputStream, inflater) - val decompressedStream = ByteArrayOutputStream() - inflaterStream.copyTo(decompressedStream) - inflater.end() - } - println("$fileName | ${kflateTime}ms | ${jvmTime}ms") - } - } - - // GZIP TESTS - @Test - fun testGzipCompressPerformance() { - println("\nGzip Compress Performance") - println("File | KFlate Time | JVM Time") - println("--- | --- | ---") - for (fileName in testFiles) { - val originalData = readResourceFile(fileName) - - val kflateTime = measureTimeMillis { - KFlate.compress(originalData, GZIP()) - } - - val jvmTime = measureTimeMillis { - val outputStream = ByteArrayOutputStream() - val gzipOutputStream = GZIPOutputStream(outputStream) - gzipOutputStream.write(originalData) - gzipOutputStream.finish() - gzipOutputStream.close() - outputStream.toByteArray() - } - println("$fileName | ${kflateTime}ms | ${jvmTime}ms") - } - } - - @Test - fun testGzipDecompressPerformance() { - println("\nGzip Decompress Performance") - println("File | KFlate Time | JVM Time") - println("--- | --- | ---") - for (fileName in testFiles) { - val originalData = readResourceFile(fileName) - val outputStream = ByteArrayOutputStream() - val gzipOutputStream = GZIPOutputStream(outputStream) - gzipOutputStream.write(originalData) - gzipOutputStream.finish() - gzipOutputStream.close() - val compressedData = outputStream.toByteArray() - - val kflateTime = measureTimeMillis { - KFlate.decompress(compressedData, Gzip()) - } - - val jvmTime = measureTimeMillis { - val inputStream = ByteArrayInputStream(compressedData) - val gzipInputStream = GZIPInputStream(inputStream) - val decompressedStream = ByteArrayOutputStream() - gzipInputStream.copyTo(decompressedStream) - } - println("$fileName | ${kflateTime}ms | ${jvmTime}ms") - } - } - - // ZLIBTESTS - @Test - fun testZlibCompressPerformance() { - println("\nZlib Compress Performance") - println("File | KFlate Time | JVM Time") - println("--- | --- | ---") - for (fileName in testFiles) { - val originalData = readResourceFile(fileName) - - val kflateTime = measureTimeMillis { - KFlate.compress(originalData, ZLIB()) - } - - val jvmTime = measureTimeMillis { - val deflater = Deflater(Deflater.DEFAULT_COMPRESSION) - val outputStream = ByteArrayOutputStream() - val deflaterStream = DeflaterOutputStream(outputStream, deflater) - deflaterStream.write(originalData) - deflaterStream.finish() - deflaterStream.close() - outputStream.toByteArray() - } - println("$fileName | ${kflateTime}ms | ${jvmTime}ms") - } - } - - @Test - fun testZlibDecompressPerformance() { - println("\nZlib Decompress Performance") - println("File | KFlate Time | JVM Time") - println("--- | --- | ---") - for (fileName in testFiles) { - val originalData = readResourceFile(fileName) - val deflater = Deflater(Deflater.DEFAULT_COMPRESSION) - val outputStream = ByteArrayOutputStream() - val deflaterStream = DeflaterOutputStream(outputStream, deflater) - deflaterStream.write(originalData) - deflaterStream.finish() - deflaterStream.close() - val compressedData = outputStream.toByteArray() - - val kflateTime = measureTimeMillis { - KFlate.decompress(compressedData, Zlib()) - } - - val jvmTime = measureTimeMillis { - val inflater = Inflater() - val inputStream = ByteArrayInputStream(compressedData) - val inflaterStream = InflaterInputStream(inputStream, inflater) - val decompressedStream = ByteArrayOutputStream() - inflaterStream.copyTo(decompressedStream) - inflater.end() - } - println("$fileName | ${kflateTime}ms | ${jvmTime}ms") - } - } -} diff --git a/kflate/src/jvmTest/kotlin/ReservedLiteralLengthCodeTest.kt b/kflate/src/jvmTest/kotlin/ReservedLiteralLengthCodeTest.kt new file mode 100644 index 0000000..7063de7 --- /dev/null +++ b/kflate/src/jvmTest/kotlin/ReservedLiteralLengthCodeTest.kt @@ -0,0 +1,26 @@ +package com.rafambn.kflate + +import com.rafambn.kflate.decompression.Raw +import com.rafambn.kflate.error.FlateError +import com.rafambn.kflate.error.FlateErrorCode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ReservedLiteralLengthCodeTest { + @Test + fun reservedLiteralLengthCodesAreRejected() { + val compressedBySymbol = listOf( + 286 to byteArrayOf(0x1b, 0x03, 0x00), + 287 to byteArrayOf(0x1b, 0x07, 0x00), + ) + + for ((symbol, compressed) in compressedBySymbol) { + val error = assertFailsWith("Reserved literal/length symbol $symbol must be rejected") { + KFlate.decompress(compressed, Raw()) + } + + assertEquals(FlateErrorCode.INVALID_LENGTH_LITERAL, error.code) + } + } +} diff --git a/kflate/src/jvmTest/kotlin/SizeTest.kt b/kflate/src/jvmTest/kotlin/SizeTest.kt deleted file mode 100644 index e9d4c49..0000000 --- a/kflate/src/jvmTest/kotlin/SizeTest.kt +++ /dev/null @@ -1,88 +0,0 @@ - -package com.rafambn.kflate - -import org.junit.Test -import java.io.ByteArrayOutputStream -import java.util.zip.Deflater -import java.util.zip.DeflaterOutputStream -import java.util.zip.GZIPOutputStream - -class SizeTest { - - private val testFiles = listOf( - "model3D", - "text", - "Rainier.bmp", - "Maltese.bmp", - "Sunrise.bmp", - "simpleText", - ) - - private fun readResourceFile(fileName: String): ByteArray { - return javaClass.classLoader.getResourceAsStream(fileName)?.readBytes() - ?: throw IllegalArgumentException("Resource file not found: $fileName") - } - - - @Test - fun testFlateSize() { - println("Flate Size Test") - for (fileName in testFiles) { - val originalData = readResourceFile(fileName) - val compressedData = KFlate.compress(originalData, RAW()) - - val deflater = Deflater(6, true) - val outputStream = ByteArrayOutputStream() - val deflaterStream = DeflaterOutputStream(outputStream, deflater) - - deflaterStream.write(originalData) - deflaterStream.finish() - deflaterStream.close() - - val expectedCompressedSize = outputStream.toByteArray().size - - println("File: $fileName, KFlate: ${compressedData.size}, JVM: $expectedCompressedSize") - } - } - - @Test - fun testGzipSize() { - println("Gzip Size Test") - for (fileName in testFiles) { - val originalData = readResourceFile(fileName) - val compressedData = KFlate.compress(originalData, GZIP()) - - val outputStream = ByteArrayOutputStream() - val gzipOutputStream = GZIPOutputStream(outputStream) - - gzipOutputStream.write(originalData) - gzipOutputStream.finish() - gzipOutputStream.close() - - val expectedCompressedSize = outputStream.toByteArray().size - - println("File: $fileName, KFlate: ${compressedData.size}, JVM: $expectedCompressedSize") - } - } - - @Test - fun testZlibSize() { - println("Zlib Size Test") - for (fileName in testFiles) { - val originalData = readResourceFile(fileName) - val compressedData = KFlate.compress(originalData, ZLIB()) - - val deflater = Deflater(Deflater.DEFAULT_COMPRESSION) - val outputStream = ByteArrayOutputStream() - val deflaterStream = DeflaterOutputStream(outputStream, deflater) - - deflaterStream.write(originalData) - deflaterStream.finish() - deflaterStream.close() - - val expectedCompressedSize = outputStream.toByteArray().size - - println("File: $fileName, KFlate: ${compressedData.size}, JVM: $expectedCompressedSize") - } - } -} diff --git a/kflate/src/jvmTest/kotlin/StreamingApiTest.kt b/kflate/src/jvmTest/kotlin/StreamingApiTest.kt index 9bafa9d..bc04ad0 100644 --- a/kflate/src/jvmTest/kotlin/StreamingApiTest.kt +++ b/kflate/src/jvmTest/kotlin/StreamingApiTest.kt @@ -1,5 +1,13 @@ package com.rafambn.kflate +import com.rafambn.kflate.compression.CompressionType +import com.rafambn.kflate.compression.Gzip as CompressionGzip +import com.rafambn.kflate.compression.Raw as CompressionRaw +import com.rafambn.kflate.compression.Zlib as CompressionZlib +import com.rafambn.kflate.decompression.DecompressionType +import com.rafambn.kflate.decompression.Gzip as DecompressionGzip +import com.rafambn.kflate.decompression.Raw as DecompressionRaw +import com.rafambn.kflate.decompression.Zlib as DecompressionZlib import kotlin.coroutines.Continuation import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.startCoroutine @@ -14,9 +22,6 @@ class StreamingApiTest { private val testFiles = listOf( "model3D", "text", - "Rainier.bmp", - "Maltese.bmp", - "Sunrise.bmp", "simpleText", ) @@ -58,8 +63,8 @@ class StreamingApiTest { fun testRawSelfRoundtrip() { for (fileName in testFiles) { val original = readResourceFile(fileName) - val compressed = streamCompress(original, RAW()) - val decompressed = streamDecompress(compressed, Raw()) + val compressed = streamCompress(original, CompressionRaw()) + val decompressed = streamDecompress(compressed, DecompressionRaw()) assertContentEquals(original, decompressed, "RAW roundtrip failed on: $fileName") } } @@ -68,8 +73,8 @@ class StreamingApiTest { fun testGzipSelfRoundtrip() { for (fileName in testFiles) { val original = readResourceFile(fileName) - val compressed = streamCompress(original, GZIP()) - val decompressed = streamDecompress(compressed, Gzip()) + val compressed = streamCompress(original, CompressionGzip()) + val decompressed = streamDecompress(compressed, DecompressionGzip()) assertContentEquals(original, decompressed, "GZIP roundtrip failed on: $fileName") } } @@ -78,8 +83,8 @@ class StreamingApiTest { fun testZlibSelfRoundtrip() { for (fileName in testFiles) { val original = readResourceFile(fileName) - val compressed = streamCompress(original, ZLIB()) - val decompressed = streamDecompress(compressed, Zlib()) + val compressed = streamCompress(original, CompressionZlib()) + val decompressed = streamDecompress(compressed, DecompressionZlib()) assertContentEquals(original, decompressed, "ZLIB roundtrip failed on: $fileName") } } @@ -90,33 +95,21 @@ class StreamingApiTest { fun testCompressionLevels() { val original = readResourceFile("text") for (level in 0..9) { - val compressed = streamCompress(original, ZLIB(level = level)) - val decompressed = streamDecompress(compressed, Zlib()) + val compressed = streamCompress(original, CompressionZlib(level = level)) + val decompressed = streamDecompress(compressed, DecompressionZlib()) assertContentEquals(original, decompressed, "Level $level roundtrip failed") } } - // 3. MEMORY LEVELS - - @Test - fun testMemoryLevels() { - val original = readResourceFile("text") - for (mem in listOf(1, 4, 8, 12)) { - val compressed = streamCompress(original, ZLIB(mem = mem)) - val decompressed = streamDecompress(compressed, Zlib()) - assertContentEquals(original, decompressed, "Mem $mem roundtrip failed") - } - } - - // 4. DICTIONARY SUPPORT + // 3. DICTIONARY SUPPORT @Test fun testRawDictionaryRoundtrip() { val dictionary = "the quick brown fox jumps over the lazy dog".encodeToByteArray() val original = readResourceFile("simpleText") - val compressed = streamCompress(original, RAW(dictionary = dictionary)) - val decompressed = streamDecompress(compressed, Raw(dictionary = dictionary)) + val compressed = streamCompress(original, CompressionRaw(dictionary = dictionary)) + val decompressed = streamDecompress(compressed, DecompressionRaw(dictionary = dictionary)) assertContentEquals(original, decompressed) } @@ -125,8 +118,8 @@ class StreamingApiTest { val dictionary = "the quick brown fox jumps over the lazy dog".encodeToByteArray() val original = readResourceFile("simpleText") - val compressed = streamCompress(original, ZLIB(dictionary = dictionary)) - val decompressed = streamDecompress(compressed, Zlib(dictionary = dictionary)) + val compressed = streamCompress(original, CompressionZlib(dictionary = dictionary)) + val decompressed = streamDecompress(compressed, DecompressionZlib(dictionary = dictionary)) assertContentEquals(original, decompressed) } @@ -135,47 +128,47 @@ class StreamingApiTest { val dictionary = "the quick brown fox jumps over the lazy dog".encodeToByteArray() val original = readResourceFile("simpleText") - val compressed = streamCompress(original, ZLIB(dictionary = dictionary)) + val compressed = streamCompress(original, CompressionZlib(dictionary = dictionary)) assertFailsWith { - streamDecompress(compressed, Zlib()) + streamDecompress(compressed, DecompressionZlib()) } } - // 5. BLOCKING VS STREAMING EQUIVALENCE + // 4. BLOCKING VS STREAMING EQUIVALENCE @Test fun testBlockingCompressStreamingDecompress() { val original = readResourceFile("text") - val rawCompressed = KFlate.compress(original, RAW()) - assertContentEquals(original, streamDecompress(rawCompressed, Raw()), "RAW blocking->streaming failed") + val rawCompressed = KFlate.compress(original, CompressionRaw()) + assertContentEquals(original, streamDecompress(rawCompressed, DecompressionRaw()), "RAW blocking->streaming failed") - val gzipCompressed = KFlate.compress(original, GZIP()) - assertContentEquals(original, streamDecompress(gzipCompressed, Gzip()), "GZIP blocking->streaming failed") + val gzipCompressed = KFlate.compress(original, CompressionGzip()) + assertContentEquals(original, streamDecompress(gzipCompressed, DecompressionGzip()), "GZIP blocking->streaming failed") - val zlibCompressed = KFlate.compress(original, ZLIB()) - assertContentEquals(original, streamDecompress(zlibCompressed, Zlib()), "ZLIB blocking->streaming failed") + val zlibCompressed = KFlate.compress(original, CompressionZlib()) + assertContentEquals(original, streamDecompress(zlibCompressed, DecompressionZlib()), "ZLIB blocking->streaming failed") } @Test fun testStreamingCompressBlockingDecompress() { val original = readResourceFile("text") - val rawCompressed = streamCompress(original, RAW()) - assertContentEquals(original, KFlate.decompress(rawCompressed, Raw()), "RAW streaming->blocking failed") + val rawCompressed = streamCompress(original, CompressionRaw()) + assertContentEquals(original, KFlate.decompress(rawCompressed, DecompressionRaw()), "RAW streaming->blocking failed") - val gzipCompressed = streamCompress(original, GZIP()) - assertContentEquals(original, KFlate.decompress(gzipCompressed, Gzip()), "GZIP streaming->blocking failed") + val gzipCompressed = streamCompress(original, CompressionGzip()) + assertContentEquals(original, KFlate.decompress(gzipCompressed, DecompressionGzip()), "GZIP streaming->blocking failed") - val zlibCompressed = streamCompress(original, ZLIB()) - assertContentEquals(original, KFlate.decompress(zlibCompressed, Zlib()), "ZLIB streaming->blocking failed") + val zlibCompressed = streamCompress(original, CompressionZlib()) + assertContentEquals(original, KFlate.decompress(zlibCompressed, DecompressionZlib()), "ZLIB streaming->blocking failed") } - // 6. EDGE CASES + // 5. EDGE CASES @Test fun testEmptyInput() { - for ((compType, decType) in listOf(RAW() to Raw(), GZIP() to Gzip(), ZLIB() to Zlib())) { + for ((compType, decType) in listOf(CompressionRaw() to DecompressionRaw(), CompressionGzip() to DecompressionGzip(), CompressionZlib() to DecompressionZlib())) { val compressed = streamCompress(ByteArray(0), compType) val decompressed = streamDecompress(compressed, decType) assertContentEquals(ByteArray(0), decompressed, "Empty input failed for ${compType::class.simpleName}") @@ -185,7 +178,7 @@ class StreamingApiTest { @Test fun testSingleByte() { val original = byteArrayOf(42) - for ((compType, decType) in listOf(RAW() to Raw(), GZIP() to Gzip(), ZLIB() to Zlib())) { + for ((compType, decType) in listOf(CompressionRaw() to DecompressionRaw(), CompressionGzip() to DecompressionGzip(), CompressionZlib() to DecompressionZlib())) { val compressed = streamCompress(original, compType) val decompressed = streamDecompress(compressed, decType) assertContentEquals(original, decompressed, "Single byte failed for ${compType::class.simpleName}") @@ -195,8 +188,8 @@ class StreamingApiTest { @Test fun testHighlyCompressible() { val original = ByteArray(65536) - val compressed = streamCompress(original, ZLIB()) - val decompressed = streamDecompress(compressed, Zlib()) + val compressed = streamCompress(original, CompressionZlib()) + val decompressed = streamDecompress(compressed, DecompressionZlib()) assertContentEquals(original, decompressed) assert(compressed.size < original.size / 10) { "All-zeros should compress significantly" } } @@ -205,8 +198,8 @@ class StreamingApiTest { fun testIncompressible() { val random = java.util.Random(12345) val original = ByteArray(65536) { random.nextInt(256).toByte() } - val compressed = streamCompress(original, ZLIB()) - val decompressed = streamDecompress(compressed, Zlib()) + val compressed = streamCompress(original, CompressionZlib()) + val decompressed = streamDecompress(compressed, DecompressionZlib()) assertContentEquals(original, decompressed) } @@ -217,74 +210,74 @@ class StreamingApiTest { for (i in original.indices) { original[i] = pattern[i % pattern.size] } - val compressed = streamCompress(original, ZLIB()) - val decompressed = streamDecompress(compressed, Zlib()) + val compressed = streamCompress(original, CompressionZlib()) + val decompressed = streamDecompress(compressed, DecompressionZlib()) assertContentEquals(original, decompressed) assert(compressed.size < original.size / 10) { "Repeated pattern should compress significantly" } } - // 7. GZIP OPTIONAL FIELDS (STREAMING) + // 6. GZIP OPTIONAL FIELDS (STREAMING) @Test fun testGzipOptionalFieldsStreaming() { val original = "gzip optional fields test data".encodeToByteArray() val compressed = streamCompress( original, - GZIP( + CompressionGzip( filename = "test.txt", comment = "a comment", extraFields = mapOf("XX" to byteArrayOf(1, 2, 3)), includeHeaderCrc = true ) ) - val decompressed = streamDecompress(compressed, Gzip()) + val decompressed = streamDecompress(compressed, DecompressionGzip()) assertContentEquals(original, decompressed) } @Test fun testGzipFilenameOnlyStreaming() { val original = readResourceFile("simpleText") - val compressed = streamCompress(original, GZIP(filename = "simpleText.txt")) - val decompressed = streamDecompress(compressed, Gzip()) + val compressed = streamCompress(original, CompressionGzip(filename = "simpleText.txt")) + val decompressed = streamDecompress(compressed, DecompressionGzip()) assertContentEquals(original, decompressed) } @Test fun testGzipHeaderCrcOnlyStreaming() { val original = readResourceFile("simpleText") - val compressed = streamCompress(original, GZIP(includeHeaderCrc = true)) - val decompressed = streamDecompress(compressed, Gzip()) + val compressed = streamCompress(original, CompressionGzip(includeHeaderCrc = true)) + val decompressed = streamDecompress(compressed, DecompressionGzip()) assertContentEquals(original, decompressed) } - // 8. ERROR HANDLING (STREAMING) + // 7. ERROR HANDLING (STREAMING) @Test fun testTruncatedStream() { val original = readResourceFile("text") - val compressed = streamCompress(original, ZLIB()) + val compressed = streamCompress(original, CompressionZlib()) val truncated = compressed.copyOf(compressed.size / 2) assertFailsWith { - streamDecompress(truncated, Zlib()) + streamDecompress(truncated, DecompressionZlib()) } } @Test fun testCorruptedCompressedData() { val original = readResourceFile("simpleText") - val compressed = streamCompress(original, ZLIB()).clone() + val compressed = streamCompress(original, CompressionZlib()).clone() compressed[compressed.size / 2] = (compressed[compressed.size / 2].toInt() xor 0xFF).toByte() assertFailsWith { - streamDecompress(compressed, Zlib()) + streamDecompress(compressed, DecompressionZlib()) } } @Test fun testWrongFormat() { val original = readResourceFile("simpleText") - val gzipCompressed = streamCompress(original, GZIP()) + val gzipCompressed = streamCompress(original, CompressionGzip()) assertFailsWith { - streamDecompress(gzipCompressed, Zlib()) + streamDecompress(gzipCompressed, DecompressionZlib()) } } } diff --git a/kflate/src/jvmTest/kotlin/StreamingEdgeCasesCoverageTest.kt b/kflate/src/jvmTest/kotlin/StreamingEdgeCasesCoverageTest.kt new file mode 100644 index 0000000..bb1f046 --- /dev/null +++ b/kflate/src/jvmTest/kotlin/StreamingEdgeCasesCoverageTest.kt @@ -0,0 +1,143 @@ +package com.rafambn.kflate + +import com.rafambn.kflate.compression.Gzip as CompressionGzip +import com.rafambn.kflate.compression.Raw as CompressionRaw +import com.rafambn.kflate.compression.Zlib as CompressionZlib +import com.rafambn.kflate.decompression.DecompressionType +import com.rafambn.kflate.decompression.Gzip as DecompressionGzip +import com.rafambn.kflate.decompression.Raw as DecompressionRaw +import com.rafambn.kflate.decompression.Zlib as DecompressionZlib +import com.rafambn.kflate.error.FlateError +import com.rafambn.kflate.error.FlateErrorCode +import kotlinx.io.Buffer +import kotlinx.io.RawSource +import kotlinx.io.readByteArray +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class StreamingEdgeCasesCoverageTest { + + @Test + fun oneByteChunksRoundTripEveryFormat() { + val original = "one byte source".repeat(100).encodeToByteArray() + val formats = listOf( + KFlate.compress(original, CompressionRaw()) to DecompressionRaw(), + KFlate.compress(original, CompressionGzip()) to DecompressionGzip(), + KFlate.compress(original, CompressionZlib()) to DecompressionZlib(), + ) + + for ((compressed, type) in formats) { + val output = Buffer() + KFlate.decompress(type, ChunkedRawSource(compressed, 1), output) + assertContentEquals(original, output.readByteArray()) + } + } + + @Test + fun emptyStreamsFailForEveryFormat() { + assertStreamError(byteArrayOf(), DecompressionRaw(), FlateErrorCode.UNEXPECTED_EOF) + assertStreamError(byteArrayOf(), DecompressionGzip(), FlateErrorCode.UNEXPECTED_EOF) + assertStreamError(byteArrayOf(), DecompressionZlib(), FlateErrorCode.UNEXPECTED_EOF) + } + + @Test + fun streamingGzipRejectsPartialSectionsAndTrailingGarbage() { + assertStreamError(ByteArray(10), DecompressionGzip(), FlateErrorCode.INVALID_HEADER) + val header = ByteArray(10).also { + it[0] = 31 + it[1] = 139.toByte() + it[2] = 8 + } + assertStreamError(header, DecompressionGzip(), FlateErrorCode.UNEXPECTED_EOF) + assertStreamError(header + byteArrayOf(2), DecompressionGzip(), FlateErrorCode.UNEXPECTED_EOF) + + val compressed = KFlate.compress("gzip stream".encodeToByteArray(), CompressionGzip()) + assertStreamError(compressed.copyOf(compressed.size - 1), DecompressionGzip(), FlateErrorCode.UNEXPECTED_EOF) + assertStreamError( + compressed.copyOf().also { it[it.size - 8] = (it[it.size - 8] + 1).toByte() }, + DecompressionGzip(), + FlateErrorCode.CRC_MISMATCH, + ) + assertStreamError( + compressed.copyOf().also { it[it.size - 4] = (it[it.size - 4] + 1).toByte() }, + DecompressionGzip(), + FlateErrorCode.ISIZE_MISMATCH, + ) + assertStreamError(compressed + ByteArray(10), DecompressionGzip(), FlateErrorCode.TRAILING_GARBAGE) + assertStreamError( + compressed + byteArrayOf(31, 139.toByte(), 8, 0, 0), + DecompressionGzip(), + FlateErrorCode.TRAILING_GARBAGE, + chunkSize = 1, + ) + } + + @Test + fun streamingZlibRejectsPartialSectionsAndBadChecksum() { + assertStreamError(byteArrayOf(0x78), DecompressionZlib(), FlateErrorCode.UNEXPECTED_EOF, chunkSize = 1) + assertStreamError(byteArrayOf(0x78, 0x9c.toByte()), DecompressionZlib(), FlateErrorCode.UNEXPECTED_EOF) + assertStreamError(byteArrayOf(0x78, 0x9c.toByte(), 2), DecompressionZlib(), FlateErrorCode.UNEXPECTED_EOF) + + val compressed = KFlate.compress("zlib stream".encodeToByteArray(), CompressionZlib()) + assertStreamError(compressed.copyOf(compressed.size - 1), DecompressionZlib(), FlateErrorCode.UNEXPECTED_EOF) + assertStreamError( + compressed.copyOf().also { it[it.lastIndex] = (it.last() + 1).toByte() }, + DecompressionZlib(), + FlateErrorCode.CHECKSUM_MISMATCH, + ) + } + + @Test + fun streamingRawRejectsAnIncompleteDynamicBlock() { + assertStreamError(byteArrayOf(2), DecompressionRaw(), FlateErrorCode.UNEXPECTED_EOF) + } + + @Test + fun blockingGzipChecksEveryMagicByte() { + for (index in 0..2) { + val invalid = ByteArray(20) + invalid[0] = 31 + invalid[1] = 139.toByte() + invalid[2] = 8 + invalid[index] = 0 + + val error = assertFailsWith { + KFlate.decompress(invalid, DecompressionGzip()) + } + assertEquals(FlateErrorCode.TRAILING_GARBAGE, error.code) + } + } + + private fun assertStreamError( + data: ByteArray, + type: DecompressionType, + expected: FlateErrorCode, + chunkSize: Int = Int.MAX_VALUE, + ) { + val error = assertFailsWith { + KFlate.decompress(type, ChunkedRawSource(data, chunkSize), Buffer()) + } + assertEquals(expected, error.code) + } + + private class ChunkedRawSource( + private val data: ByteArray, + private val chunkSize: Int, + ) : RawSource { + private var offset = 0 + + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { + require(byteCount > 0) + if (offset == data.size) return -1 + + val count = minOf(chunkSize, byteCount.toInt(), data.size - offset) + sink.write(data, offset, offset + count) + offset += count + return count.toLong() + } + + override fun close() = Unit + } +} diff --git a/kflate/src/jvmTest/kotlin/StreamingInflateFragmentationTest.kt b/kflate/src/jvmTest/kotlin/StreamingInflateFragmentationTest.kt new file mode 100644 index 0000000..9224d0c --- /dev/null +++ b/kflate/src/jvmTest/kotlin/StreamingInflateFragmentationTest.kt @@ -0,0 +1,58 @@ +package com.rafambn.kflate + +import com.rafambn.kflate.decompression.Raw +import kotlinx.io.Buffer +import kotlinx.io.RawSource +import kotlinx.io.readByteArray +import java.util.Random +import java.util.zip.Deflater +import kotlin.test.Test +import kotlin.test.assertContentEquals + +class StreamingInflateFragmentationTest { + @Test + fun acceptsDynamicHuffmanCodesSplitAcrossSourceChunks() { + val random = Random(0) + val original = ByteArray(1_000 + random.nextInt(10_000)) { + random.nextInt(8).toByte() + } + val compressed = deflateRaw(original) + val output = Buffer() + + assertContentEquals(original, KFlate.decompress(compressed, Raw())) + KFlate.decompress(Raw(), ChunkedRawSource(compressed, chunkSize = 23), output) + + assertContentEquals(original, output.readByteArray()) + } + + private fun deflateRaw(input: ByteArray): ByteArray { + val deflater = Deflater(6, true) + return try { + val output = ByteArray(input.size * 2) + deflater.setInput(input) + deflater.finish() + output.copyOf(deflater.deflate(output)) + } finally { + deflater.end() + } + } + + private class ChunkedRawSource( + private val data: ByteArray, + private val chunkSize: Int, + ) : RawSource { + private var offset = 0 + + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { + require(byteCount > 0) + if (offset == data.size) return -1 + + val count = minOf(chunkSize, byteCount.toInt(), data.size - offset) + sink.write(data.copyOfRange(offset, offset + count)) + offset += count + return count.toLong() + } + + override fun close() = Unit + } +} diff --git a/kflate/src/jvmTest/kotlin/StreamingValidityTest.kt b/kflate/src/jvmTest/kotlin/StreamingValidityTest.kt index d229625..386cbdf 100644 --- a/kflate/src/jvmTest/kotlin/StreamingValidityTest.kt +++ b/kflate/src/jvmTest/kotlin/StreamingValidityTest.kt @@ -1,5 +1,13 @@ package com.rafambn.kflate +import com.rafambn.kflate.compression.CompressionType +import com.rafambn.kflate.compression.Gzip as CompressionGzip +import com.rafambn.kflate.compression.Raw as CompressionRaw +import com.rafambn.kflate.compression.Zlib as CompressionZlib +import com.rafambn.kflate.decompression.DecompressionType +import com.rafambn.kflate.decompression.Gzip as DecompressionGzip +import com.rafambn.kflate.decompression.Raw as DecompressionRaw +import com.rafambn.kflate.decompression.Zlib as DecompressionZlib import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.util.zip.Deflater @@ -90,7 +98,7 @@ class StreamingValidityTest { for (fileName in testFiles) { val originalData = readResourceFile(fileName) - val compressedData = streamCompress(originalData, RAW()) + val compressedData = streamCompress(originalData, CompressionRaw()) val inflater = Inflater(true) val inputStream = ByteArrayInputStream(compressedData) @@ -121,7 +129,7 @@ class StreamingValidityTest { val compressedData = outputStream.toByteArray() - val decompressedData = streamDecompress(compressedData, Raw()) + val decompressedData = streamDecompress(compressedData, DecompressionRaw()) assertContentEquals(originalData, decompressedData, "Failed on file: $fileName") @@ -136,7 +144,7 @@ class StreamingValidityTest { for (fileName in testFiles) { val originalData = readResourceFile(fileName) - val compressedData = streamCompress(originalData, GZIP()) + val compressedData = streamCompress(originalData, CompressionGzip()) val inputStream = ByteArrayInputStream(compressedData) val gzipInputStream = GZIPInputStream(inputStream) @@ -163,7 +171,7 @@ class StreamingValidityTest { val compressedData = outputStream.toByteArray() - val decompressedData = streamDecompress(compressedData, Gzip()) + val decompressedData = streamDecompress(compressedData, DecompressionGzip()) assertContentEquals(originalData, decompressedData, "Failed on file: $fileName") } @@ -173,20 +181,20 @@ class StreamingValidityTest { fun testGzipXflFlags() { val testData = readResourceFile("simpleText") - val compressed0 = streamCompress(testData, GZIP(level = 0)) + val compressed0 = streamCompress(testData, CompressionGzip(level = 0)) assert(compressed0[8] == 4.toByte()) { "Level 0 should set XFL = 4 (max speed)" } - val compressed1 = streamCompress(testData, GZIP(level = 1)) + val compressed1 = streamCompress(testData, CompressionGzip(level = 1)) assert(compressed1[8] == 4.toByte()) { "Level 1 should set XFL = 4 (max speed)" } for (level in 2..8) { - val compressed = streamCompress(testData, GZIP(level = level)) + val compressed = streamCompress(testData, CompressionGzip(level = level)) assert(compressed[8] == 0.toByte()) { "Level $level should set XFL = 0 (default), but got ${compressed[8]}" } } - val compressed9 = streamCompress(testData, GZIP(level = 9)) + val compressed9 = streamCompress(testData, CompressionGzip(level = 9)) assert(compressed9[8] == 2.toByte()) { "Level 9 should set XFL = 2 (max compression)" } } @@ -197,7 +205,7 @@ class StreamingValidityTest { for (fileName in testFiles) { val originalData = readResourceFile(fileName) - val compressedData = streamCompress(originalData, ZLIB()) + val compressedData = streamCompress(originalData, CompressionZlib()) val inflater = Inflater() val inputStream = ByteArrayInputStream(compressedData) @@ -228,7 +236,7 @@ class StreamingValidityTest { val compressedData = outputStream.toByteArray() - val decompressedData = streamDecompress(compressedData, Zlib()) + val decompressedData = streamDecompress(compressedData, DecompressionZlib()) assertContentEquals(originalData, decompressedData, "Failed on file: $fileName") @@ -242,9 +250,9 @@ class StreamingValidityTest { fun testZlibDecompressValidChecksumAccepted() { val originalData = readResourceFile("simpleText") - val compressedData = streamCompress(originalData, ZLIB()) + val compressedData = streamCompress(originalData, CompressionZlib()) - val decompressedData = streamDecompress(compressedData, Zlib()) + val decompressedData = streamDecompress(compressedData, DecompressionZlib()) assertContentEquals(originalData, decompressedData) } @@ -253,13 +261,13 @@ class StreamingValidityTest { fun testZlibDecompressCorruptedChecksumRejected() { val originalData = readResourceFile("simpleText") - val compressedData = streamCompress(originalData, ZLIB()).toMutableList() + val compressedData = streamCompress(originalData, CompressionZlib()).toMutableList() val checksumStartIndex = compressedData.size - 4 compressedData[checksumStartIndex] = (compressedData[checksumStartIndex].toInt() xor 0xFF).toByte() try { - streamDecompress(compressedData.toByteArray(), Zlib()) + streamDecompress(compressedData.toByteArray(), DecompressionZlib()) assert(false) { "Expected checksum validation error but none was thrown" } } catch (e: Exception) { assert(e.message?.contains("checksum", ignoreCase = true) == true) { @@ -272,14 +280,14 @@ class StreamingValidityTest { fun testZlibDecompressCorruptedDataRejected() { val originalData = readResourceFile("simpleText") - val compressedData = streamCompress(originalData, ZLIB()).toMutableList() + val compressedData = streamCompress(originalData, CompressionZlib()).toMutableList() if (compressedData.size > 10) { compressedData[5] = (compressedData[5].toInt() xor 0xFF).toByte() } try { - streamDecompress(compressedData.toByteArray(), Zlib()) + streamDecompress(compressedData.toByteArray(), DecompressionZlib()) assert(false) { "Expected checksum validation error but none was thrown" } } catch (e: Exception) { assert(e.message?.contains("checksum", ignoreCase = true) == true) { @@ -295,7 +303,7 @@ class StreamingValidityTest { 0x03.toByte(), 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x01.toByte() ) - val decompressedData = streamDecompress(emptyData, Zlib()) + val decompressedData = streamDecompress(emptyData, DecompressionZlib()) assertContentEquals(ByteArray(0), decompressedData) } diff --git a/kflate/src/jvmTest/kotlin/ValueTypesCoverageTest.kt b/kflate/src/jvmTest/kotlin/ValueTypesCoverageTest.kt new file mode 100644 index 0000000..2157e55 --- /dev/null +++ b/kflate/src/jvmTest/kotlin/ValueTypesCoverageTest.kt @@ -0,0 +1,352 @@ +package com.rafambn.kflate + +import com.rafambn.kflate.compression.Gzip as CompressionGzip +import com.rafambn.kflate.compression.Raw as CompressionRaw +import com.rafambn.kflate.compression.Zlib as CompressionZlib +import com.rafambn.kflate.decompression.Gzip as DecompressionGzip +import com.rafambn.kflate.decompression.Raw as DecompressionRaw +import com.rafambn.kflate.decompression.Zlib as DecompressionZlib +import com.rafambn.kflate.format.GzipMemberResult +import com.rafambn.kflate.huffman.HuffmanNode +import com.rafambn.kflate.huffman.HuffmanTable +import com.rafambn.kflate.huffman.HuffmanTreeResult +import com.rafambn.kflate.streaming.DeflateState +import com.rafambn.kflate.streaming.InflateState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Instant + +class ValueTypesCoverageTest { + + private val nullValue: Any? + get() = listOf().firstOrNull() + + @Test + fun compressionRawValueContract() { + val dictionary = byteArrayOf(1, 2) + val value = CompressionRaw(level = 1, dictionary = dictionary) + + assertSame(value, value) + assertEqualsAcceptsSame(value) + assertFalse(value.equals(null)) + assertFalse(value.equals(nullValue)) + assertEqualsRejectsNull(value) + assertFalse(value.equals(CompressionZlib(level = 1, dictionary = dictionary))) + assertEqualsRejects(value, CompressionZlib(level = 1, dictionary = dictionary)) + assertFalse(valuesEqual(value, Any())) + assertNotEquals(value, CompressionRaw(level = 2, dictionary = dictionary)) + assertNotEquals(value, CompressionRaw(level = 1, dictionary = byteArrayOf(2, 1))) + assertEquals(value, CompressionRaw(level = 1, dictionary = dictionary.copyOf())) + assertTrue(valuesEqual(value, CompressionRaw(level = 1, dictionary = dictionary.copyOf()))) + assertEquals(value.hashCode(), CompressionRaw(level = 1, dictionary = dictionary.copyOf()).hashCode()) + assertEquals(CompressionRaw().hashCode(), CompressionRaw().copy().hashCode()) + assertEquals(1, value.component1()) + assertTrue(value.component2()!!.contentEquals(dictionary)) + assertEquals(CompressionRaw(level = 3, dictionary = dictionary), value.copy(level = 3)) + assertTrue(value.toString().contains("level=1")) + + assertFailsWith { CompressionRaw(level = -1) } + assertFailsWith { CompressionRaw(level = 10) } + assertFailsWith { CompressionRaw(dictionary = ByteArray(32_769)) } + } + + @Test + fun compressionZlibValueContract() { + val dictionary = byteArrayOf(1, 2) + val value = CompressionZlib(level = 1, dictionary = dictionary) + + assertSame(value, value) + assertEqualsAcceptsSame(value) + assertFalse(value.equals(null)) + assertFalse(value.equals(nullValue)) + assertEqualsRejectsNull(value) + assertFalse(value.equals(CompressionRaw(level = 1, dictionary = dictionary))) + assertEqualsRejects(value, CompressionRaw(level = 1, dictionary = dictionary)) + assertFalse(valuesEqual(value, Any())) + assertNotEquals(value, CompressionZlib(level = 2, dictionary = dictionary)) + assertNotEquals(value, CompressionZlib(level = 1, dictionary = byteArrayOf(2, 1))) + assertEquals(value, CompressionZlib(level = 1, dictionary = dictionary.copyOf())) + assertTrue(valuesEqual(value, CompressionZlib(level = 1, dictionary = dictionary.copyOf()))) + assertEquals(value.hashCode(), CompressionZlib(level = 1, dictionary = dictionary.copyOf()).hashCode()) + assertEquals(CompressionZlib().hashCode(), CompressionZlib().copy().hashCode()) + assertEquals(1, value.component1()) + assertTrue(value.component2()!!.contentEquals(dictionary)) + assertEquals(CompressionZlib(level = 3, dictionary = dictionary), value.copy(level = 3)) + assertTrue(value.toString().contains("level=1")) + + assertFailsWith { CompressionZlib(level = -1) } + assertFailsWith { CompressionZlib(level = 10) } + assertFailsWith { CompressionZlib(dictionary = ByteArray(32_769)) } + } + + @Test + fun compressionGzipValueContract() { + val mtime = Instant.fromEpochSeconds(123) + val fields = mapOf("AB" to byteArrayOf(1)) + val value = CompressionGzip( + level = 1, + filename = "file", + mtime = mtime, + comment = "comment", + extraFields = fields, + includeHeaderCrc = true, + ) + + assertSame(value, value) + assertEqualsAcceptsSame(value) + assertFalse(value.equals(null)) + assertFalse(value.equals(nullValue)) + assertEqualsRejectsNull(value) + assertFalse(value.equals(CompressionRaw())) + assertEqualsRejects(value, CompressionRaw()) + assertFalse(valuesEqual(value, Any())) + assertNotEquals(value, value.copy(level = 2)) + assertNotEquals(value, value.copy(filename = "other")) + assertNotEquals(value, value.copy(mtime = Instant.fromEpochSeconds(124))) + assertNotEquals(value, value.copy(comment = "other")) + assertNotEquals(value, value.copy(extraFields = null)) + assertNotEquals(value, value.copy(includeHeaderCrc = false)) + assertEquals(value, value.copy()) + assertTrue(valuesEqual(value, value.copy())) + assertEquals(value.hashCode(), value.copy().hashCode()) + assertEquals(CompressionGzip().hashCode(), CompressionGzip().copy().hashCode()) + assertEquals(1, value.component1()) + assertEquals("file", value.component2()) + assertEquals(mtime, value.component3()) + assertEquals("comment", value.component4()) + assertEquals(fields, value.component5()) + assertTrue(value.component6()) + assertTrue(value.toString().contains("filename=file")) + + assertFailsWith { CompressionGzip(level = -1) } + assertFailsWith { CompressionGzip(level = 10) } + assertFailsWith { CompressionGzip(filename = "a".repeat(65_536)) } + assertFailsWith { CompressionGzip(comment = "a".repeat(65_536)) } + assertFailsWith { CompressionGzip(extraFields = mapOf("A" to byteArrayOf())) } + assertFailsWith { CompressionGzip(extraFields = mapOf("ABC" to byteArrayOf())) } + assertFailsWith { CompressionGzip(extraFields = mapOf("AB" to ByteArray(65_536))) } + } + + @Test + fun decompressionValueContracts() { + val dictionary = byteArrayOf(1, 2) + val raw = DecompressionRaw(dictionary, 3) + assertSame(raw, raw) + assertEqualsAcceptsSame(raw) + assertFalse(raw.equals(null)) + assertFalse(raw.equals(nullValue)) + assertEqualsRejectsNull(raw) + assertFalse(raw.equals(DecompressionZlib(dictionary, 3))) + assertEqualsRejects(raw, DecompressionZlib(dictionary, 3)) + assertFalse(valuesEqual(raw, Any())) + assertNotEquals(raw, DecompressionRaw(byteArrayOf(2, 1), 3)) + assertNotEquals(raw, DecompressionRaw(dictionary, 4)) + assertEquals(raw, DecompressionRaw(dictionary.copyOf(), 3)) + assertTrue(valuesEqual(raw, DecompressionRaw(dictionary.copyOf(), 3))) + assertEquals(raw.hashCode(), DecompressionRaw(dictionary.copyOf(), 3).hashCode()) + assertEquals(0, DecompressionRaw().hashCode()) + assertTrue(raw.component1()!!.contentEquals(dictionary)) + assertEquals(3, raw.component2()) + assertEquals(DecompressionRaw(dictionary, 4), raw.copy(maxOutputSize = 4)) + assertTrue(raw.toString().contains("maxOutputSize=3")) + + val zlib = DecompressionZlib(dictionary, 3) + assertSame(zlib, zlib) + assertEqualsAcceptsSame(zlib) + assertFalse(zlib.equals(null)) + assertFalse(zlib.equals(nullValue)) + assertEqualsRejectsNull(zlib) + assertFalse(zlib.equals(raw)) + assertEqualsRejects(zlib, raw) + assertFalse(valuesEqual(zlib, Any())) + assertNotEquals(zlib, DecompressionZlib(byteArrayOf(2, 1), 3)) + assertNotEquals(zlib, DecompressionZlib(dictionary, 4)) + assertEquals(zlib, DecompressionZlib(dictionary.copyOf(), 3)) + assertTrue(valuesEqual(zlib, DecompressionZlib(dictionary.copyOf(), 3))) + assertEquals(zlib.hashCode(), DecompressionZlib(dictionary.copyOf(), 3).hashCode()) + assertEquals(0, DecompressionZlib().hashCode()) + assertTrue(zlib.component1()!!.contentEquals(dictionary)) + assertEquals(3, zlib.component2()) + assertEquals(DecompressionZlib(dictionary, 4), zlib.copy(maxOutputSize = 4)) + assertTrue(zlib.toString().contains("maxOutputSize=3")) + + val gzip = DecompressionGzip(3) + assertEquals(3, gzip.component1()) + assertEquals(DecompressionGzip(4), gzip.copy(maxOutputSize = 4)) + assertTrue(gzip.toString().contains("maxOutputSize=3")) + + assertFailsWith { DecompressionRaw(dictionary = ByteArray(32_769)) } + assertFailsWith { DecompressionRaw(maxOutputSize = -1) } + assertFailsWith { DecompressionZlib(dictionary = ByteArray(32_769)) } + assertFailsWith { DecompressionZlib(maxOutputSize = -1) } + assertFailsWith { DecompressionGzip(maxOutputSize = -1) } + } + + @Test + fun deflateStateValueContract() { + val head = shortArrayOf(1, 2) + val prev = shortArrayOf(3, 4) + val value = DeflateState(head, prev, 1, 2, 3, 4, true) + + assertSame(value, value) + assertEqualsAcceptsSame(value) + assertFalse(value.equals(null)) + assertFalse(value.equals(nullValue)) + assertEqualsRejectsNull(value) + assertFalse(value.equals(InflateState())) + assertEqualsRejects(value, InflateState()) + assertFalse(valuesEqual(value, Any())) + assertNotEquals(value, value.copy(inputOffset = 2)) + assertNotEquals(value, value.copy(inputEndIndex = 3)) + assertNotEquals(value, value.copy(waitIndex = 4)) + assertNotEquals(value, value.copy(bitBuffer = 5)) + assertNotEquals(value, value.copy(isLastChunk = false)) + assertNotEquals(value, value.copy(head = shortArrayOf(2, 1))) + assertNotEquals(value, value.copy(prev = shortArrayOf(4, 3))) + assertEquals(value, value.copy(head = head.copyOf(), prev = prev.copyOf())) + assertTrue(valuesEqual(value, value.copy(head = head.copyOf(), prev = prev.copyOf()))) + assertEquals(value.hashCode(), value.copy(head = head.copyOf(), prev = prev.copyOf()).hashCode()) + assertEquals(DeflateState().hashCode(), DeflateState().copy().hashCode()) + + val copy = value.copy() + copy.head = head + copy.prev = prev + copy.inputOffset = 1 + copy.inputEndIndex = 2 + copy.waitIndex = 3 + copy.bitBuffer = 4 + copy.isLastChunk = true + assertEquals(value, copy) + assertTrue(copy.toString().contains("inputOffset=1")) + } + + @Test + fun inflateStateValueContract() { + val literals = shortArrayOf(1, 2) + val distances = shortArrayOf(3, 4) + val value = InflateState(literals, distances, 1, 2, true, 3, 4, 5) + + assertSame(value, value) + assertEqualsAcceptsSame(value) + assertFalse(value.equals(null)) + assertFalse(value.equals(nullValue)) + assertEqualsRejectsNull(value) + assertFalse(value.equals(DeflateState())) + assertEqualsRejects(value, DeflateState()) + assertFalse(valuesEqual(value, Any())) + assertNotEquals(value, value.copy(literalMaxBits = 2)) + assertNotEquals(value, value.copy(distanceMaxBits = 3)) + assertNotEquals(value, value.copy(isFinalBlock = false)) + assertNotEquals(value, value.copy(inputBitPosition = 4)) + assertNotEquals(value, value.copy(outputOffset = 5)) + assertNotEquals(value, value.copy(validationMode = 6)) + assertNotEquals(value, value.copy(literalMap = shortArrayOf(2, 1))) + assertNotEquals(value, value.copy(distanceMap = shortArrayOf(4, 3))) + assertEquals(value, value.copy(literalMap = literals.copyOf(), distanceMap = distances.copyOf())) + assertTrue(valuesEqual(value, value.copy(literalMap = literals.copyOf(), distanceMap = distances.copyOf()))) + assertEquals(value.hashCode(), value.copy(literalMap = literals.copyOf(), distanceMap = distances.copyOf()).hashCode()) + assertEquals(InflateState().hashCode(), InflateState().copy().hashCode()) + + val copy = value.copy() + copy.literalMap = literals + copy.distanceMap = distances + copy.literalMaxBits = 1 + copy.distanceMaxBits = 2 + copy.isFinalBlock = true + copy.inputBitPosition = 3 + copy.outputOffset = 4 + copy.validationMode = 5 + assertEquals(value, copy) + assertTrue(copy.toString().contains("literalMaxBits=1")) + } + + @Test + fun internalHolderValueContracts() { + val member = GzipMemberResult(byteArrayOf(1, 2), 3) + assertSame(member, member) + assertEqualsAcceptsSame(member) + assertFalse(member.equals(null)) + assertFalse(member.equals(nullValue)) + assertEqualsRejectsNull(member) + assertFalse(member.equals(HuffmanTreeResult(byteArrayOf(1, 2), 3))) + assertEqualsRejects(member, HuffmanTreeResult(byteArrayOf(1, 2), 3)) + assertFalse(valuesEqual(member, Any())) + assertNotEquals(member, GzipMemberResult(byteArrayOf(2, 1), 3)) + assertNotEquals(member, GzipMemberResult(byteArrayOf(1, 2), 4)) + assertEquals(member, GzipMemberResult(byteArrayOf(1, 2), 3)) + assertTrue(valuesEqual(member, GzipMemberResult(byteArrayOf(1, 2), 3))) + assertEquals(member.hashCode(), GzipMemberResult(byteArrayOf(1, 2), 3).hashCode()) + assertTrue(member.decompressed.contentEquals(byteArrayOf(1, 2))) + assertEquals(3, member.bytesConsumed) + assertTrue(member.toString().contains("bytesConsumed=3")) + + val tree = HuffmanTreeResult(byteArrayOf(1, 2), 3) + assertSame(tree, tree) + assertEqualsAcceptsSame(tree) + assertFalse(tree.equals(null)) + assertFalse(tree.equals(nullValue)) + assertEqualsRejectsNull(tree) + assertFalse(tree.equals(member)) + assertEqualsRejects(tree, member) + assertFalse(valuesEqual(tree, Any())) + assertNotEquals(tree, HuffmanTreeResult(byteArrayOf(1, 2), 4)) + assertNotEquals(tree, HuffmanTreeResult(byteArrayOf(2, 1), 3)) + assertEquals(tree, HuffmanTreeResult(byteArrayOf(1, 2), 3)) + assertTrue(valuesEqual(tree, HuffmanTreeResult(byteArrayOf(1, 2), 3))) + assertEquals(tree.hashCode(), HuffmanTreeResult(byteArrayOf(1, 2), 3).hashCode()) + assertTrue(tree.tree.contentEquals(byteArrayOf(1, 2))) + assertEquals(3, tree.maxBits) + assertTrue(tree.toString().contains("maxBits=3")) + + val table = HuffmanTable(shortArrayOf(1, 2), intArrayOf(3, 4)) + assertSame(table, table) + assertEqualsAcceptsSame(table) + assertFalse(table.equals(null)) + assertFalse(table.equals(nullValue)) + assertEqualsRejectsNull(table) + assertFalse(table.equals(tree)) + assertEqualsRejects(table, tree) + assertFalse(valuesEqual(table, Any())) + assertNotEquals(table, HuffmanTable(shortArrayOf(2, 1), intArrayOf(3, 4))) + assertNotEquals(table, HuffmanTable(shortArrayOf(1, 2), intArrayOf(4, 3))) + assertEquals(table, HuffmanTable(shortArrayOf(1, 2), intArrayOf(3, 4))) + assertTrue(valuesEqual(table, HuffmanTable(shortArrayOf(1, 2), intArrayOf(3, 4)))) + assertEquals(table.hashCode(), HuffmanTable(shortArrayOf(1, 2), intArrayOf(3, 4)).hashCode()) + assertTrue(table.toString().contains("baseLengths=")) + + val left = HuffmanNode(1, 2) + val right = HuffmanNode(3, 4) + val node = HuffmanNode(5, 6, left, right) + assertEquals(5, node.component1()) + assertEquals(6, node.component2()) + assertEquals(left, node.component3()) + assertEquals(right, node.component4()) + node.leftChild = right + node.rightChild = left + assertEquals(HuffmanNode(5, 6, right, left), node) + assertFalse(node == left) + assertTrue(node.toString().contains("symbol=5")) + assertEquals(node.hashCode(), node.copy().hashCode()) + } + + private fun assertEqualsRejectsNull(value: Any) { + assertEqualsRejects(value, nullValue) + } + + private fun assertEqualsAcceptsSame(value: Any) { + val equals = value.javaClass.getMethod("equals", Any::class.java) + assertEquals(true, equals.invoke(value, value)) + } + + private fun assertEqualsRejects(value: Any, other: Any?) { + val equals = value.javaClass.getMethod("equals", Any::class.java) + assertEquals(false, equals.invoke(value, other)) + } + + private fun valuesEqual(first: Any?, second: Any?): Boolean = first == second +} diff --git a/kflate/src/jvmTest/kotlin/ZlibDictidValidationTest.kt b/kflate/src/jvmTest/kotlin/ZlibDictidValidationTest.kt index 85d58ed..f94a202 100644 --- a/kflate/src/jvmTest/kotlin/ZlibDictidValidationTest.kt +++ b/kflate/src/jvmTest/kotlin/ZlibDictidValidationTest.kt @@ -1,7 +1,8 @@ package com.rafambn.kflate -import com.rafambn.kflate.checksum.Adler32Checksum +import com.rafambn.kflate.compression.Zlib as CompressionZlib +import com.rafambn.kflate.decompression.Zlib as DecompressionZlib import com.rafambn.kflate.error.FlateError import com.rafambn.kflate.error.FlateErrorCode import kotlin.test.Test @@ -15,8 +16,8 @@ class ZlibDictidValidationTest { val data = "hello world".encodeToByteArray() val dictionary = "common".encodeToByteArray() - val compressed = KFlate.compress(data, ZLIB(dictionary = dictionary)) - val decompressed = KFlate.decompress(compressed, Zlib(dictionary = dictionary)) + val compressed = KFlate.compress(data, CompressionZlib(dictionary = dictionary)) + val decompressed = KFlate.decompress(compressed, DecompressionZlib(dictionary = dictionary)) assertEquals("hello world", decompressed.decodeToString()) } @@ -27,10 +28,10 @@ class ZlibDictidValidationTest { val correctDict = "common".encodeToByteArray() val wrongDict = "wrong".encodeToByteArray() - val compressed = KFlate.compress(data, ZLIB(dictionary = correctDict)) + val compressed = KFlate.compress(data, CompressionZlib(dictionary = correctDict)) val error = assertFailsWith { - KFlate.decompress(compressed, Zlib(dictionary = wrongDict)) + KFlate.decompress(compressed, DecompressionZlib(dictionary = wrongDict)) } assertEquals(FlateErrorCode.CHECKSUM_MISMATCH, error.code) } @@ -40,11 +41,11 @@ class ZlibDictidValidationTest { val data = "hello world".encodeToByteArray() val dictionary = "common".encodeToByteArray() - val compressed = KFlate.compress(data, ZLIB(dictionary = dictionary)) + val compressed = KFlate.compress(data, CompressionZlib(dictionary = dictionary)) // Try to decompress without providing the required dictionary val error = assertFailsWith { - KFlate.decompress(compressed, Zlib()) + KFlate.decompress(compressed, DecompressionZlib()) } assertEquals(FlateErrorCode.INVALID_HEADER, error.code) } @@ -53,8 +54,8 @@ class ZlibDictidValidationTest { fun testNoDictidWhenFdictNotSet() { val data = "hello world".encodeToByteArray() - val compressed = KFlate.compress(data, ZLIB()) - val decompressed = KFlate.decompress(compressed, Zlib()) + val compressed = KFlate.compress(data, CompressionZlib()) + val decompressed = KFlate.decompress(compressed, DecompressionZlib()) assertEquals("hello world", decompressed.decodeToString()) } @@ -64,14 +65,14 @@ class ZlibDictidValidationTest { val data = "hello world".encodeToByteArray() val dictionary = "common".encodeToByteArray() - val compressed = KFlate.compress(data, ZLIB(dictionary = dictionary)) + val compressed = KFlate.compress(data, CompressionZlib(dictionary = dictionary)) // Truncate to remove the DICTID (it's after the 2-byte header) // Header with DICTID is 6 bytes total (2 bytes CMF/FLG + 4 bytes DICTID) val truncated = compressed.copyOfRange(0, 5) val error = assertFailsWith { - KFlate.decompress(truncated, Zlib(dictionary = dictionary)) + KFlate.decompress(truncated, DecompressionZlib(dictionary = dictionary)) } assertEquals(FlateErrorCode.UNEXPECTED_EOF, error.code) } @@ -81,13 +82,13 @@ class ZlibDictidValidationTest { val data = "hello world".encodeToByteArray() val dictionary = "common".encodeToByteArray() - val compressed = KFlate.compress(data, ZLIB(dictionary = dictionary)).toMutableList() + val compressed = KFlate.compress(data, CompressionZlib(dictionary = dictionary)).toMutableList() // Corrupt the DICTID bytes (bytes 2-5) compressed[2] = (compressed[2].toInt() + 1).toByte() val error = assertFailsWith { - KFlate.decompress(compressed.toByteArray(), Zlib(dictionary = dictionary)) + KFlate.decompress(compressed.toByteArray(), DecompressionZlib(dictionary = dictionary)) } assertEquals(FlateErrorCode.CHECKSUM_MISMATCH, error.code) } diff --git a/kflate/src/jvmTest/kotlin/ZlibStoredBlockNlenValidationTest.kt b/kflate/src/jvmTest/kotlin/ZlibStoredBlockNlenValidationTest.kt index 92d18a9..35d17fd 100644 --- a/kflate/src/jvmTest/kotlin/ZlibStoredBlockNlenValidationTest.kt +++ b/kflate/src/jvmTest/kotlin/ZlibStoredBlockNlenValidationTest.kt @@ -1,6 +1,7 @@ package com.rafambn.kflate +import com.rafambn.kflate.decompression.Zlib import kotlin.test.Test import kotlin.test.assertFailsWith diff --git a/kflate/src/jvmTest/kotlin/ZlibStreamingHeaderTest.kt b/kflate/src/jvmTest/kotlin/ZlibStreamingHeaderTest.kt new file mode 100644 index 0000000..bee2c04 --- /dev/null +++ b/kflate/src/jvmTest/kotlin/ZlibStreamingHeaderTest.kt @@ -0,0 +1,66 @@ +package com.rafambn.kflate + +import com.rafambn.kflate.compression.Zlib as CompressionZlib +import com.rafambn.kflate.decompression.Zlib as DecompressionZlib +import com.rafambn.kflate.error.FlateError +import com.rafambn.kflate.error.FlateErrorCode +import kotlinx.io.Buffer +import kotlinx.io.RawSource +import kotlinx.io.readByteArray +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ZlibStreamingHeaderTest { + @Test + fun acceptsOneByteSourceChunks() { + val original = "one byte at a time".repeat(100).encodeToByteArray() + val compressed = KFlate.compress(original, CompressionZlib()) + val output = Buffer() + + KFlate.decompress(DecompressionZlib(), OneByteRawSource(compressed), output) + + assertContentEquals(original, output.readByteArray()) + } + + @Test + fun rejectsEofAfterOneHeaderByte() { + val compressed = KFlate.compress("truncated".encodeToByteArray(), CompressionZlib()) + + val error = assertFailsWith { + KFlate.decompress(DecompressionZlib(), OneByteRawSource(compressed.copyOf(1)), Buffer()) + } + + assertEquals(FlateErrorCode.UNEXPECTED_EOF, error.code) + } + + @Test + fun acceptsDictionaryHeaderInOneByteChunks() { + val dictionary = "shared dictionary".encodeToByteArray() + val original = "shared dictionary content".repeat(100).encodeToByteArray() + val compressed = KFlate.compress(original, CompressionZlib(dictionary = dictionary)) + val output = Buffer() + + KFlate.decompress( + DecompressionZlib(dictionary = dictionary), + OneByteRawSource(compressed), + output + ) + + assertContentEquals(original, output.readByteArray()) + } + + private class OneByteRawSource(private val data: ByteArray) : RawSource { + private var offset = 0 + + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { + require(byteCount > 0) + if (offset == data.size) return -1 + sink.write(byteArrayOf(data[offset++])) + return 1 + } + + override fun close() = Unit + } +} diff --git a/kflate/src/jvmTest/kotlin/com/rafambn/kflate/performance/PlatformInfo.kt b/kflate/src/jvmTest/kotlin/com/rafambn/kflate/performance/PlatformInfo.kt deleted file mode 100644 index f2c41d2..0000000 --- a/kflate/src/jvmTest/kotlin/com/rafambn/kflate/performance/PlatformInfo.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.rafambn.kflate.performance - -actual val PLATFORM_NAME: String = "jvm" diff --git a/kflate/src/nativeTest/kotlin/PlatformInfo.native.kt b/kflate/src/nativeTest/kotlin/PlatformInfo.native.kt deleted file mode 100644 index 4929183..0000000 --- a/kflate/src/nativeTest/kotlin/PlatformInfo.native.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.rafambn.kflate.performance - -actual val PLATFORM_NAME = "native" \ No newline at end of file diff --git a/kflate/src/wasmJsTest/kotlin/PlatformInfo.wasmJs.kt b/kflate/src/wasmJsTest/kotlin/PlatformInfo.wasmJs.kt deleted file mode 100644 index 2efa422..0000000 --- a/kflate/src/wasmJsTest/kotlin/PlatformInfo.wasmJs.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.rafambn.kflate.performance - -actual val PLATFORM_NAME = "wasm" \ No newline at end of file diff --git a/performance/1#/compression_jvm.txt b/performance/1#/compression_jvm.txt deleted file mode 100644 index 9366b16..0000000 --- a/performance/1#/compression_jvm.txt +++ /dev/null @@ -1,20 +0,0 @@ -=== KFlate vs Kompress Compression Benchmark Results === - -File: Sunrise.bmp -Original Size: 49.91MB - -KFlate: - Compressed Size: 23.76MB (47.61%) - Avg Compression Time (10 iterations): 1634.78ms - Avg Decompression Time (10 iterations): 375.41ms - -Kompress: - Compressed Size: 25.46MB (51.0%) - Avg Compression Time (10 iterations): 1177.85ms - Avg Decompression Time (10 iterations): 204.29ms - -Comparison: - Size Difference: 1.69MB (6.64%) - KFlate is LARGER than Kompress - -────────────────────────────────────────────────────────────────────── diff --git a/performance/1#/compression_native.txt b/performance/1#/compression_native.txt deleted file mode 100644 index 319ca87..0000000 --- a/performance/1#/compression_native.txt +++ /dev/null @@ -1,20 +0,0 @@ -=== KFlate vs Kompress Compression Benchmark Results === - -File: Sunrise.bmp -Original Size: 49.91MB - -KFlate: - Compressed Size: 23.76MB (47.61%) - Avg Compression Time (10 iterations): 8975.8ms - Avg Decompression Time (10 iterations): 2667.19ms - -Kompress: - Compressed Size: 25.46MB (51.0%) - Avg Compression Time (10 iterations): 1172.02ms - Avg Decompression Time (10 iterations): 212.7ms - -Comparison: - Size Difference: 1.69MB (6.64%) - KFlate is LARGER than Kompress - -────────────────────────────────────────────────────────────────────── diff --git a/performance/1#/compression_wasm.txt b/performance/1#/compression_wasm.txt deleted file mode 100644 index f4257ea..0000000 --- a/performance/1#/compression_wasm.txt +++ /dev/null @@ -1,20 +0,0 @@ -=== KFlate vs Kompress Compression Benchmark Results === - -File: Sunrise.bmp -Original Size: 49.91MB - -KFlate: - Compressed Size: 23.76MB (47.61%) - Avg Compression Time (10 iterations): 3602.98ms - Avg Decompression Time (10 iterations): 810.15ms - -Kompress: - Compressed Size: 23.76MB (47.61%) - Avg Compression Time (10 iterations): 2508.34ms - Avg Decompression Time (10 iterations): 1172.44ms - -Comparison: - Size Difference: 31B (0.0%) - KFlate is SMALLER than Kompress - -────────────────────────────────────────────────────────────────────── diff --git a/performance/2#/compression_jvm.txt b/performance/2#/compression_jvm.txt deleted file mode 100644 index 21319a1..0000000 --- a/performance/2#/compression_jvm.txt +++ /dev/null @@ -1,20 +0,0 @@ -=== KFlate vs Kompress Compression Benchmark Results === - -File: Sunrise.bmp -Original Size: 49.91MB - -KFlate: - Compressed Size: 23.78MB (47.64%) - Avg Compression Time (10 iterations): 1299.28ms - Avg Decompression Time (10 iterations): 311.24ms - -Kompress: - Compressed Size: 25.46MB (51.0%) - Avg Compression Time (10 iterations): 1166.27ms - Avg Decompression Time (10 iterations): 197.1ms - -Comparison: - Size Difference: 1.67MB (6.59%) - KFlate is SMALLER than Kompress - -────────────────────────────────────────────────────────────────────── diff --git a/performance/2#/compression_native.txt b/performance/2#/compression_native.txt deleted file mode 100644 index f9a27a8..0000000 --- a/performance/2#/compression_native.txt +++ /dev/null @@ -1,20 +0,0 @@ -=== KFlate vs Kompress Compression Benchmark Results === - -File: Sunrise.bmp -Original Size: 49.91MB - -KFlate: - Compressed Size: 23.78MB (47.64%) - Avg Compression Time (10 iterations): 4969.45ms - Avg Decompression Time (10 iterations): 1358.23ms - -Kompress: - Compressed Size: 25.46MB (51.0%) - Avg Compression Time (10 iterations): 1244.02ms - Avg Decompression Time (10 iterations): 220.92ms - -Comparison: - Size Difference: 1.67MB (6.59%) - KFlate is SMALLER than Kompress - -────────────────────────────────────────────────────────────────────── diff --git a/performance/2#/compression_wasm.txt b/performance/2#/compression_wasm.txt deleted file mode 100644 index f6fa591..0000000 --- a/performance/2#/compression_wasm.txt +++ /dev/null @@ -1,20 +0,0 @@ -=== KFlate vs Kompress Compression Benchmark Results === - -File: Sunrise.bmp -Original Size: 49.91MB - -KFlate: - Compressed Size: 23.78MB (47.64%) - Avg Compression Time (10 iterations): 2973.87ms - Avg Decompression Time (10 iterations): 606.78ms - -Kompress: - Compressed Size: 23.76MB (47.61%) - Avg Compression Time (10 iterations): 2582.61ms - Avg Decompression Time (10 iterations): 1210.58ms - -Comparison: - Size Difference: -13655B (-0.05%) - KFlate is LARGER than Kompress - -────────────────────────────────────────────────────────────────────── diff --git a/performance/3#/compression_jvm.txt b/performance/3#/compression_jvm.txt deleted file mode 100644 index 4c87efe..0000000 --- a/performance/3#/compression_jvm.txt +++ /dev/null @@ -1,21 +0,0 @@ -=== KFlate vs Kompress Compression Benchmark Results === - -Test Date: 2026-02-05T17:35:58.500756441 -File: Sunrise.bmp -Original Size: 49.91MB - -KFlate: - Compressed Size: 23.78MB (47.64%) - Avg Compression Time (10 iterations): 1255.02ms - Avg Decompression Time (10 iterations): 304.23ms - -Kompress: - Compressed Size: 25.46MB (51.0%) - Avg Compression Time (10 iterations): 1175.45ms - Avg Decompression Time (10 iterations): 199.52ms - -Comparison: - Size Difference: 1.67MB (6.59%) - KFlate is SMALLER than Kompress - -────────────────────────────────────────────────────────────────────── diff --git a/performance/3#/compression_native.txt b/performance/3#/compression_native.txt deleted file mode 100644 index ba0cab1..0000000 --- a/performance/3#/compression_native.txt +++ /dev/null @@ -1,21 +0,0 @@ -=== KFlate vs Kompress Compression Benchmark Results === - -Test Date: 2026-02-05T17:36:55.140761020 -File: Sunrise.bmp -Original Size: 49.91MB - -KFlate: - Compressed Size: 23.78MB (47.64%) - Avg Compression Time (10 iterations): 4533.7ms - Avg Decompression Time (10 iterations): 1313.74ms - -Kompress: - Compressed Size: 25.46MB (51.0%) - Avg Compression Time (10 iterations): 1261.42ms - Avg Decompression Time (10 iterations): 222.47ms - -Comparison: - Size Difference: 1.67MB (6.59%) - KFlate is SMALLER than Kompress - -────────────────────────────────────────────────────────────────────── diff --git a/performance/3#/compression_wasm.txt b/performance/3#/compression_wasm.txt deleted file mode 100644 index 0885eda..0000000 --- a/performance/3#/compression_wasm.txt +++ /dev/null @@ -1,21 +0,0 @@ -=== KFlate vs Kompress Compression Benchmark Results === - -Test Date: 2026-02-05T17:38:54.441 -File: Sunrise.bmp -Original Size: 49.91MB - -KFlate: - Compressed Size: 23.78MB (47.64%) - Avg Compression Time (10 iterations): 2769.23ms - Avg Decompression Time (10 iterations): 563.3ms - -Kompress: - Compressed Size: 23.76MB (47.61%) - Avg Compression Time (10 iterations): 2297.35ms - Avg Decompression Time (10 iterations): 1100.98ms - -Comparison: - Size Difference: -13655B (-0.05%) - KFlate is LARGER than Kompress - -────────────────────────────────────────────────────────────────────── diff --git a/performance/3#/optimization.txt b/performance/3#/optimization.txt deleted file mode 100644 index 16cd61c..0000000 --- a/performance/3#/optimization.txt +++ /dev/null @@ -1,15 +0,0 @@ -OPTIMIZATION: MATCH FINDING - REDUNDANT COMPARISONS -============================================================= - -CHANGES MADE: -1. Added guard condition to minMatchDiff loop (if minMatchDiff > 0) - - Skips unnecessary loop iterations when minMatchDiff is 0 - -2. Added early exit in minMatchDiff loop (if maxDiff >= maxD) - - Terminates loop as soon as maximum distance is found - -3. Kept original diff accumulation (diff += instead of diff =) - - Critical for correct hash chain traversal - -FILE: kflate/src/commonMain/kotlin/com/rafambn/kflate/algorithm/FlateAlgorithms.kt -LINES: 475-495 diff --git a/performance/4#/compression_jvm.txt b/performance/4#/compression_jvm.txt deleted file mode 100644 index 4a4dc90..0000000 --- a/performance/4#/compression_jvm.txt +++ /dev/null @@ -1,21 +0,0 @@ -=== KFlate vs Kompress Compression Benchmark Results === - -Test Date: 2026-02-06T14:47:58.176403367 -File: Sunrise.bmp -Original Size: 49.91MB - -KFlate: - Compressed Size: 23.78MB (47.64%) - Avg Compression Time (10 iterations): 1377.49ms - Avg Decompression Time (10 iterations): 326.89ms - -Kompress: - Compressed Size: 25.46MB (51.0%) - Avg Compression Time (10 iterations): 1249.7ms - Avg Decompression Time (10 iterations): 212.86ms - -Comparison: - Size Difference: 1.67MB (6.59%) - KFlate is SMALLER than Kompress - -────────────────────────────────────────────────────────────────────── diff --git a/performance/4#/compression_native.txt b/performance/4#/compression_native.txt deleted file mode 100644 index 92eb943..0000000 --- a/performance/4#/compression_native.txt +++ /dev/null @@ -1,21 +0,0 @@ -=== KFlate vs Kompress Compression Benchmark Results === - -Test Date: 2026-02-06T14:43:24.433170439 -File: Sunrise.bmp -Original Size: 49.91MB - -KFlate: - Compressed Size: 23.78MB (47.64%) - Avg Compression Time (10 iterations): 1240.12ms - Avg Decompression Time (10 iterations): 333.96ms - -Kompress: - Compressed Size: 25.46MB (51.0%) - Avg Compression Time (10 iterations): 1269.84ms - Avg Decompression Time (10 iterations): 219.11ms - -Comparison: - Size Difference: 1.67MB (6.59%) - KFlate is SMALLER than Kompress - -────────────────────────────────────────────────────────────────────── diff --git a/performance/4#/compression_wasm.txt b/performance/4#/compression_wasm.txt deleted file mode 100644 index 6e88911..0000000 --- a/performance/4#/compression_wasm.txt +++ /dev/null @@ -1,21 +0,0 @@ -=== KFlate vs Kompress Compression Benchmark Results === - -Test Date: 2026-02-06T14:44:24.665 -File: Sunrise.bmp -Original Size: 49.91MB - -KFlate: - Compressed Size: 23.78MB (47.64%) - Avg Compression Time (10 iterations): 2085.17ms - Avg Decompression Time (10 iterations): 491.25ms - -Kompress: - Compressed Size: 23.76MB (47.61%) - Avg Compression Time (10 iterations): 1994.78ms - Avg Decompression Time (10 iterations): 974.77ms - -Comparison: - Size Difference: -13655B (-0.05%) - KFlate is LARGER than Kompress - -────────────────────────────────────────────────────────────────────── diff --git a/performance/4#/optimization.txt b/performance/4#/optimization.txt deleted file mode 100644 index 99c4fa2..0000000 --- a/performance/4#/optimization.txt +++ /dev/null @@ -1,49 +0,0 @@ -OPTIMIZATION: NATIVE RELEASE BUILD + BENCHMARK INFRASTRUCTURE -============================================================= - -COMMITS: - aeb5529 - 4# - Improve testing - 365fe46 - Feat: add Gradle tasks for multiplatform performance benchmarks - -CHANGES MADE: - -1. Enabled native release build (kflate/build.gradle.kts) - - Added binaries.test("release") block to linuxX64 target - with optimized = true and debuggable = false - - Produces release.kexe with full compiler optimizations - - This was the critical change: without it all native benchmarks - ran in debug mode with bounds checks, GC safepoints, etc. - -2. Added warmup iterations to compressionBenchmark() - - 3 warmup compress+decompress cycles for both KFlate and Kompress - - Excluded from timing results - - Prevents JIT warm-up effects from contaminating measurements - -3. Added Gradle benchmark tasks (kflate/build.gradle.kts) - - benchmarkNativeRelease: builds and runs release.kexe - - benchmarkJvmRelease: runs JVM performance tests - - benchmarkWasmJs: runs WASM/JS benchmark via Node.js - - benchmarkAll: aggregate task running all three sequentially - -FILE: kflate/build.gradle.kts -LINES: linuxX64 block (binaries.test section) - -PERFORMANCE DELTA (3# → 4#, Native Release, Sunrise.bmp 49.91MB): - Compression: 4533.70ms → 1240.12ms (-72.6%, 3.65x faster) - Decompression: 1313.74ms → 333.96ms (-74.6%, 3.94x faster) - Compressed size: 23.78MB (47.64%) — unchanged - -NOTE: The entire speedup came from enabling the release build. - No algorithm was changed. 3# was measured in debug mode, - which inflated times by ~3.7x due to array bounds checks - (28.6% of debug samples) and GC safepoints (7.6%). - -PERFORMANCE DELTA (3# → 4#, JVM, Sunrise.bmp 49.91MB): - Compression: n/a → 1377.49ms - Decompression: n/a → 326.89ms - (JVM baseline established in this iteration) - -PERFORMANCE DELTA (3# → 4#, WASM/JS, Sunrise.bmp 49.91MB): - Compression: 2085.17ms (4#) - Decompression: 491.25ms (4#) - (WASM baseline established in this iteration) diff --git a/performance/5#/compression_jvm.txt b/performance/5#/compression_jvm.txt deleted file mode 100644 index df6ea83..0000000 --- a/performance/5#/compression_jvm.txt +++ /dev/null @@ -1,21 +0,0 @@ -=== KFlate vs Kompress Compression Benchmark Results === - -Test Date: 2026-02-19T09:01:43.874718603 -File: Sunrise.bmp -Original Size: 49.91MB - -KFlate: - Compressed Size: 24.62MB (49.32%) - Avg Compression Time (10 iterations): 1302.47ms - Avg Decompression Time (10 iterations): 325.2ms - -Kompress: - Compressed Size: 25.46MB (51.0%) - Avg Compression Time (10 iterations): 1331.9ms - Avg Decompression Time (10 iterations): 221.99ms - -Comparison: - Size Difference: 860.97KB (3.3%) - KFlate is SMALLER than Kompress - -────────────────────────────────────────────────────────────────────── diff --git a/performance/5#/compression_native.txt b/performance/5#/compression_native.txt deleted file mode 100644 index 36a086c..0000000 --- a/performance/5#/compression_native.txt +++ /dev/null @@ -1,21 +0,0 @@ -=== KFlate vs Kompress Compression Benchmark Results === - -Test Date: 2026-02-19T09:06:19.417013952 -File: Sunrise.bmp -Original Size: 49.91MB - -KFlate: - Compressed Size: 24.62MB (49.32%) - Avg Compression Time (10 iterations): 1160.09ms - Avg Decompression Time (10 iterations): 327.87ms - -Kompress: - Compressed Size: 25.46MB (51.0%) - Avg Compression Time (10 iterations): 1180.78ms - Avg Decompression Time (10 iterations): 205.94ms - -Comparison: - Size Difference: 860.97KB (3.3%) - KFlate is SMALLER than Kompress - -────────────────────────────────────────────────────────────────────── diff --git a/performance/5#/compression_wasm.txt b/performance/5#/compression_wasm.txt deleted file mode 100644 index e97c50a..0000000 --- a/performance/5#/compression_wasm.txt +++ /dev/null @@ -1,21 +0,0 @@ -=== KFlate vs Kompress Compression Benchmark Results === - -Test Date: 2026-02-19T09:54:11.864 -File: Sunrise.bmp -Original Size: 49.91MB - -KFlate: - Compressed Size: 24.62MB (49.32%) - Avg Compression Time (10 iterations): 2285.53ms - Avg Decompression Time (10 iterations): 529.5ms - -Kompress: - Compressed Size: 23.76MB (47.61%) - Avg Compression Time (10 iterations): 2438.07ms - Avg Decompression Time (10 iterations): 1203.36ms - -Comparison: - Size Difference: -893426B (-3.58%) - KFlate is LARGER than Kompress - -────────────────────────────────────────────────────────────────────── diff --git a/performance/5#/optimization.txt b/performance/5#/optimization.txt deleted file mode 100644 index 6309a0e..0000000 --- a/performance/5#/optimization.txt +++ /dev/null @@ -1,19 +0,0 @@ -OPTIMIZATION: LEVEL-AWARE HASH TABLE SIZING (PLAN 2) -============================================================= - -CHANGES MADE: - -1. Level-aware hash table cap (FlateAlgorithms.kt, deflateWithOptions()) - - Added maxHashBitsForLevel when-expression before memoryUsage calculation - - Hash table exponent is now capped per compression level: - Level 0-1 → 12 bits (4K entries, 8KB — fits L1) - Level 2-3 → 13 bits (8K entries, 16KB — fits L1) - Level 4-5 → 14 bits (16K entries, 32KB — fits L1) - Level 6-7 → 15 bits (32K entries, 64KB — fits L2) - Level 8 → 16 bits (64K entries,128KB — fits L2) - Level 9 → 20 bits (1M entries, 2MB — unchanged) - - Applied via minOf(maxHashBitsForLevel, computed size) in the - isLastChunk && mem==8 branch; explicit mem settings untouched - - FILE: kflate/src/commonMain/kotlin/com/rafambn/kflate/algorithm/FlateAlgorithms.kt - LINES: 598-612 \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 0781e6b..550d032 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1,3 @@ -rootProject.name = "KFlate" -include(":kflate") -include(":web-demo") - pluginManagement { repositories { google { @@ -17,6 +13,14 @@ pluginManagement { } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +rootProject.name = "KFlate" +include(":kflate") +include(":web-demo") + dependencyResolutionManagement { repositories { google { @@ -30,4 +34,3 @@ dependencyResolutionManagement { mavenCentral() } } - diff --git a/web-demo/build.gradle.kts b/web-demo/build.gradle.kts index 43ad6bc..be47ee9 100644 --- a/web-demo/build.gradle.kts +++ b/web-demo/build.gradle.kts @@ -23,10 +23,9 @@ val optimizedOutputDir = layout.buildDirectory.dir( "compileSync/wasmJs/main/productionExecutable/optimized" ) -tasks.register("assembleWebDemo") { +tasks.register("assembleWebDemo") { dependsOn("compileProductionExecutableKotlinWasmJsOptimize") from(optimizedOutputDir) from("src/wasmJsMain/resources") into(layout.buildDirectory.dir("webDemo")) } - diff --git a/web-demo/src/wasmJsMain/kotlin/com/rafambn/kflate/demo/WebApi.kt b/web-demo/src/wasmJsMain/kotlin/com/rafambn/kflate/demo/WebApi.kt index 32aeac1..e192c2a 100644 --- a/web-demo/src/wasmJsMain/kotlin/com/rafambn/kflate/demo/WebApi.kt +++ b/web-demo/src/wasmJsMain/kotlin/com/rafambn/kflate/demo/WebApi.kt @@ -1,15 +1,16 @@ -@file:OptIn(ExperimentalJsExport::class) +@file:OptIn(ExperimentalJsExport::class, ExperimentalWasmJsInterop::class) package com.rafambn.kflate.demo -import com.rafambn.kflate.GZIP -import com.rafambn.kflate.Gzip +import com.rafambn.kflate.compression.Gzip as CompressionGzip +import com.rafambn.kflate.decompression.Gzip as DecompressionGzip import com.rafambn.kflate.KFlate -import com.rafambn.kflate.RAW -import com.rafambn.kflate.Raw -import com.rafambn.kflate.ZLIB -import com.rafambn.kflate.Zlib +import com.rafambn.kflate.compression.Raw as CompressionRaw +import com.rafambn.kflate.decompression.Raw as DecompressionRaw +import com.rafambn.kflate.compression.Zlib as CompressionZlib +import com.rafambn.kflate.decompression.Zlib as DecompressionZlib import kotlin.js.ExperimentalJsExport +import kotlin.js.ExperimentalWasmJsInterop import kotlin.js.JsExport // Kotlin/WASM JS interop helpers — these run as inline JS, called from WASM @@ -32,7 +33,9 @@ private var lastError = "" @JsExport fun loadInput(arr: JsAny) { val len = jsLength(arr) + require(len <= MAX_INPUT_SIZE) { "Input exceeds the 64 MiB demo limit" } inputData = ByteArray(len) { i -> jsGet(arr, i).toByte() } + outputData = ByteArray(0) } @JsExport @@ -42,15 +45,18 @@ fun runCompress(format: String, level: Int): Int { outputData = KFlate.compress( inputData, when (format) { - "raw" -> RAW(level = level) - "gzip" -> GZIP(level = level) - else -> ZLIB(level = level) + "raw" -> CompressionRaw(level = level) + "gzip" -> CompressionGzip(level = level) + "zlib" -> CompressionZlib(level = level) + else -> error("Unsupported format: $format") } ) outputData.size } catch (e: Exception) { lastError = e.message ?: "Unknown error" -1 + } finally { + inputData = ByteArray(0) } } @@ -61,15 +67,18 @@ fun runDecompress(format: String): Int { outputData = KFlate.decompress( inputData, when (format) { - "raw" -> Raw() - "gzip" -> Gzip() - else -> Zlib() + "raw" -> DecompressionRaw(maxOutputSize = MAX_OUTPUT_SIZE) + "gzip" -> DecompressionGzip(maxOutputSize = MAX_OUTPUT_SIZE) + "zlib" -> DecompressionZlib(maxOutputSize = MAX_OUTPUT_SIZE) + else -> error("Unsupported format: $format") } ) outputData.size } catch (e: Exception) { lastError = e.message ?: "Unknown error" -1 + } finally { + inputData = ByteArray(0) } } @@ -79,8 +88,12 @@ fun getOutput(): JsAny { for (i in outputData.indices) { jsSet(arr, i, outputData[i].toInt() and 0xFF) } + outputData = ByteArray(0) return arr } @JsExport fun getLastError(): String = lastError + +private const val MAX_INPUT_SIZE = 64 * 1_024 * 1_024 +private const val MAX_OUTPUT_SIZE = 128 * 1_024 * 1_024 diff --git a/web-demo/src/wasmJsMain/resources/KFlate-Logo.svg b/web-demo/src/wasmJsMain/resources/KFlate-Logo.svg new file mode 100644 index 0000000..fd9acde --- /dev/null +++ b/web-demo/src/wasmJsMain/resources/KFlate-Logo.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/web-demo/src/wasmJsMain/resources/demo.js b/web-demo/src/wasmJsMain/resources/demo.js new file mode 100644 index 0000000..38865c8 --- /dev/null +++ b/web-demo/src/wasmJsMain/resources/demo.js @@ -0,0 +1,280 @@ + // Base URL so the blob-URL worker can resolve absolute imports + const BASE_URL = new URL('.', import.meta.url).href; + + // ── Web Worker (inline, loaded as ESM via blob URL) ────────────────────── + const workerSrc = ` +import * as kflate from '${BASE_URL}kflate-demo.mjs'; + +// Signal that the WASM module is initialised (top-level await has resolved) +self.postMessage({ type: 'ready' }); + +self.onmessage = ({ data: msg }) => { + const { id, action, input, format, level } = msg; + try { + kflate.loadInput(new Uint8Array(input)); + const size = action === 'compress' + ? kflate.runCompress(format, level ?? 6) + : kflate.runDecompress(format); + if (size < 0) { + self.postMessage({ id, error: kflate.getLastError() || 'Operation failed' }); + return; + } + const out = kflate.getOutput(); + self.postMessage({ id, result: out.buffer }, [out.buffer]); + } catch (e) { + self.postMessage({ id, error: e.message || String(e) }); + } +}; +`; + + const blobUrl = URL.createObjectURL( + new Blob([workerSrc], { type: 'application/javascript' }) + ); + const worker = new Worker(blobUrl, { type: 'module' }); + + const pending = new Map(); + let nextId = 1; + let wasmReady = false; + + worker.onmessage = ({ data }) => { + if (data.type === 'ready') { + URL.revokeObjectURL(blobUrl); + wasmReady = true; + dot.className = 'dot ready'; + statusText.textContent = 'WASM ready'; + updateBtn(); + return; + } + const request = pending.get(data.id); + if (request) { + pending.delete(data.id); + request.resolve(data); + } + }; + + function failWorker(message) { + URL.revokeObjectURL(blobUrl); + wasmReady = false; + dot.className = 'dot error'; + statusText.textContent = 'WASM unavailable'; + for (const { reject } of pending.values()) reject(new Error(message)); + pending.clear(); + showError(message); + updateBtn(); + } + + worker.onerror = e => failWorker( + 'WebAssembly worker failed: ' + (e.message || 'unknown error') + ); + worker.onmessageerror = () => failWorker('The WebAssembly worker returned an unreadable response.'); + + function callWorker(msg) { + return new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, { resolve, reject }); + const transfer = msg.input instanceof ArrayBuffer ? [msg.input] : []; + try { + worker.postMessage({ ...msg, id }, transfer); + } catch (error) { + pending.delete(id); + reject(error); + } + }); + } + + // ── DOM refs ───────────────────────────────────────────────────────────── + const dot = document.getElementById('wasm-dot'); + const statusText = document.getElementById('wasm-status-text'); + const tabs = document.querySelectorAll('.tab-btn'); + const levelGroup = document.getElementById('level-group'); + const levelInput = document.getElementById('level-input'); + const levelValue = document.getElementById('level-value'); + const dropZone = document.getElementById('drop-zone'); + const fileInputEl = document.getElementById('file-input'); + const dropHint = document.getElementById('drop-hint'); + const fileNameEl = document.getElementById('file-name'); + const fileSizeEl = document.getElementById('file-size'); + const processBtn = document.getElementById('process-btn'); + const spinner = document.getElementById('spinner'); + const errorMsg = document.getElementById('error-msg'); + const modalBackdrop = document.getElementById('modal-backdrop'); + const modalClose = document.getElementById('modal-close'); + + let mode = 'compress'; + let selectedFile = null; + let currentObjectUrl = null; + + // ── Tab switching ───────────────────────────────────────────────────────── + tabs.forEach(btn => btn.addEventListener('click', () => { + tabs.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + mode = btn.dataset.mode; + levelGroup.style.display = mode === 'compress' ? '' : 'none'; + processBtn.textContent = mode === 'compress' ? 'Compress' : 'Decompress'; + updateBtn(); + })); + + // ── Level slider ────────────────────────────────────────────────────────── + levelInput.addEventListener('input', () => { + levelValue.textContent = levelInput.value; + }); + + // ── File drag / drop / pick ─────────────────────────────────────────────── + const MAX_INPUT_BYTES = 64 * 1024 * 1024; + + dropZone.addEventListener('click', () => fileInputEl.click()); + + dropZone.addEventListener('dragover', e => { + e.preventDefault(); + dropZone.classList.add('drag-over'); + }); + + dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); + + dropZone.addEventListener('drop', e => { + e.preventDefault(); + dropZone.classList.remove('drag-over'); + const f = e.dataTransfer.files[0]; + if (f) setFile(f); + }); + + fileInputEl.addEventListener('change', () => { + if (fileInputEl.files[0]) setFile(fileInputEl.files[0]); + }); + + function setFile(f) { + hideError(); + if (f.size > MAX_INPUT_BYTES) { + clearSelectedFile(); + showError(`File is too large (${fmtSize(f.size)}). Maximum allowed size is 64 [MiB].`); + return; + } + selectedFile = f; + dropHint.style.display = 'none'; + fileNameEl.style.display = ''; + fileSizeEl.style.display = ''; + fileNameEl.textContent = f.name; + fileSizeEl.textContent = fmtSize(f.size); + updateBtn(); + } + + function clearSelectedFile() { + selectedFile = null; + fileInputEl.value = ''; + dropHint.style.display = ''; + fileNameEl.style.display = 'none'; + fileSizeEl.style.display = 'none'; + updateBtn(); + } + + // ── Process ─────────────────────────────────────────────────────────────── + processBtn.addEventListener('click', async () => { + if (!selectedFile || !wasmReady) return; + hideError(); + setProcessing(true); + + const file = selectedFile; + const action = mode; + const format = document.querySelector('input[name=format]:checked').value; + const level = parseInt(levelInput.value, 10); + const t0 = performance.now(); + try { + const buf = await file.arrayBuffer(); + + const reply = await callWorker({ action, input: buf, format, level }); + + if (reply.error) { + showErrorModal(reply.error, action); + return; + } + + const elapsed = performance.now() - t0; + showResult(reply.result, file.size, reply.result.byteLength, elapsed, file.name, format, action); + } catch (e) { + showErrorModal(e.message || String(e), action); + } finally { + setProcessing(false); + } + }); + + // ── Result modal ────────────────────────────────────────────────────────── + function showResult(buffer, inBytes, outBytes, ms, origName, format, action) { + if (currentObjectUrl) URL.revokeObjectURL(currentObjectUrl); + const blob = new Blob([buffer]); + currentObjectUrl = URL.createObjectURL(blob); + const dlName = outFilename(origName, format, action); + + document.getElementById('modal-title').innerHTML = + action === 'compress' ? 'Compressed successfully' : 'Decompressed successfully'; + document.getElementById('modal-in').textContent = fmtSize(inBytes); + document.getElementById('modal-out').textContent = fmtSize(outBytes); + document.getElementById('modal-ratio').textContent = + inBytes > 0 ? ((outBytes / inBytes) * 100).toFixed(1) + ' [%]' : '—'; + document.getElementById('modal-time').textContent = + ms >= 1000 ? (ms / 1000).toFixed(2) + ' [s]' : Math.round(ms) + ' [ms]'; + + const dl = document.getElementById('modal-dl'); + dl.href = currentObjectUrl; + dl.download = dlName; + dl.textContent = '⬇ Save ' + dlName; + + modalBackdrop.classList.remove('error'); + modalBackdrop.classList.add('open'); + } + + function showErrorModal(msg, action) { + document.getElementById('modal-title').innerHTML = + action === 'compress' ? 'Compression failed' : 'Decompression failed'; + document.getElementById('modal-error-text').textContent = msg; + modalBackdrop.classList.add('error'); + modalBackdrop.classList.add('open'); + } + + function closeModal() { + modalBackdrop.classList.remove('open'); + modalBackdrop.classList.remove('error'); + if (currentObjectUrl) { + URL.revokeObjectURL(currentObjectUrl); + currentObjectUrl = null; + } + } + + modalClose.addEventListener('click', closeModal); + modalBackdrop.addEventListener('click', e => { if (e.target === modalBackdrop) closeModal(); }); + document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); + + // ── Helpers ─────────────────────────────────────────────────────────────── + function outFilename(name, format, mode) { + if (mode === 'compress') { + const ext = { raw: '.deflate', gzip: '.gz', zlib: '.zlib' }[format] ?? '.bin'; + return name + ext; + } + return name.replace(/\.(gz|gzip|zlib|deflate|zz)$/i, '') || name + '.out'; + } + + function setProcessing(on) { + spinner.style.display = on ? 'block' : 'none'; + if (on) processBtn.disabled = true; + else updateBtn(); + } + + function updateBtn() { + processBtn.disabled = !(wasmReady && selectedFile); + } + + function showError(msg) { + errorMsg.textContent = msg; + errorMsg.style.display = ''; + } + + function hideError() { + errorMsg.style.display = 'none'; + } + + // ── Utilities ───────────────────────────────────────────────────────────── + function fmtSize(bytes) { + if (bytes < 1024) return bytes + ' [bytes]'; + if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + ' [KiB]'; + if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(2) + ' [MiB]'; + return (bytes / 1024 ** 3).toFixed(2) + ' [GiB]'; + } diff --git a/web-demo/src/wasmJsMain/resources/fonts/GothicA1-Bold.ttf b/web-demo/src/wasmJsMain/resources/fonts/GothicA1-Bold.ttf new file mode 100644 index 0000000..e07f55b Binary files /dev/null and b/web-demo/src/wasmJsMain/resources/fonts/GothicA1-Bold.ttf differ diff --git a/web-demo/src/wasmJsMain/resources/fonts/GothicA1-OFL.txt b/web-demo/src/wasmJsMain/resources/fonts/GothicA1-OFL.txt new file mode 100644 index 0000000..05f9673 --- /dev/null +++ b/web-demo/src/wasmJsMain/resources/fonts/GothicA1-OFL.txt @@ -0,0 +1,93 @@ +(C) Copyright HanYang I&C Co.,Ltd. All rights reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web-demo/src/wasmJsMain/resources/fonts/Inter-OFL.txt b/web-demo/src/wasmJsMain/resources/fonts/Inter-OFL.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/web-demo/src/wasmJsMain/resources/fonts/Inter-OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web-demo/src/wasmJsMain/resources/fonts/InterVariable.woff2 b/web-demo/src/wasmJsMain/resources/fonts/InterVariable.woff2 new file mode 100644 index 0000000..5a8d3e7 Binary files /dev/null and b/web-demo/src/wasmJsMain/resources/fonts/InterVariable.woff2 differ diff --git a/web-demo/src/wasmJsMain/resources/fonts/JetBrainsMono-Regular.woff2 b/web-demo/src/wasmJsMain/resources/fonts/JetBrainsMono-Regular.woff2 new file mode 100644 index 0000000..66c5467 Binary files /dev/null and b/web-demo/src/wasmJsMain/resources/fonts/JetBrainsMono-Regular.woff2 differ diff --git a/web-demo/src/wasmJsMain/resources/fonts/OFL.txt b/web-demo/src/wasmJsMain/resources/fonts/OFL.txt new file mode 100644 index 0000000..5ceee00 --- /dev/null +++ b/web-demo/src/wasmJsMain/resources/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web-demo/src/wasmJsMain/resources/index.html b/web-demo/src/wasmJsMain/resources/index.html index beda98f..bd50ccf 100644 --- a/web-demo/src/wasmJsMain/resources/index.html +++ b/web-demo/src/wasmJsMain/resources/index.html @@ -1,654 +1,22 @@ - - - - - - KFlate — DEFLATE/GZIP/ZLIB in your browser - - + +KFlate · Compression demo - -
-

KFlate

-

Pure Kotlin DEFLATE · GZIP · ZLIB — running in WebAssembly

-
- -
-
- - Loading WASM… -
- -
- - -
- - -
- - - -
- -
- - -
- -
- -

Drop a file here or click to choose

- - -
- - - -
- -
-
- - - - - - - - - +
+
+ +
+
+
+

Input file

Result ↓
+

or drop it here · Up to 64 MiB

+
Format
+
6
+ +
+

Result

Run compression to inspect the result

Input size
—
Output size
—
Ratio
—
Time
—
Download
+
+ +
+ + + diff --git a/web-demo/src/wasmJsMain/resources/report.css b/web-demo/src/wasmJsMain/resources/report.css new file mode 100644 index 0000000..b34425b --- /dev/null +++ b/web-demo/src/wasmJsMain/resources/report.css @@ -0,0 +1,26 @@ +@font-face{font-family:Inter;src:url("fonts/InterVariable.woff2") format("woff2");font-weight:100 900;font-display:swap} +:root{color-scheme:dark;--bg:#0c1b2b;--card:#182838;--inset:#0f1f2f;--border:#37485a;--accent:#00b4db;--accent-hover:#38cdeb;--text:#f1f2f6;--muted:#b7c2d1;--success:#00e676;--error:#ffaaa8;font-family:Inter,system-ui,sans-serif;font-size:20px;background:var(--bg);color:var(--text)} +*{box-sizing:border-box}body,h1,h2,p,fieldset,dl,dd{margin:0}button,input,select{font:inherit}button,a,input{touch-action:manipulation}button{cursor:pointer}button:disabled{cursor:not-allowed}a{color:var(--accent);text-underline-offset:4px}button{transition:background-color 160ms ease-out,border-color 160ms ease-out}::selection{background:#006b86;color:#fff}:focus-visible{outline:3px solid var(--accent-hover);outline-offset:4px}[hidden]{display:none!important}input{accent-color:var(--accent);caret-color:var(--accent)}.icon{width:24px;height:24px;flex:none} +.opening{min-height:100vh;min-height:100dvh;display:grid;grid-template-rows:1fr auto}.demo-shell{min-height:100%;display:flex;flex-direction:column}.site-header{display:flex;align-items:center;justify-content:space-between;gap:24px;min-height:156px;padding:24px 44px;border-bottom:1px solid var(--border)}.brand{display:flex;align-items:center;gap:18px;text-decoration:none;color:var(--text);min-width:0}.brand img{object-fit:contain;flex:none;width:88px;height:100px}.brand h1{font-size:2rem;font-weight:600;line-height:1.2;letter-spacing:-.03em}.brand p{font-size:1rem;color:var(--muted);margin-top:4px;line-height:1.5}.header-links{display:flex;align-items:center;gap:48px}.github{display:flex;align-items:center;gap:12px;text-decoration:none;color:var(--text)}.github .icon{width:30px;height:30px}.github:hover{color:var(--accent-hover)}.wasm-status{display:flex;align-items:center;gap:12px;white-space:nowrap}.dot{width:20px;height:20px;border-radius:50%;background:var(--muted)}.dot.ready{background:var(--success)}.dot.error{background:var(--error)} +.report-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;margin:30px 44px 0;flex:1}.panel{background:var(--card);border:1px solid var(--border);border-radius:12px;min-width:0}.input-panel{padding:12px 38px 24px;display:flex;flex-direction:column}.modes{display:flex;min-height:56px;border:1px solid var(--border);border-radius:6px;overflow:hidden;background:var(--inset)}.tab-btn{flex:1;min-width:0;border:0;border-bottom:2px solid transparent;padding:10px;background:transparent;color:var(--muted);font-size:1.2rem;line-height:1.25}.tab-btn+.tab-btn{border-left:1px solid var(--border)}.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}.tab-btn:hover{background:#153144}.tab-btn:focus-visible{outline-offset:-4px}.input-title{margin:20px 0 16px}h2,.format-group legend,.level-label{font-size:1.25rem;font-weight:500;line-height:1.25;letter-spacing:-.02em} +.drop-zone{min-height:224px;padding:18px;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:14px;text-align:center;border:1px dashed #627084;border-radius:12px;background:var(--inset)}.file-icon{width:60px;height:66px;color:var(--muted)}.choose-button,.primary{border:1px solid #00c5e8;border-radius:6px;background:var(--accent);color:#041723;font-weight:500;padding:12px 24px;line-height:1.3}.choose-button{min-height:56px;font-size:1.15rem}.choose-button:hover,.primary:hover:not(:disabled){background:var(--accent-hover)}.drop-zone.drag-over{border-color:var(--accent);background:#12374b}.drop-zone p{color:var(--muted);line-height:1.4}#file-name{color:var(--text);font-weight:500;overflow-wrap:anywhere;max-width:100%}#file-size{font-size:.9rem}.drop-zone.has-file .file-icon{display:none} +.format-group{border:0;padding:0;margin:24px 0 0;min-width:0}.format-group legend{padding:0;margin-bottom:12px}.format-options{display:flex;border:1px solid var(--border);border-radius:6px;background:var(--inset);min-height:56px;overflow:hidden}.format-options label{position:relative;flex:1;min-width:0;cursor:pointer}.format-options input{position:absolute;opacity:0;width:1px;height:1px}.format-options span{display:grid;place-items:center;min-height:54px;padding:8px;line-height:1.2;color:var(--muted);border:1px solid transparent}.format-options input:checked+span{color:var(--accent);background:#063349;border-color:var(--accent)}.format-options input:focus-visible+span{outline:3px solid var(--accent-hover);outline-offset:-4px}.format-options label:hover span{background:#153144} +#level-group{margin-top:26px}.level-label{display:block;margin-bottom:8px}.level-controls{display:flex;align-items:center;gap:26px;min-height:56px}.level-controls input{min-width:0;flex:1;width:100%;height:32px;appearance:none;background:transparent;--fill:66.6667%}.level-controls input::-webkit-slider-runnable-track{height:6px;border-radius:4px;background:linear-gradient(to right,var(--accent) var(--fill),var(--border) var(--fill))}.level-controls input::-webkit-slider-thumb{appearance:none;width:24px;height:24px;border:1px solid #00c9ec;border-radius:50%;background:var(--accent);margin-top:-9px}.level-controls input::-moz-range-track{height:6px;border-radius:4px;background:var(--border)}.level-controls input::-moz-range-progress{height:6px;background:var(--accent)}.level-controls input::-moz-range-thumb{width:24px;height:24px;border:1px solid #00c9ec;border-radius:50%;background:var(--accent)}#level-value{display:grid;place-items:center;min-width:74px;height:56px;border:1px solid var(--border);border-radius:6px;background:var(--inset);font-size:1.3rem;font-variant-numeric:tabular-nums}.primary{width:100%;font-size:1.3rem;min-height:62px;margin-top:20px}.primary:disabled{background:#246378;border-color:#327c90;color:#c5d8dd}.error-msg{color:var(--error);font-size:.95rem;line-height:1.5;margin-top:14px} +.result-panel{padding:62px 34px 30px;display:flex;flex-direction:column}.result-summary{min-height:224px;margin-top:24px;border:1px dashed var(--border);border-radius:12px;background:var(--inset);padding:24px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:24px;text-align:center;color:var(--muted)}.result-icon{width:66px;height:78px}.result-summary p{line-height:1.45}#output-name{font-size:.95rem;overflow-wrap:anywhere;max-width:100%;color:var(--text)}.result-stats{margin-top:28px;font-variant-numeric:tabular-nums}.result-stats>div{display:grid;grid-template-columns:1fr 1fr;gap:16px;min-height:58px;align-items:center;border-bottom:1px solid var(--border);padding:10px 0}.result-stats dd{overflow-wrap:anywhere}.download{display:flex;align-items:center;gap:12px;min-height:60px;align-self:flex-start;margin-top:20px;padding:12px 20px;border:1px solid var(--border);border-radius:6px;text-decoration:none;color:var(--text)}.download.disabled{color:#8f9eb1;cursor:not-allowed}.download:not(.disabled){border-color:var(--accent);color:var(--accent)}.download:not(.disabled):hover{background:#12374b}.result-panel[data-state=success] .result-summary{border-color:#398b77}.result-panel[data-state=success] .result-icon{color:var(--success)}.result-panel[data-state=error] .result-summary{border-color:#a55d60}.result-panel[data-state=error] #result-status{color:var(--error)}.result-panel[data-state=processing] .result-icon{animation:working 1s ease-in-out infinite alternate}@keyframes working{to{opacity:.35}} +.opening-footer{display:flex;justify-content:center;align-items:center;min-height:98px;padding:20px 44px max(20px,env(safe-area-inset-bottom))}.privacy{display:flex;align-items:center;gap:14px;color:var(--muted);white-space:nowrap}.site-footer{display:flex;justify-content:center;gap:28px;padding:24px 20px 40px;font-size:.9rem} +@media(min-width:1800px){.report-grid{width:1496px;align-self:center;margin-left:0;margin-right:0}.site-header{padding-left:max(44px,calc((100% - 1496px)/2));padding-right:max(44px,calc((100% - 1496px)/2))}} +@media(max-width:1200px){.site-header{padding:20px 28px;min-height:124px}.brand img{width:64px;height:78px}.brand h1{font-size:1.8rem}.brand p{font-size:.9rem}.header-links{gap:24px}.report-grid{margin:24px 28px 0}.input-panel{padding:16px 24px 24px}.result-panel{padding:64px 24px 24px}.level-controls{gap:18px}.opening-footer{padding-left:28px;padding-right:28px}.privacy{font-size:.85rem}} +@media(max-height:850px) and (min-width:901px){.site-header{min-height:108px;padding-top:12px;padding-bottom:12px}.brand img{height:78px;width:70px}.report-grid{margin-top:20px}.drop-zone,.result-summary{min-height:166px;padding:16px;gap:12px}.file-icon{height:44px;width:42px}.result-icon{height:50px;width:46px}.input-title{margin:16px 0 12px}.format-group{margin-top:20px}#level-group{margin-top:20px}.result-panel{padding-top:60px}.result-stats{margin-top:20px}.result-stats>div{min-height:50px}.opening-footer{min-height:78px;padding-top:12px;padding-bottom:12px}} +@media(max-width:900px){.header-links{gap:16px;flex-direction:column;align-items:flex-end;font-size:.9rem}.report-grid{grid-template-columns:1fr;max-width:680px;width:calc(100% - 40px);margin:20px auto 0}.site-header{padding:16px 20px;gap:16px}.brand{gap:10px}.brand img{width:54px;height:64px}.brand h1{font-size:1.7rem}.brand p{font-size:.8rem;max-width:300px}.dot{width:12px;height:12px}.github .icon{width:24px;height:24px}.input-panel{padding:20px 24px 24px}.result-panel{padding:24px}.result-summary{min-height:160px;margin-top:20px}.result-stats{margin-top:20px}.drop-zone{min-height:190px}.file-icon{width:44px;height:50px}.opening-footer{min-height:68px;padding:12px 20px}.site-footer{flex-wrap:wrap}} +@media(max-width:480px){:root{font-size:18px}.site-header{align-items:flex-start}.brand p{max-width:200px;font-size:.85rem}.brand img{width:40px;height:50px}.brand h1{font-size:1.5rem}.header-links{font-size:.8rem;gap:14px}.wasm-status{gap:6px}.github{gap:7px}.report-grid{width:calc(100% - 28px)}.input-panel,.result-panel{padding:16px}.tab-btn{font-size:1.05rem}.drop-zone{padding:16px 10px}h2,.format-group legend,.level-label{font-size:1.15rem}.level-controls{gap:12px}#level-value{min-width:52px}.format-options span{font-size:.95rem;padding:8px 4px}.result-stats>div{grid-template-columns:1fr 1fr}.brand{align-items:flex-start}} +@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}} +@font-face{font-family:"Gothic A1";src:url("fonts/GothicA1-Bold.ttf") format("truetype");font-style:normal;font-weight:700;font-display:swap} +.brand h1{font-family:"Gothic A1",Inter,sans-serif;font-weight:700;font-size:37px;letter-spacing:-.02em} +.choose-button{min-width:237px}.primary:disabled{background:#089cbc;border-color:#00b4db;color:#06212b}.format-options input:checked+span{background:var(--inset)} +.result-icon{height:86px;width:78px;stroke-width:1.1}.download .icon{width:30px;height:30px} +@media(max-width:900px){.result-icon{height:60px;width:56px}.brand h1{font-size:30px}} +@media(max-width:480px){.brand h1{font-size:27px}.choose-button{min-width:0;width:auto}} + +.brand>div{transform:translateY(6px)}.file-icon{width:66px;height:74px}.result-icon{width:86px;height:92px}.result-jump{display:none} +@media(max-height:980px) and (min-width:901px){.site-header{min-height:104px;padding-top:10px;padding-bottom:10px}.brand img{height:78px;width:70px}.report-grid{margin-top:16px}.input-panel{padding-top:12px;padding-bottom:16px}.input-title{margin:12px 0}.drop-zone,.result-summary{min-height:156px;padding:12px;gap:10px}.file-icon{height:40px;width:40px}.choose-button{min-height:48px;padding:8px 24px}.format-group{margin-top:16px}.format-group legend{margin-bottom:8px}#level-group{margin-top:16px}.primary{margin-top:12px;min-height:56px}.result-panel{padding-top:52px;padding-bottom:16px}.result-summary{margin-top:20px}.result-icon{height:54px;width:54px}.result-stats{margin-top:16px}.result-stats>div{min-height:48px}.download{min-height:56px;margin-top:16px}.opening-footer{min-height:72px;padding-top:10px;padding-bottom:10px}} +@media(max-width:900px){.input-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.result-jump{display:inline-flex;align-items:center;min-height:44px;font-size:.9rem}.brand>div{transform:none}.file-icon{height:50px;width:44px}.result-icon{height:60px;width:56px}.drop-zone{min-height:164px}.format-group{margin-top:18px}#level-group{margin-top:18px}.input-title{margin:14px 0}} diff --git a/web-demo/src/wasmJsMain/resources/report.js b/web-demo/src/wasmJsMain/resources/report.js new file mode 100644 index 0000000..a6e46b4 --- /dev/null +++ b/web-demo/src/wasmJsMain/resources/report.js @@ -0,0 +1,275 @@ + // Base URL so the blob-URL worker can resolve absolute imports + const BASE_URL = new URL('.', import.meta.url).href; + + // ── Web Worker (inline, loaded as ESM via blob URL) ────────────────────── + const workerSrc = ` +import * as kflate from '${BASE_URL}kflate-demo.mjs'; + +// Signal that the WASM module is initialised (top-level await has resolved) +self.postMessage({ type: 'ready' }); + +self.onmessage = ({ data: msg }) => { + const { id, action, input, format, level } = msg; + try { + kflate.loadInput(new Uint8Array(input)); + const size = action === 'compress' + ? kflate.runCompress(format, level ?? 6) + : kflate.runDecompress(format); + if (size < 0) { + self.postMessage({ id, error: kflate.getLastError() || 'Operation failed' }); + return; + } + const out = kflate.getOutput(); + self.postMessage({ id, result: out.buffer }, [out.buffer]); + } catch (e) { + self.postMessage({ id, error: e.message || String(e) }); + } +}; +`; + + const blobUrl = URL.createObjectURL( + new Blob([workerSrc], { type: 'application/javascript' }) + ); + const worker = new Worker(blobUrl, { type: 'module' }); + + const pending = new Map(); + let nextId = 1; + let wasmReady = false; + + worker.onmessage = ({ data }) => { + if (data.type === 'ready') { + URL.revokeObjectURL(blobUrl); + wasmReady = true; + dot.className = 'dot ready'; + statusText.textContent = 'WASM ready'; + updateBtn(); + return; + } + const request = pending.get(data.id); + if (request) { + pending.delete(data.id); + request.resolve(data); + } + }; + + function failWorker(message) { + URL.revokeObjectURL(blobUrl); + wasmReady = false; + dot.className = 'dot error'; + statusText.textContent = 'WASM unavailable'; + for (const { reject } of pending.values()) reject(new Error(message)); + pending.clear(); + showError(message); + updateBtn(); + } + + worker.onerror = e => failWorker( + 'WebAssembly worker failed: ' + (e.message || 'unknown error') + ); + worker.onmessageerror = () => failWorker('The WebAssembly worker returned an unreadable response.'); + + function callWorker(msg) { + return new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, { resolve, reject }); + const transfer = msg.input instanceof ArrayBuffer ? [msg.input] : []; + try { + worker.postMessage({ ...msg, id }, transfer); + } catch (error) { + pending.delete(id); + reject(error); + } + }); + } + + +const byId = id => document.getElementById(id); +const dot = byId('wasm-dot'); +const statusText = byId('wasm-status-text'); +const processButton = byId('process-btn'); +const picker = byId('file-input'); +const dropZone = byId('drop-zone'); +const chooseButton = byId('choose-file'); +const errorMessage = byId('error-msg'); +const resultPanel = document.querySelector('.result-panel'); +const resultStatus = byId('result-status'); +const download = byId('download-result'); +const levelInput = byId('level-input'); +const operationButtons = [...document.querySelectorAll('.tab-btn')]; +const formatInputs = [...document.querySelectorAll('input[name="format"]')]; +const MAX_INPUT_BYTES = 64 * 1024 * 1024; +let selectedFile = null; +let mode = 'compress'; +let processing = false; +let resultUrl = null; + +function updateBtn() { + processButton.disabled = !wasmReady || !selectedFile || processing; +} + +function showError(message) { + errorMessage.textContent = message; + errorMessage.hidden = false; +} + +function hideError() { + errorMessage.hidden = true; + errorMessage.textContent = ''; +} + +function clearResult() { + if (resultUrl) URL.revokeObjectURL(resultUrl); + resultUrl = null; + download.removeAttribute('href'); + download.removeAttribute('download'); + download.classList.add('disabled'); + download.setAttribute('aria-disabled', 'true'); + download.tabIndex = -1; + for (const id of ['result-in', 'result-out', 'result-ratio', 'result-time']) byId(id).textContent = '—'; + byId('output-name').hidden = true; + byId('output-name').textContent = ''; + resultPanel.dataset.state = 'empty'; + resultStatus.textContent = `Run ${mode === 'compress' ? 'compression' : 'decompression'} to inspect the result`; +} + +function selectFile(file) { + if (processing) return; + hideError(); + clearResult(); + if (file.size > MAX_INPUT_BYTES) { + selectedFile = null; + picker.value = ''; + dropZone.classList.remove('has-file'); + byId('file-name').hidden = true; + byId('file-size').hidden = true; + byId('drop-hint').hidden = false; + chooseButton.textContent = 'Choose a file'; + showError(`This file is ${formatSize(file.size)}. Choose a file no larger than 64 MiB.`); + } else { + selectedFile = file; + dropZone.classList.add('has-file'); + byId('file-name').textContent = file.name; + byId('file-name').hidden = false; + byId('file-size').textContent = formatSize(file.size); + byId('file-size').hidden = false; + byId('drop-hint').hidden = true; + chooseButton.textContent = 'Change file'; + } + updateBtn(); +} + +chooseButton.addEventListener('click', () => picker.click()); +picker.addEventListener('change', () => { + if (picker.files[0]) selectFile(picker.files[0]); +}); +for (const name of ['dragenter', 'dragover']) dropZone.addEventListener(name, event => { + event.preventDefault(); + if (!processing) dropZone.classList.add('drag-over'); +}); +dropZone.addEventListener('dragleave', event => { + if (!dropZone.contains(event.relatedTarget)) dropZone.classList.remove('drag-over'); +}); +dropZone.addEventListener('drop', event => { + event.preventDefault(); + dropZone.classList.remove('drag-over'); + if (event.dataTransfer.files[0]) selectFile(event.dataTransfer.files[0]); +}); +// Keep a dropped file outside the target from replacing the current page. +document.addEventListener('dragover', event => { + if (event.dataTransfer.types.includes('Files')) event.preventDefault(); +}); +document.addEventListener('drop', event => { + if (event.dataTransfer.types.includes('Files')) event.preventDefault(); +}); + +operationButtons.forEach(button => button.addEventListener('click', () => { + if (processing || mode === button.dataset.mode) return; + mode = button.dataset.mode; + operationButtons.forEach(other => { + other.classList.toggle('active', other === button); + other.setAttribute('aria-pressed', String(other === button)); + }); + byId('level-group').hidden = mode === 'decompress'; + processButton.textContent = mode === 'compress' ? 'Compress' : 'Decompress'; + hideError(); + clearResult(); +})); +formatInputs.forEach(input => input.addEventListener('change', () => { + hideError(); + clearResult(); +})); +levelInput.addEventListener('input', () => { + byId('level-value').textContent = levelInput.value; + levelInput.style.setProperty('--fill', `${Number(levelInput.value) / 9 * 100}%`); + hideError(); + clearResult(); +}); +download.addEventListener('click', event => { + if (!resultUrl) event.preventDefault(); +}); + +processButton.addEventListener('click', async () => { + if (!selectedFile || !wasmReady || processing) return; + hideError(); + clearResult(); + const file = selectedFile; + const format = formatInputs.find(input => input.checked).value; + const level = Number(levelInput.value); + setProcessing(true); + resultStatus.textContent = mode === 'compress' ? 'Compressing file…' : 'Decompressing file…'; + resultPanel.dataset.state = 'processing'; + const start = performance.now(); + try { + const input = await file.arrayBuffer(); + const reply = await callWorker({action: mode, input, format, level}); + if (reply.error) throw new Error(reply.error); + const elapsed = performance.now() - start; + const output = reply.result; + resultUrl = URL.createObjectURL(new Blob([output])); + const filename = outputFilename(file.name, format, mode); + byId('result-in').textContent = formatSize(file.size); + byId('result-out').textContent = formatSize(output.byteLength); + byId('result-ratio').textContent = file.size ? `${(output.byteLength / file.size * 100).toFixed(1)}%` : '—'; + byId('result-time').textContent = elapsed >= 1000 ? `${(elapsed / 1000).toFixed(2)} s` : `${elapsed.toFixed(1)} ms`; + byId('output-name').textContent = filename; + byId('output-name').hidden = false; + download.href = resultUrl; + download.download = filename; + download.classList.remove('disabled'); + download.removeAttribute('aria-disabled'); + download.removeAttribute('tabindex'); + resultPanel.dataset.state = 'success'; + resultStatus.textContent = mode === 'compress' ? 'Compression complete' : 'Decompression complete'; + } catch (error) { + resultPanel.dataset.state = 'error'; + resultStatus.textContent = `${mode === 'compress' ? 'Compression' : 'Decompression'} failed`; + const recovery = mode === 'decompress' ? 'Check that the selected format matches the file, or choose another file.' : 'Try another file. If the problem continues, reload the demo.'; + showError(`${error.message || 'The operation could not finish.'} ${recovery}`); + } finally { + setProcessing(false); + } +}); + +function setProcessing(value) { + processing = value; + resultPanel.setAttribute('aria-busy', String(value)); + for (const control of [chooseButton, picker, levelInput, ...operationButtons, ...formatInputs]) control.disabled = value; + processButton.textContent = value ? (mode === 'compress' ? 'Compressing…' : 'Decompressing…') : (mode === 'compress' ? 'Compress' : 'Decompress'); + updateBtn(); +} + +function outputFilename(name, format, action) { + if (action === 'compress') return name + {raw: '.deflate', gzip: '.gz', zlib: '.zlib'}[format]; + const stripped = name.replace(/\.(gz|gzip|zlib|deflate|zz)$/i, ''); + return stripped && stripped !== name ? stripped : name + '.out'; +} + +function formatSize(bytes) { + if (bytes < 1024) return `${bytes} bytes`; + if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KiB`; + return `${(bytes / 1024 ** 2).toFixed(2)} MiB`; +} + +window.addEventListener('pagehide', () => { + if (resultUrl) URL.revokeObjectURL(resultUrl); +});