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 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.
-### 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
-
-
-
-
-
-
- Loading WASM…
-
-
-
- Compress
- Decompress
-
-
-
Format
-
- Raw (DEFLATE)
- GZIP
- ZLIB
-
-
-
- Level: 6
-
-
-
-
-
-
Drop a file here or click to choose
-
-
-
-
-
Compress
-
-
-
-
-
-
-
-
-
-
✕
-
Done!
-
-
- Input
- Output
- Ratio
- Time
-
-
⬇ Save file
-
-
-
-
-
-
-
-
+
+
+
+