Initial import of apache-commons-io from upstream master am: d3c12233f2

Original change: https://android-review.googlesource.com/c/platform/external/apache-commons-io/+/2434333

Change-Id: I6e8ea5c3a42a1077c5806559495813423a5caf1f
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
diff --git a/.asf.yaml b/.asf.yaml
new file mode 100644
index 0000000..bce78d4
--- /dev/null
+++ b/.asf.yaml
@@ -0,0 +1,29 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+github:
+  description: "Apache Commons IO"
+  homepage: https://commons.apache.org/io/
+
+notifications:
+    commits:      commits@commons.apache.org
+    issues:       issues@commons.apache.org
+    pullrequests: issues@commons.apache.org
+    jira_options: link label
+    jobs:         notifications@commons.apache.org
+    issues_bot_dependabot: notifications@commons.apache.org
+    pullrequests_bot_dependabot: notifications@commons.apache.org
+    issues_bot_codecov-commenter: notifications@commons.apache.org
+    pullrequests_bot_codecov-commenter: notifications@commons.apache.org
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..288cfe5
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,27 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# Auto detect text files and perform LF normalization
+*        text=auto
+
+*.java   text diff=java
+*.html   text diff=html
+*.css    text
+*.js     text
+*.sql    text
+
+# Exclude test files for line-ending related tests from normalization (IO-520)
+*.dat binary
diff --git a/.github/GH-ROBOTS.txt b/.github/GH-ROBOTS.txt
new file mode 100644
index 0000000..e3329e5
--- /dev/null
+++ b/.github/GH-ROBOTS.txt
@@ -0,0 +1,19 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Keeps on creating FUD PRs in test code
+# Does not follow Apache disclosure policies
+User-agent: JLLeitschuh/security-research
+Disallow: *
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..9ebcd0e
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,27 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+version: 2
+updates:
+  - package-ecosystem: "maven"
+    directory: "/"
+    schedule:
+      interval: "weekly"
+      day: "friday"
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "weekly"
+      day: "friday"
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..8b890bb
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,85 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ master ]
+  pull_request:
+    # The branches below must be a subset of the branches above
+    branches: [ master ]
+  schedule:
+    - cron: '33 9 * * 4'
+
+permissions:
+  contents: read
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+    permissions:
+      actions: read
+      contents: read
+      security-events: write
+
+    strategy:
+      fail-fast: false
+      matrix:
+        language: [ 'java' ]
+        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
+        # Learn more about CodeQL language support at https://git.io/codeql-language-support
+
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v3.3.0
+      with:
+        persist-credentials: false
+    - uses: actions/cache@v3.2.4
+      with:
+        path: ~/.m2/repository
+        key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+        restore-keys: |
+          ${{ runner.os }}-maven-
+
+    # Initializes the CodeQL tools for scanning.
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v2
+      with:
+        languages: ${{ matrix.language }}
+        # If you wish to specify custom queries, you can do so here or in a config file.
+        # By default, queries listed here will override any specified in a config file.
+        # Prefix the list here with "+" to use these queries and those in the config file.
+        # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
+    # If this step fails, then you should remove it and run the build manually (see below)
+    - name: Autobuild
+      uses: github/codeql-action/autobuild@v2
+
+    # ℹ️ Command-line programs to run using the OS shell.
+    # 📚 https://git.io/JvXDl
+
+    # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
+    #    and modify them (or add more) to build your code if your project
+    #    uses a compiled language
+
+    #- run: |
+    #   make bootstrap
+    #   make release
+
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v2
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
new file mode 100644
index 0000000..861208b
--- /dev/null
+++ b/.github/workflows/coverage.yml
@@ -0,0 +1,52 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: Coverage
+
+on: [push, pull_request]
+
+permissions:
+  contents: read
+
+jobs:
+  build:
+
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        java: [ 8 ]
+
+    steps:
+    - uses: actions/checkout@v3.3.0
+      with:
+        persist-credentials: false
+    - uses: actions/cache@v3.2.4
+      with:
+        path: ~/.m2/repository
+        key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+        restore-keys: |
+          ${{ runner.os }}-maven-
+    - name: Set up JDK ${{ matrix.java }}
+      uses: actions/setup-java@v3.10.0
+      with:
+        distribution: 'temurin'
+        java-version: ${{ matrix.java }}
+    - name: Build with Maven
+      run: mvn -V test jacoco:report --file pom.xml --no-transfer-progress
+
+    - name: Upload coverage to Codecov
+      uses: codecov/codecov-action@v3
+      with:
+        files: ./target/site/jacoco/jacoco.xml
diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
new file mode 100644
index 0000000..0205fb7
--- /dev/null
+++ b/.github/workflows/maven.yml
@@ -0,0 +1,61 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: Java CI
+
+on: [push, pull_request]
+
+permissions:
+  contents: read
+
+jobs:
+  build:
+
+    runs-on: ${{ matrix.os }}
+    continue-on-error: ${{ matrix.experimental }}
+    strategy:
+      matrix:
+        os: [ubuntu-latest, windows-latest, macos-latest]
+        java: [ 8, 11, 17 ]
+        experimental: [false]
+#        include:
+#          - java: 18-ea
+#            os: ubuntu-latest
+#            experimental: true        
+#          - java: 18-ea
+#            os: windows-latest
+#            experimental: true        
+#          - java: 18-ea
+#            os: macos-latest
+#            experimental: true        
+      fail-fast: false
+        
+    steps:
+    - uses: actions/checkout@v3.3.0
+      with:
+        persist-credentials: false
+    - uses: actions/cache@v3.2.4
+      with:
+        path: ~/.m2/repository
+        key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+        restore-keys: |
+          ${{ runner.os }}-maven-
+    - name: Set up JDK ${{ matrix.java }}
+      uses: actions/setup-java@v3.10.0
+      with:
+        distribution: 'temurin'
+        java-version: ${{ matrix.java }}
+    - name: Build with Maven
+      run: mvn -V --file pom.xml --no-transfer-progress -DtrimStackTrace=false
diff --git a/.github/workflows/scorecards-analysis.yml b/.github/workflows/scorecards-analysis.yml
new file mode 100644
index 0000000..27027ac
--- /dev/null
+++ b/.github/workflows/scorecards-analysis.yml
@@ -0,0 +1,69 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache license, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the license for the specific language governing permissions and
+# limitations under the license.
+
+name: "Scorecards supply-chain security"
+
+on:
+  branch_protection_rule:
+  schedule:
+    - cron: "30 1 * * 6"    # Weekly on Saturdays
+  push:
+    branches: [ "master" ]
+
+permissions: read-all
+
+jobs:
+
+  analysis:
+
+    name: "Scorecards analysis"
+    runs-on: ubuntu-latest
+    permissions:
+      # Needed to upload the results to the code-scanning dashboard.
+      security-events: write
+      actions: read
+      id-token: write # This is required for requesting the JWT
+      contents: read  # This is required for actions/checkout
+
+    steps:
+
+      - name: "Checkout code"
+        uses: actions/checkout@v3.3.0   # 3.1.0
+        with:
+          persist-credentials: false
+
+      - name: "Run analysis"
+        uses: ossf/scorecard-action@e38b1902ae4f44df626f11ba0734b14fb91f8f86    # 2.1.2
+        with:
+          results_file: results.sarif
+          results_format: sarif
+          # A read-only PAT token, which is sufficient for the action to function.
+          # The relevant discussion: https://github.com/ossf/scorecard-action/issues/188
+          repo_token: ${{ secrets.GITHUB_TOKEN }}
+          # Publish the results for public repositories to enable scorecard badges.
+          # For more details: https://github.com/ossf/scorecard-action#publishing-results
+          publish_results: true
+
+      - name: "Upload artifact"
+        uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce    # 3.1.2
+        with:
+          name: SARIF file
+          path: results.sarif
+          retention-days: 5
+
+      - name: "Upload to code-scanning"
+        uses: github/codeql-action/upload-sarif@b398f525a5587552e573b247ac661067fafa920b    # 2.1.22
+        with:
+          sarif_file: results.sarif
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f43fea0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,22 @@
+### https://raw.github.com/github/gitignore/14b7566ce157ce95b07006466bacee160f242284/maven.gitignore
+
+target/
+pom.xml.tag
+pom.xml.releaseBackup
+pom.xml.versionsBackup
+pom.xml.next
+release.properties
+
+
+site-content
+/.classpath
+/.project
+/.settings/
+
+### Ignore IntelliJ files
+/.idea/
+*.iml
+/bin/
+
+### Ignore Visual Studio code files
+/.vscode/
diff --git a/Android.bp b/Android.bp
new file mode 100644
index 0000000..98ac0ff
--- /dev/null
+++ b/Android.bp
@@ -0,0 +1,48 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// 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.
+
+package {
+    default_applicable_licenses: ["external_apache_commons_io_license"],
+}
+
+license {
+    name: "external_apache_commons_io_license",
+    visibility: [":__subpackages__"],
+    license_kinds: [
+        "SPDX-license-identifier-Apache-2.0",
+    ],
+    license_text: [
+        "LICENSE",
+        "LICENSE.txt",
+        "NOTICE.txt",
+    ],
+}
+
+java_library {
+    name: "apache-commons-io",
+    host_supported: true,
+    srcs: ["src/main/java/**/*.java"],
+    sdk_version: "current",
+    min_sdk_version: "33",
+
+    java_version: "1.8",
+    apex_available: [
+        "//apex_available:platform",
+        "com.android.ondevicepersonalization",
+    ],
+    visibility: [
+        "//external/apache-velocity-engine",
+        "//packages/modules/OnDevicePersonalization:__subpackages__",
+    ],
+}
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..3ed5015
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,17 @@
+<!---
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+The Apache code of conduct page is [https://www.apache.org/foundation/policies/conduct.html](https://www.apache.org/foundation/policies/conduct.html).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..e2748c9
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,115 @@
+<!---
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<!---
+ +======================================================================+
+ |****                                                              ****|
+ |****      THIS FILE IS GENERATED BY THE COMMONS BUILD PLUGIN      ****|
+ |****                    DO NOT EDIT DIRECTLY                      ****|
+ |****                                                              ****|
+ +======================================================================+
+ | TEMPLATE FILE: contributing-md-template.md                           |
+ | commons-build-plugin/trunk/src/main/resources/commons-xdoc-templates |
+ +======================================================================+
+ |                                                                      |
+ | 1) Re-generate using: mvn commons-build:contributing-md              |
+ |                                                                      |
+ | 2) Set the following properties in the component's pom:              |
+ |    - commons.jira.id  (required, alphabetic, upper case)             |
+ |                                                                      |
+ | 3) Example Properties                                                |
+ |                                                                      |
+ |  <properties>                                                        |
+ |    <commons.jira.id>MATH</commons.jira.id>                           |
+ |  </properties>                                                       |
+ |                                                                      |
+ +======================================================================+
+--->
+Contributing to Apache Commons IO
+======================
+
+You have found a bug or you have an idea for a cool new feature? Contributing code is a great way to give something back to
+the open source community. Before you dig right into the code there are a few guidelines that we need contributors to
+follow so that we can have a chance of keeping on top of things.
+
+Getting Started
+---------------
+
++ Make sure you have a [JIRA account](https://issues.apache.org/jira/).
++ Make sure you have a [GitHub account](https://github.com/signup/free).
++ If you're planning to implement a new feature it makes sense to discuss your changes on the [dev list](https://commons.apache.org/mail-lists.html) first. This way you can make sure you're not wasting your time on something that isn't considered to be in Apache Commons IO's scope.
++ Submit a [Jira Ticket][jira] for your issue, assuming one does not already exist.
+  + Clearly describe the issue including steps to reproduce when it is a bug.
+  + Make sure you fill in the earliest version that you know has the issue.
++ Find the corresponding [repository on GitHub](https://github.com/apache/?query=commons-),
+[fork](https://help.github.com/articles/fork-a-repo/) and check out your forked repository.
+
+Making Changes
+--------------
+
++ Create a _topic branch_ for your isolated work.
+  * Usually you should base your branch on the `master` branch.
+  * A good topic branch name can be the JIRA bug id plus a keyword, e.g. `IO-123-InputStream`.
+  * If you have submitted multiple JIRA issues, try to maintain separate branches and pull requests.
++ Make commits of logical units.
+  * Make sure your commit messages are meaningful and in the proper format. Your commit message should contain the key of the JIRA issue.
+  * e.g. `IO-123: Close input stream earlier`
++ Respect the original code style:
+  + Only use spaces for indentation.
+  + Create minimal diffs - disable _On Save_ actions like _Reformat Source Code_ or _Organize Imports_. If you feel the source code should be reformatted create a separate PR for this change first.
+  + Check for unnecessary whitespace with `git diff` -- check before committing.
++ Make sure you have added the necessary tests for your changes, typically in `src/test/java`.
++ Run all the tests with `mvn clean verify` to assure nothing else was accidentally broken.
+
+Making Trivial Changes
+----------------------
+
+The JIRA tickets are used to generate the changelog for the next release.
+
+For changes of a trivial nature to comments and documentation, it is not always necessary to create a new ticket in JIRA.
+In this case, it is appropriate to start the first line of a commit with '(doc)' instead of a ticket number.
+
+
+Submitting Changes
+------------------
+
++ Sign and submit the Apache [Contributor License Agreement][cla] if you haven't already.
+  * Note that small patches & typical bug fixes do not require a CLA as
+    clause 5 of the [Apache License](https://www.apache.org/licenses/LICENSE-2.0.html#contributions)
+    covers them.
++ Push your changes to a topic branch in your fork of the repository.
++ Submit a _Pull Request_ to the corresponding repository in the `apache` organization.
+  * Verify _Files Changed_ shows only your intended changes and does not
+  include additional files like `target/*.class`
++ Update your JIRA ticket and include a link to the pull request in the ticket.
+
+If you prefer to not use GitHub, then you can instead use
+`git format-patch` (or `svn diff`) and attach the patch file to the JIRA issue.
+
+
+Additional Resources
+--------------------
+
++ [Contributing patches](https://commons.apache.org/patches.html)
++ [Apache Commons IO JIRA project page][jira]
++ [Contributor License Agreement][cla]
++ [General GitHub documentation](https://help.github.com/)
++ [GitHub pull request documentation](https://help.github.com/articles/creating-a-pull-request/)
++ [Apache Commons Twitter Account](https://twitter.com/ApacheCommons)
++ `#apache-commons` IRC channel on `irc.freenode.net`
+
+[cla]:https://www.apache.org/licenses/#clas
+[jira]:https://issues.apache.org/jira/browse/IO
diff --git a/LICENSE b/LICENSE
new file mode 120000
index 0000000..85de3d4
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1 @@
+LICENSE.txt
\ No newline at end of file
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..6b0b127
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,203 @@
+
+                                 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/METADATA b/METADATA
new file mode 100644
index 0000000..40d8889
--- /dev/null
+++ b/METADATA
@@ -0,0 +1,19 @@
+name: "apache-commons-io"
+description:
+    "The Apache Commons IO library contains utility classes, stream "
+    "implementations, file filters, file comparators, endian transformation "
+    "classes, and much more."
+
+third_party {
+  url {
+    type: HOMEPAGE
+    value: "https://commons.apache.org/proper/commons-io/"
+  }
+  url {
+    type: GIT
+    value: "https://github.com/apache/commons-io.git"
+  }
+  version: "9e08e9e91976d2ba1607011ea01ea8b53f8a1366"
+  last_upgrade_date { year: 2023 month: 02 day: 13 }
+  license_type: NOTICE
+}
diff --git a/MODULE_LICENSE_APACHE2 b/MODULE_LICENSE_APACHE2
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/MODULE_LICENSE_APACHE2
diff --git a/NOTICE.txt b/NOTICE.txt
new file mode 100644
index 0000000..20d8ffa
--- /dev/null
+++ b/NOTICE.txt
@@ -0,0 +1,5 @@
+Apache Commons IO
+Copyright 2002-2023 The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (https://www.apache.org/).
diff --git a/OWNERS b/OWNERS
new file mode 100644
index 0000000..3f20f54
--- /dev/null
+++ b/OWNERS
@@ -0,0 +1,2 @@
+akvuong@google.com
+karthikmahesh@google.com
diff --git a/PROPOSAL.html b/PROPOSAL.html
new file mode 100644
index 0000000..a96b3e6
--- /dev/null
+++ b/PROPOSAL.html
@@ -0,0 +1,78 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
+<!--
+   Licensed to the Apache Software Foundation (ASF) under one or more
+   contributor license agreements.  See the NOTICE file distributed with
+   this work for additional information regarding copyright ownership.
+   The ASF licenses this file to You under the Apache License, Version 2.0
+   (the "License"); you may not use this file except in compliance with
+   the License.  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+-->
+<html>
+<head>
+  <title>Proposal for IO Package</title>
+</head>
+ <body bgcolor="white">
+  
+<div align="center"> 
+<h1>Proposal for <em>IO</em> Package</h1>
+ </div>
+  
+<h3>(0) Rationale</h3>
+  
+<p>Many software projects have a need to perform I/O in various ways, and
+the JDK class libraries provide a lot of functionality, but sometimes you
+need just a little bit more.  The io package seeks to  encapsulate some of
+the most popular i/o base classes into one easy to  use package.</p>
+   
+<h3>(1) Scope of the Package</h3>
+  
+<p>This proposal is to create a package of Java utility classes for  various
+types of i/o related activity.</p>
+   
+<h3>(1.5) Interaction With Other Packages</h3>
+  
+<p><em>IO</em> relies only on standard JDK 1.2 (or later) APIs for production
+deployment.  It utilizes the JUnit unit testing framework for developing
+and executing unit tests, but this is of interest only to developers of the
+component.  IO will be a dependency for several existing components in the
+open source world.</p>
+  
+<p>No external configuration files are utilized.</p>
+   
+<h3>(2) Initial Source of the Package</h3>
+  
+<p>The original Java classes are splashed around various Apache  subprojects.
+ We intend to seek them out and integrate them.</p>
+  
+<p>The proposed package name for the new component is <code>org.apache.commons.io</code>.</p>
+   
+<h3>(3)  Required Jakarta-Commons Resources</h3>
+  
+<ul>
+ <li>CVS Repository - New directory <code>io</code> in the     <code>jakarta-commons</code>
+CVS repository.</li>
+ <li>Mailing List - Discussions will take place on the general     <em>dev@commons.apache.org</em>
+mailing list.  To help     list subscribers identify messages of interest,
+it is suggested that     the message subject of messages about this component
+be prefixed with     [IO].</li>
+ <li>Bugzilla - New component "IO" under the "Commons" product     category,
+with appropriate version identifiers as needed.</li>
+ <li>Jyve FAQ - New category "commons-io" (when available).</li>
+ 
+</ul>
+   
+<h3>(4) Initial Committers</h3>
+  
+<p>The initial committers on the IO component shall be Scott Sanders and
+Nicola Ken Barozzi and Henri Yandell</p>
+    <br>
+</body>
+</html>
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5e3b95e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,108 @@
+<!---
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<!---
+ +======================================================================+
+ |****                                                              ****|
+ |****      THIS FILE IS GENERATED BY THE COMMONS BUILD PLUGIN      ****|
+ |****                    DO NOT EDIT DIRECTLY                      ****|
+ |****                                                              ****|
+ +======================================================================+
+ | TEMPLATE FILE: readme-md-template.md                                 |
+ | commons-build-plugin/trunk/src/main/resources/commons-xdoc-templates |
+ +======================================================================+
+ |                                                                      |
+ | 1) Re-generate using: mvn commons-build:readme-md                    |
+ |                                                                      |
+ | 2) Set the following properties in the component's pom:              |
+ |    - commons.componentid (required, alphabetic, lower case)          |
+ |    - commons.release.version (required)                              |
+ |                                                                      |
+ | 3) Example Properties                                                |
+ |                                                                      |
+ |  <properties>                                                        |
+ |    <commons.componentid>math</commons.componentid>                   |
+ |    <commons.release.version>1.2</commons.release.version>            |
+ |  </properties>                                                       |
+ |                                                                      |
+ +======================================================================+
+--->
+Apache Commons IO
+===================
+
+[![GitHub Actions Status](https://github.com/apache/commons-io/workflows/Java%20CI/badge.svg)](https://github.com/apache/commons-io/actions)
+[![Coverage Status](https://codecov.io/gh/apache/commons-io/branch/master/graph/badge.svg)](https://app.codecov.io/gh/apache/commons-io/branch/master)
+[![Maven Central](https://maven-badges.herokuapp.com/maven-central/commons-io/commons-io/badge.svg?gav=true)](https://maven-badges.herokuapp.com/maven-central/commons-io/commons-io/?gav=true)
+[![Javadocs](https://javadoc.io/badge/commons-io/commons-io/2.11.0.svg)](https://javadoc.io/doc/commons-io/commons-io/2.11.0)
+[![CodeQL](https://github.com/apache/commons-io/workflows/CodeQL/badge.svg)](https://github.com/apache/commons-io/actions/workflows/codeql-analysis.yml?query=workflow%3ACodeQL)
+[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/apache/commons-io/badge)](https://api.securityscorecards.dev/projects/github.com/apache/commons-io)
+
+The Apache Commons IO library contains utility classes, stream implementations, file filters,
+file comparators, endian transformation classes, and much more.
+
+Documentation
+-------------
+
+More information can be found on the [Apache Commons IO homepage](https://commons.apache.org/proper/commons-io).
+The [Javadoc](https://commons.apache.org/proper/commons-io/apidocs) can be browsed.
+Questions related to the usage of Apache Commons IO should be posted to the [user mailing list][ml].
+
+Where can I get the latest release?
+-----------------------------------
+You can download source and binaries from our [download page](https://commons.apache.org/proper/commons-io/download_io.cgi).
+
+Alternatively you can pull it from the central Maven repositories:
+
+```xml
+<dependency>
+  <groupId>commons-io</groupId>
+  <artifactId>commons-io</artifactId>
+  <version>2.11.0</version>
+</dependency>
+```
+
+Contributing
+------------
+
+We accept Pull Requests via GitHub. The [developer mailing list][ml] is the main channel of communication for contributors.
+There are some guidelines which will make applying PRs easier for us:
++ No tabs! Please use spaces for indentation.
++ Respect the code style.
++ Create minimal diffs - disable on save actions like reformat source code or organize imports. If you feel the source code should be reformatted create a separate PR for this change.
++ Provide JUnit tests for your changes and make sure your changes don't break any existing tests by running ```mvn```.
+
+If you plan to contribute on a regular basis, please consider filing a [contributor license agreement](https://www.apache.org/licenses/#clas).
+You can learn more about contributing via GitHub in our [contribution guidelines](CONTRIBUTING.md).
+
+License
+-------
+This code is under the [Apache Licence v2](https://www.apache.org/licenses/LICENSE-2.0).
+
+See the `NOTICE.txt` file for required notices and attributions.
+
+Donations
+---------
+You like Apache Commons IO? Then [donate back to the ASF](https://www.apache.org/foundation/contributing.html) to support the development.
+
+Additional Resources
+--------------------
+
++ [Apache Commons Homepage](https://commons.apache.org/)
++ [Apache Issue Tracker (JIRA)](https://issues.apache.org/jira/browse/IO)
++ [Apache Commons Twitter Account](https://twitter.com/ApacheCommons)
++ `#apache-commons` IRC channel on `irc.freenode.org`
+
+[ml]:https://commons.apache.org/mail-lists.html
diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt
new file mode 100644
index 0000000..5337bf2
--- /dev/null
+++ b/RELEASE-NOTES.txt
@@ -0,0 +1,1462 @@
+Apache Commons IO 
+Version 2.11.0
+Release Notes
+
+INTRODUCTION:
+
+Commons IO is a package of Java utility classes like java.io.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+The Apache Commons IO library contains utility classes, stream implementations, file filters,
+file comparators, endian transformation classes, and much more.
+
+==============================================================================
+Apache Commons IO Version 2.11.0
+==============================================================================
+Java 8 required.
+
+Changes in this version include:
+
+
+Fixed Bugs:
+o IO-741:  FileUtils.listFiles does not list matching files if File parameter is a symbolic link. Thanks to Zach Sherman. 
+o IO-724:  FileUtils#deleteDirectory(File) exception Javadoc inaccurate update #245. Thanks to liran2000. 
+o          Minor changes #243. Thanks to Arturo Bernal. 
+o          Replace construction of FileInputStream and FileOutputStream objects with Files NIO APIs. #221. Thanks to Arturo Bernal. 
+o          Fix IndexOutOfBoundsException in IOExceptionList constructors. Thanks to Gary Gregory. 
+o          Remove IOException from the method signatures that no longer throw IOException.
+           This maintains binary compatibility but not source compatibility.
+           - FilenameUtils
+               directoryContains(String, String)
+           - BoundedReader
+               BoundedReader(java.io.Reader, int)
+           - IOUtils
+               lineIterator(java.io.InputStream, Charset)
+               lineIterator(java.io.InputStream, String)
+               toByteArray(String)
+               toInputStream(CharSequence, String)
+               toInputStream(String, String)
+               toString(byte[])
+               toString(byte[], String) Thanks to Gary Gregory. 
+
+Changes:
+o          Add SymbolicLinkFileFilter. Thanks to Gary Gregory. 
+o          Add test to make sure the setter of AndFileFilter works correctly #244. Thanks to trncate.
+o          Add XmlStreamReader(Path). Thanks to Gary Gregory. 
+o          Bump mockito-inline from 3.11.0 to 3.11.2 #247. Thanks to Dependabot. 
+o          Bump jmh.version from 1.27 to 1.32 #237. Thanks to Dependabot. 
+o          Bump spotbugs from 4.2.3 to 4.3.0 #249. Thanks to Dependabot. 
+
+Compatibility with 2.6:
+Binary compatible: Yes.
+Source compatible: Yes.
+Semantic compatible: Yes.
+
+Commons IO 2.9.0 requires Java 8.
+Commons IO 2.8.0 requires Java 8.
+Commons IO 2.7 requires Java 8.
+Commons IO 2.6 requires Java 7.
+Commons IO 2.5 requires Java 6.
+Commons IO 2.4 requires Java 6.
+Commons IO 2.3 requires Java 6.
+Commons IO 2.2 requires Java 5.
+Commons IO 1.4 requires Java 1.3.
+
+Historical list of changes: https://commons.apache.org/proper/commons-io/changes-report.html
+
+For complete information on Apache Commons IO, including instructions on how to submit bug reports,
+patches, or suggestions for improvement, see the Apache Commons IO website:
+
+https://commons.apache.org/proper/commons-io/
+
+Download page: https://commons.apache.org/proper/commons-io/download_io.cgi
+
+Have fun!
+-Apache Commons Team
+
+
+Apache Commons IO 
+Version 2.10.0
+Release Notes
+
+INTRODUCTION:
+
+Commons IO is a package of Java utility classes like java.io.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+The Apache Commons IO library contains utility classes, stream implementations, file filters,
+file comparators, endian transformation classes, and much more.
+
+==============================================================================
+Apache Commons IO Version 2.10.0
+==============================================================================
+Java 8 required.
+
+Changes in this version include:
+
+New features:
+o           Add and use RegexFileFilter.toString(). Thanks to Gary Gregory. 
+o           Add and use RegexFileFilter.RegexFileFilter(Pattern, Function<Path>, String>) Thanks to Gary Gregory. 
+o           Add and use IOCase.isCaseSensitive(IOCase). Thanks to Gary Gregory. 
+
+Fixed Bugs:
+o IO-733:  RegexFileFilter uses the path and file name instead of just the file name. Thanks to Jim Sellers, Gary Gregory. 
+o IO-734:  The OSGi manifest now contains sun.* import packages #239. Thanks to Eric Norman. 
+o IO-585:  Sanitize double slash after prefix #79. Thanks to Adam McClenaghan. 
+
+Changes:
+o           Bump actions/cache from 2.1.5 to 2.1.6 #238. Thanks to Dependabot. 
+o           Bump junit-pioneer from 1.4.1 to 1.4.2 #240. Thanks to Dependabot. 
+o           Bump checkstyle from 8.42 to 8.43 #241. Thanks to Dependabot. 
+o           Bump mockito-inline from 3.10.0 to 3.11.0 #242. Thanks to Dependabot. 
+
+Compatibility with 2.6:
+Binary compatible: Yes.
+Source compatible: Yes.
+Semantic compatible: Yes.
+
+Commons IO 2.9.0 requires Java 8.
+Commons IO 2.8.0 requires Java 8.
+Commons IO 2.7 requires Java 8.
+Commons IO 2.6 requires Java 7.
+Commons IO 2.5 requires Java 6.
+Commons IO 2.4 requires Java 6.
+Commons IO 2.3 requires Java 6.
+Commons IO 2.2 requires Java 5.
+Commons IO 1.4 requires Java 1.3.
+
+Historical list of changes: https://commons.apache.org/proper/commons-io/changes-report.html
+
+For complete information on Apache Commons IO, including instructions on how to submit bug reports,
+patches, or suggestions for improvement, see the Apache Commons IO website:
+
+https://commons.apache.org/proper/commons-io/
+
+Download page: https://commons.apache.org/proper/commons-io/download_io.cgi
+
+Have fun!
+-Apache Commons Team
+
+
+Apache Commons IO 
+Version 2.8.0
+Release Notes
+
+INTRODUCTION:
+
+Commons IO is a package of Java utility classes like java.io.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+The Apache Commons IO library contains utility classes, stream implementations, file filters,
+file comparators, endian transformation classes, and much more.
+
+==============================================================================
+Apache Commons IO Version 2.8.0
+==============================================================================
+Java 8 required.
+
+Changes in this version include:
+
+New features:
+o Add org.apache.commons.io.input.CircularInputStream. Thanks to Gary Gregory. 
+o Add org.apache.commons.io.file.PathUtils.cleanDirectory(Path, FileVisitOption...). Thanks to Gary Gregory. 
+o Add org.apache.commons.io.file.PathUtils.deleteDirectory(Path, FileVisitOption...). Thanks to Gary Gregory. 
+o Add NullAppendable. Thanks to Gary Gregory. 
+o Add PathUtils.getAclEntryList(Path). Thanks to Gary Gregory. 
+o Null-guard IOUtils.close(Closeable, IOConsumer). Thanks to Gary Gregory. 
+o Add ReversedLinesFileReader.readLines(int). Thanks to Gary Gregory. 
+o Add ReversedLinesFileReader.toString(int). Thanks to Gary Gregory. 
+o IO-684:  Add PathUtils.delete(Path, DeleteOption...).
+        Add PathUtils.deleteDirectory(Path, DeleteOption...).
+        Add PathUtils.deleteFile(Path, DeleteOption...).
+        Add PathUtils.setReadOnly(Path, boolean, LinkOption...).
+        Add CleaningPathVisitor.CleaningPathVisitor(PathCounters, DeleteOption[], String...).
+        Add DeletingPathVisitor.DeletingPathVisitor(PathCounters, DeleteOption[], String...). Thanks to Gary Gregory, Robin Jansohn. 
+o Add RandomAccessFileInputStream. Thanks to Gary Gregory. 
+o IO-681:  IOUtils.close(Closeable) should allow a list of closeables. 
+o Add IOUtils.consume(InputStream). Thanks to Gary Gregory. 
+o IO-676:  Add isFileNewer() and isFileOlder() methods that support the Java 8 Date/Time API. #124. Thanks to Isira Seneviratne, Gary Gregory. 
+o Add a MarkShieldInputStream #119. Thanks to Adam Retter, Gary Gregory. 
+o Deprecate IOUtils.LINE_SEPARATOR in favor of Java 7's System.lineSeparator(). Thanks to Gary Gregory. 
+
+Fixed Bugs:
+o CharSequenceReader.skip should return 0 instead of EOF on stream end #123. Thanks to Rob Spoor, Jochen Wiedmann. 
+o Implement CharSequenceReader.ready() #122. Thanks to Rob Spoor. 
+o IO-669:  Fix code smells; fix typos #115. Thanks to XenoAmess, Gary Gregory. 
+o Add caching for required charsets #120. Thanks to Jerome Wolff, Gary Gregory. 
+o IO-673:  Make some simplifications #121. Thanks to Jerome Wolff. 
+o IO-674:  InfiniteCircularInputStream is not infinite if its input buffer contains -1. Thanks to Gary Gregory. 
+o IO-675:  InfiniteCircularInputStream throws a divide-by-zero exception when reading if its input buffer is size 0. Thanks to Gary Gregory. 
+o IO-677:  FileSystem.getCurrent() does not return the correct enum. Thanks to Gary Gregory. 
+o IO-679:  input.AbstractCharacterFilterReader passes count of chars read #132. Thanks to proneel. 
+o IO-683:  CircularBufferInputStream.read() fails to convert byte to unsigned int 
+o Fix SpotBugs issues in org.apache.commons.io.FileUtils. Thanks to Gary Gregory. 
+o IO-672:  Copying a File sets last modified date to 01 January 1970. 
+o IO-676:  Prevent NullPointerException in ReversedLinesFileReader constructors #117. Thanks to Michael Ernst, Gary Gregory. 
+
+Changes:
+o Replace FindBugs with SpotBugs. Thanks to Gary Gregory. 
+o maven-checkstyle-plugin 3.1.0 -> 3.1.1. Thanks to Gary Gregory. 
+o Update tests from org.apache.commons:commons-lang3 3.10 to 3.11. Thanks to Gary Gregory. 
+o Update commons-parent from 50 to 51 #129. Thanks to Gary Gregory. 
+o Update actions/checkout from v1 to v2.3.1 #126. Thanks to Gary Gregory. 
+o Update junit-pioneer from 0.6.0 to 0.8.0, #127, #135. Thanks to Gary Gregory. 
+o Update mockito-core from 3.3.3 to 3.5.9 #128, #133, #145, #149, #151. Thanks to Gary Gregory. 
+o Update spotbugs from 4.0.6 to 4.1.1 #134. Thanks to Dependabot. 
+o Update junit-pioneer from 0.8.0 to 0.9.0 #138. Thanks to Dependabot. 
+o Update actions/checkout from v2.3.1 to v2.3.2 #140. Thanks to Dependabot. 
+o Update actions/setup-java from v1.4.0 to v1.4.2 #141, #148. Thanks to Dependabot. 
+
+Compatibility with 2.7:
+Binary compatible: Yes.
+Source compatible: Yes.
+Semantic compatible: Yes.
+
+Commons IO 2.7 requires Java 8.
+Commons IO 2.6 requires Java 7.
+Commons IO 2.5 requires Java 6.
+Commons IO 2.4 requires Java 6.
+Commons IO 2.3 requires Java 6.
+Commons IO 2.2 requires Java 5.
+Commons IO 1.4 requires Java 1.3.
+
+Historical list of changes: https://commons.apache.org/proper/commons-io/changes-report.html
+
+For complete information on Apache Commons IO, including instructions on how to submit bug reports,
+patches, or suggestions for improvement, see the Apache Commons IO website:
+
+https://commons.apache.org/proper/commons-io/
+
+Download page: https://commons.apache.org/proper/commons-io/download_io.cgi
+
+Have fun!
+-Apache Commons Team
+
+==============================================================================
+
+Apache Commons IO 
+Version 2.7
+Release Notes
+
+INTRODUCTION:
+
+Commons IO is a package of Java utility classes like java.io.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+The Apache Commons IO library contains utility classes, stream implementations, file filters,
+file comparators, endian transformation classes, and much more.
+
+==============================================================================
+Apache Commons IO Version 2.7
+==============================================================================
+Java 8 required.
+
+Changes in this version include:
+
+New features:
+o           Adding the CircularBufferInputStream, and the PeekableInputStream. 
+o IO-553:  Add org.apache.commons.io.FilenameUtils.isIllegalWindowsFileName(char). 
+o IO-577:  Add readers to filter out given characters: CharacterSetFilterReader and CharacterFilterReader. Thanks to Gary Gregory. 
+o IO-594:  Add IOUtils copy methods with java.lang.Appendable as the target. Thanks to Gary Gregory. 
+o IO-605:  Add class CanExecuteFileFilter. Thanks to Gary Gregory. 
+o IO-578:  Support java.nio.Path and non-default file systems for ReversedLinesFileReader (#62). Thanks to Mark Chesney. 
+o IO-608:  Add a convenience NullPrintStream. Thanks to Gary Gregory. 
+o IO-612:  Add class TeeReader. Thanks to Rob Spoor, Gary Gregory. 
+o IO-613:  Add classes ClosedReader and CloseShieldReader. #84. Thanks to Rob Spoor, Gary Gregory. 
+o IO-614:  Add classes TaggedWriter, ClosedWriter and BrokenWriter. #86. Thanks to Rob Spoor. 
+o IO-615:  Add classes TeeWriter, FilterCollectionWriter, ProxyCollectionWriter, IOExceptionList, IOIndexedException. Thanks to Gary Gregory, Rob Spoor. 
+o IO-616:  Add class AppendableWriter. #87. Thanks to Rob Spoor. 
+o IO-617:  Add class CloseShieldWriter. #83. Thanks to Rob Spoor, Gary Gregory. 
+o IO-618:  Add classes Added TaggedReader, ClosedReader and BrokenReader. #85. Thanks to Rob Spoor. 
+o IO-619:  Support sub sequences in CharSequenceReader. #91. Thanks to Rob Spoor. 
+o IO-631:  Add a CountingFileVisitor (as the basis for a forthcoming DeletingFileVisitor). Thanks to Gary Gregory. 
+o IO-632:  Add PathUtils for operations on NIO Path. Thanks to Gary Gregory. 
+o IO-633:  Add DeletingFileVisitor. Thanks to Gary Gregory. 
+o IO-635:  Add org.apache.commons.io.IOUtils.close(Closeable). Thanks to Gary Gregory. 
+o IO-636:  Add and reuse org.apache.commons.io.IOUtils.closeQuitely(Closeable, Consumer<IOException>).
+           Add and reuse org.apache.commons.io.IOUtils.close(Closeable, IOConsumer<IOException>). Thanks to Gary Gregory. 
+o IO-645:  Add org.apache.commons.io.file.PathUtils.fileContentEquals(Path, Path, OpenOption...). Thanks to Gary Gregory. 
+o IO-458:  Add a SequenceReader similar to java.io.SequenceInputStream. Thanks to Gary Gregory, Joshua Gitlin. 
+o IO-648:  Implement directory content equality. 100#. Thanks to Gary Gregory. 
+o IO-648:  Refactor ByteArrayOutputStream into synchronized and unsynchronized versions #108. Thanks to Adam Retter, Alex Herbert, Gary Gregory. 
+o IO-662:  Refactor ByteArrayOutputStream into synchronized and unsynchronized versions #108. Thanks to Adam Retter, Gary Gregory. 
+
+Fixed Bugs:
+o IO-589:  Some tests fail if the base path contains a space. 
+o IO-582:  Make methods in ObservableInputStream.Observer public. Thanks to Bruno Palos.
+o IO-535:  Thread bug in FileAlterationMonitor.stop(int). Thanks to Svetlin Zarev, Anthony Raymond. 
+o IO-557:  Perform locale independent upper case conversions. Thanks to luccioman. 
+o IO-570:  Missing Javadoc in FilenameUtils causing Travis-CI build to fail. Thanks to Pranet Verma. 
+o IO-571:  Remove redundant isDirectory() check in org.apache.commons.io.FileUtils.listFilesAndDirs(File, IOFileFilter, IOFileFilter). Thanks to pranet. 
+o IO-559:  FilenameUtils.normalize now verifies hostname syntax in UNC path. 
+o IO-554:  FileUtils.copyToFile(InputStream source, File destination) should not close input stream. Thanks to Michele Mariotti. 
+o IO-604:  FileUtils.doCopyFile(File, File, boolean) can throw ClosedByInterruptException. Thanks to Gary Gregory. 
+o IO-625:  Corrected misleading exception message for FileUtils.copyDirectoryToDirectory. Thanks to Mikko Maunu. 
+o IO-626:  A mistake in the FilenameUtils.concat()'s Javadoc about an absolute path. Thanks to Yuji Konishi. 
+o IO-640:  NPE in org.apache.commons.io.IOUtils.contentEquals(InputStream, InputStream) when only one input is null. Thanks to Gary Gregory. 
+o IO-641:  NPE in org.apache.commons.io.IOUtils.contentEquals(Reader, Reader) when only one input is null. Thanks to Gary Gregory. 
+o IO-643:  NPE in org.apache.commons.io.IOUtils.contentEqualsIgnoreEOL(Reader, Reader) when only one input is null. Thanks to Gary Gregory. 
+o IO-644:  NPE in org.apache.commons.io.FileUtils.contentEqualsIgnoreEOL(File, File) when only one input is null. Thanks to Gary Gregory. 
+o IO-664:  org.apache.commons.io.FileUtils.copyURLToFile(*) open but do not close streams. Thanks to Gary Gregory. 
+
+Changes:
+o IO-572:  Refactor duplicate code in org.apache.commons.io.FileUtils. Thanks to Pranet Verma. 
+o IO-580:  Update org.apache.commons.io.FilenameUtils.isExtension(String, String[]) to use var args. 
+o IO-701:  Make array declaration in ThresholdingOutputStream consistent with other array declarations in the library #77. Thanks to Raymond Tan. 
+o IO-607:  Update from Java 7 to Java 8. Thanks to Gary Gregory. 
+o IO-610:  Remove throws IOException in method isSymlink() #80. Thanks to Sebastian. 
+o IO-628:  Migration to JUnit Jupiter #97. Thanks to Allon Mureinik. 
+o IO-630:  Deprecate org.apache.commons.io.output.NullOutputStream.NullOutputStream() in favor of org.apache.commons.io.output.NullOutputStream.NULL_OUTPUT_STREAM. Thanks to Gary Gregory. 
+o IO-629:  FileUtils#forceDelete should use Files#delete rather than File#delete so exception messages includes reason for failure. Thanks to Ian Springer, Ian Springer, Gary Gregory. 
+o IO-634:  Make getCause synchronized and use a Deque instead of a Stack #64. Thanks to Václav Haisman, Bruno P. Kinoshita, Gary Gregory. 
+o            Update tests from Apache Commons Lang 3.9 to 3.10. Thanks to Gary Gregory. 
+o            Update tests org.junit-pioneer:junit-pioneer 0.3.0 -> 0.6.0. Thanks to Gary Gregory. 
+o            Update tests org.junit.jupiter:junit-jupiter 5.5.2 -> 5.6.2. Thanks to Gary Gregory. 
+o            Update tests org.mockito:mockito-core 3.0.0 -> 3.3.3. Thanks to Gary Gregory. 
+o IO-666:  Normalize internal buffers to 8192 bytes. Thanks to Gary Gregory. 
+o IO-665:  Ensure that passing a null InputStream results in NPE with tests #112. Thanks to Otto Fowler, Gary Gregory. 
+o            commons.jacoco.version 0.8.4 -> 0.8.5. Thanks to Gary Gregory. 
+o            com.github.siom79.japicmp:japicmp-maven-plugin 0.14.1 -> 0.14.3. Thanks to Gary Gregory. 
+o IO-667:  Add functional interfaces IOFunction and IOSupplier #110. Thanks to Adam Retter, Gary Gregory. 
+o            Support sub sequences in CharSequenceReader #91. Thanks to Rob Spoor, Gary Gregory. 
+o            Remove deprecated sudo setting. #113. Thanks to dengliming. 
+
+Compatibility with 2.6:
+Binary compatible: Yes.
+Source compatible: Yes.
+Semantic compatible: Yes.
+
+Commons IO 2.7 requires Java 8.
+Commons IO 2.6 requires Java 7.
+Commons IO 2.5 requires Java 6.
+Commons IO 2.4 requires Java 6.
+Commons IO 2.3 requires Java 6.
+Commons IO 2.2 requires Java 5.
+Commons IO 1.4 requires Java 1.3.
+
+Historical list of changes: https://commons.apache.org/proper/commons-io/changes-report.html
+
+For complete information on Apache Commons IO, including instructions on how to submit bug reports,
+patches, or suggestions for improvement, see the Apache Commons IO website:
+
+https://commons.apache.org/proper/commons-io/
+
+Download page: https://commons.apache.org/proper/commons-io/download_io.cgi
+
+Have fun!
+-Apache Commons Team
+
+==============================================================================
+Apache Commons IO Version 2.6
+==============================================================================
+
+INTRODUCTION:
+
+Apache Commons IO is a package of Java utility classes like java.io.
+Classes in this package are considered to be so standard and of such high
+reuse as to justify existence in java.io.
+
+The Apache Commons IO library contains utility classes, stream implementations,
+file filters, file comparators, endian transformation classes, and much more.
+
+Apache Commons IO 2.6 requires at least Java 7 to build and run.
+
+
+DEPRECATIONS
+============
+
+All closeQuietly overloads in org.apache.commons.io.IOUtils have been
+deprecated. Use the try-with-resources statement or handle suppressed
+exceptions manually.
+
+The class org.apache.commons.io.FileSystemUtils has been deprecated.
+Use equivalent methods in java.nio.file.FileStore instead, e.g.
+Files.getFileStore(Paths.get("/home")).getUsableSpace() or iterate over
+FileSystems.getDefault().getFileStores().
+
+
+COMPATIBILITY WITH JAVA 9
+==================
+
+The MANIFEST.MF now contains an additional entry:
+
+  Automatic-Module-Name: org.apache.commons.io
+
+This should make it possible to use Commons IO 2.6 as a module in the Java 9
+module system. For more information see the corresponding issue:
+
+    https://issues.apache.org/jira/browse/IO-551
+
+Building Commons IO 2.6 should work out of the box with the latest Java 9
+release. Please report any Java 9 related issues at:
+
+    https://issues.apache.org/jira/browse/IO
+
+
+NEW FEATURES
+============
+
+o IO-551: Add Automatic-Module-Name MANIFEST entry for Java 9 compatibility.
+o IO-367: Add convenience methods for copyToDirectory. Thanks to James Sawle.
+o IO-493: Add infinite circular input stream. Thanks to Piotr Turski.
+o IO-507: Add a ByteOrderUtils class.
+o IO-518: Add ObservableInputStream.
+o IO-519: Add MessageDigestCalculatingInputStream.
+o IO-513: Add convenience methods for reading class path resources.
+          Thanks to Behrang Saeedzadeh.
+
+FIXED BUGS
+==========
+
+o IO-546: ClosedOutputStream#flush should throw. Thanks to Tomas Celaya.
+o IO-550: Documentation issue, fix 404 Javadoc issues in the description page.
+          Thanks to Jimi Adrian.
+o IO-442: Javadoc contradictory for FileFilterUtils.ageFileFilter(cutoff) and
+          the filter it constructs: AgeFileFilter(cutoff).
+          Thanks to Simon Robinson.
+o IO-534: FileUtilTestCase.testForceDeleteDir() should not delete testDirectory
+          parent.
+o IO-528: Fix Tailer.run race condition runaway logging. Thanks to Dave Moten.
+o IO-483: getPrefixLength return -1 if Unix file contains colon.
+          Thanks to Marko Vasic.
+o IO-520: FileUtilsTestCase#testContentEqualsIgnoreEOL fails on Windows.
+o IO-516: .gitattributes not correctly applied. Thanks to Jason Pyeron.
+o IO-515: Allow Specifying Initial Buffer Size of DeferredFileOutputStream.
+          Thanks to Brett Lounsbury, Gary Gregory.
+o IO-512: ThresholdingOutputStream.thresholdReached() results in
+          FileNotFoundException. Thanks to Ralf Hauser.
+o IO-511: After a few unit tests, a few newly created directories not cleaned
+          completely. Thanks to Ahmet Celik.
+o IO-502: Exceptions are suppressed incorrectly when copying files.
+          Thanks to Christian Schulte.
+o IO-503: Update platform requirement to Java 7.
+o IO-537: BOMInputStream shouldn't sort array of BOMs in-place.
+          Thanks to Borys Zibrov.
+
+CHANGES
+=======
+
+o IO-553: Make code style of hasBOM() consistent with getBOMCharsetName().
+          Thanks to Michael Ernst.
+o IO-542: FileUtils#readFileToByteArray: optimize reading of files with known
+          size. Thanks to Ilmars Poikans.
+o IO-547: Throw a IllegalArgumentException instead of NullPointerException in
+          FileSystemUtils.freeSpaceWindows(). Thanks to Nikhil Shinde,
+          Michael Ernst, Gary Greory.
+o IO-506: Deprecate methods FileSystemUtils.freeSpaceKb().
+          Thanks to Christian Schulte.
+o IO-505: Make LineIterator implement Closeable to support try-with-resources
+          statements. Thanks to Christian Schulte.
+o IO-504: Deprecated of all IOUtils.closeQuietly() methods and use
+          try-with-resources internally. Thanks to Christian Schulte.
+
+REMOVED
+=======
+
+o IO-514: Remove org.apache.commons.io.Java7Support.
+
+COMPATIBILITY WITH OLDER VERSIONS
+=================================
+
+Compatibility with 2.5:
+Binary compatible: Yes.
+Source compatible: Yes.
+Semantic compatible: Yes.
+
+Compatibility with 2.6 and 1.4:
+Binary compatible: Yes.
+Source compatible: No, see the rare case in
+  https://issues.apache.org/jira/browse/IO-318.
+Semantic compatible: No, see the rare case in
+  https://issues.apache.org/jira/browse/IO-318.
+
+Commons IO 2.6 requires Java 7 or later.
+Commons IO 2.5 requires Java 6 or later.
+Commons IO 2.4 requires Java 6 or later.
+Commons IO 2.3 requires Java 6 or later.
+Commons IO 2.2 requires Java 5 or later.
+Commons IO 1.4 requires Java 1.3 or later.
+
+==============================================================================
+Apache Commons IO Version 2.5
+==============================================================================
+New features and bug fixes.
+
+Changes in this version include:
+
+New features:
+o IO-487:  Add ValidatingObjectInputStream for controlled deserialization 
+o IO-471:  Support for additional encodings in ReversedLinesFileReader Thanks to Leandro Reis. 
+o IO-425:  Setter method for threshold on ThresholdingOutputStream Thanks to Craig Swank. 
+o IO-406:  Introduce new class AppendableOutputStream Thanks to Niall Pemberton. 
+o IO-459:  Add WindowsLineEndingInputStream and UnixLineEndingInputStream. Thanks to Kristian Rosenvold. 
+o IO-457:  Add a BoundedReader, a wrapper that can be used to constrain access
+        to an underlying stream when used with mark/reset -
+        to avoid overflowing the mark limit of the underlying buffer. Thanks to Kristian Rosenvold. 
+o IO-426:  Add API IOUtils.closeQuietly(Closeable...) 
+o IO-410:  Readfully() That Returns A Byte Array Thanks to Beluga Behr. 
+o IO-395:  Overload IOUtils buffer methods to accept buffer size Thanks to Beluga Behr. 
+o IO-382:  Chunked IO for large arrays.
+         Added writeChunked(byte[], OutputStream) and writeChunked(char[] Writer)
+         Added ChunkedOutputStream, ChunkedWriter 
+o IO-233:  Add Methods for Buffering Streams/Writers To IOUtils
+         Added overloaded buffer() methods - see also IO-330 
+o IO-330:  IOUtils#toBufferedOutputStream/toBufferedWriter to conditionally wrap the output
+         Added overloaded buffer() methods - see also IO-233 
+o IO-381:  Add FileUtils.copyInputStreamToFile API with option to leave the source open.
+        See copyInputStreamToFile(final InputStream source, final File destination, boolean closeSource) 
+o IO-379:  CharSequenceInputStream - add tests for available()
+         Fix code so it really does reflect a minimum available. 
+o IO-346:  Add ByteArrayOutputStream.toInputStream() 
+o IO-341:  A constant for holding the BOM character (U+FEFF) 
+o IO-361:  Add API FileUtils.forceMkdirsParent(). 
+o IO-360:  Add API Charsets.requiredCharsets(). 
+o IO-359:  Add IOUtils.skip and skipFully(ReadableByteChannel, long). Thanks to yukoba. 
+o IO-358:  Add IOUtils.read and readFully(ReadableByteChannel, ByteBuffer buffer). Thanks to yukoba. 
+o IO-353:  Add API IOUtils.copy(InputStream, OutputStream, int) Thanks to ggregory. 
+o IO-349:  Add API with array offset and length argument to FileUtils.writeByteArrayToFile. Thanks to scop. 
+o IO-348:  Missing information in IllegalArgumentException thrown by org.apache.commons.io.FileUtils#validateListFilesParameters. Thanks to plcstpierre. 
+o IO-345:  Supply a hook method allowing Tailer actively determining stop condition. Thanks to mkresse. 
+o IO-437:  Make IOUtils.EOF public and reuse it in various classes. 
+
+Fixed Bugs:
+o IO-446:  adds an endOfFileReached method to the TailerListener Thanks to Jeffrey Barrus. 
+o IO-484:  FilenameUtils should handle embedded null bytes Thanks to Philippe Arteau. 
+o IO-481:  Changed/Corrected algorithm for waitFor 
+o IO-428:  BOMInputStream.skip returns wrong count if stream contains no BOM Thanks to Stefan Gmeiner. 
+o IO-488:  FileUtils.waitFor(...) swallows thread interrupted status Thanks to Björn Buchner. 
+o IO-452:  Support for symlinks with missing target. Added support for JDK7 symlink features when present Thanks to David Standish. 
+o IO-453:  Regression in FileUtils.readFileToString from 2.0.1 Thanks to Steven Christou. 
+o IO-451:  ant test fails - resources missing from test classpath Thanks to David Standish. 
+o IO-435:  Document that FileUtils.deleteDirectory, directoryContains and cleanDirectory
+         may throw an IllegalArgumentException in case the passed directory does not
+         exist or is not a directory. Thanks to Dominik Stadler. 
+o IO-424:  Javadoc fixes, mostly to appease 1.8.0 Thanks to Ville Skyttä. 
+o IO-389:  FileUtils.sizeOfDirectory can throw IllegalArgumentException Thanks to Austin Doupnik. 
+o IO-390:  FileUtils.sizeOfDirectoryAsBigInteger can overflow.
+         Ensure that recursive calls all use BigInteger 
+o IO-385:  FileUtils.doCopyFile can potentially loop for ever
+         Exit loop if no data to copy 
+o IO-383:  FileUtils.doCopyFile caches the file size; needs to be documented
+         Added Javadoc; show file lengths in exception message 
+o IO-380:  FileUtils.copyInputStreamToFile should document it closes the input source Thanks to claudio_ch. 
+o IO-279:  Tailer erroneously considers file as new.
+        Fix to use file.lastModified() rather than System.currentTimeMillis() 
+o IO-356:  CharSequenceInputStream#reset() behaves incorrectly in case when buffer size is not dividable by data size.
+         Fix code so skip relates to the encoded bytes; reset now re-encodes the data up to the point of the mark 
+o IO-368:  ClassLoaderObjectInputStream does not handle primitive typed members 
+o IO-314:  Deprecate all methods that use the default encoding 
+o IO-338:  When a file is rotated, finish reading previous file prior to starting new one 
+o IO-354:  Commons IO Tailer does not respect UTF-8 Charset. 
+o IO-323:  What should happen in FileUtils.sizeOf[Directory] when an overflow takes place?
+        Added Javadoc. 
+o IO-372:  FileUtils.moveDirectory can produce misleading error message on failure
+o IO-362:  IOUtils.contentEquals* methods returns false if input1 == input2, should return true. Thanks to mmadson, ggregory. 
+o IO-357:  [Tailer] InterruptedException while the thread is sleeping is silently ignored Thanks to mortenh. 
+o IO-352:  Spelling fixes. Thanks to scop. 
+o IO-436:  Improper Javadoc comment for FilenameUtils.indexOfExtension. Thanks to christoph.schneegans. 
+
+Changes:
+o IO-433:  Converted all testcases to JUnit 4 
+o IO-466:  Added testcase to show this was fixed with IO-423 
+o IO-479:  Correct exception message in FileUtils.getFile(File, String...) Thanks to Zhouce Chen. 
+o IO-465:  Update to JUnit 4.12 Thanks to based2. 
+o IO-462:  IOExceptionWithCause no longer needed 
+o IO-422:  Deprecate Charsets Charset constants in favor of Java 7's java.nio.charset.StandardCharsets 
+o IO-239:  Convert IOCase to a Java 1.5+ Enumeration
+         [N.B. this is binary compatible] 
+o IO-328:  getPrefixLength returns null if filename has leading slashes
+        Javadoc: add examples to show correct behavior; add unit tests 
+o IO-299:  FileUtils.listFilesAndDirs includes original dir in results even when it doesn't match filter
+        Javadoc: clarify that original dir is included in the results 
+o IO-375:  FilenameUtils.splitOnTokens(String text) check for '**' could be simplified 
+o IO-374:  WildcardFileFilter ctors should not use null to mean IOCase.SENSITIVE when delegating to other ctors 
+
+Compatibility with 2.4:
+Binary compatible: Yes.
+Source compatible: Yes.
+Semantic compatible: Yes.
+
+Compatibility with 2.2 and 1.4:
+Binary compatible: Yes.
+Source compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+Semantic compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+
+Commons IO 2.5 requires Java 6 or later.
+Commons IO 2.4 requires Java 6 or later.
+Commons IO 2.3 requires Java 6 or later.
+Commons IO 2.2 requires Java 5 or later.
+Commons IO 1.4 requires Java 1.3 or later.
+
+==============================================================================
+Apache Commons IO Version 2.4
+==============================================================================
+
+Changes in this version include:
+
+New features:
+o IO-269:  Tailer locks file from deletion/rename on Windows. Thanks to
+sebb.
+o IO-333:  Export OSGi packages at version 1.x in addition to 2.x. Thanks
+to fmeschbe.
+o IO-320:  Add XmlStreamReader support for UTF-32. Thanks to ggregory.
+o IO-331:  BOMInputStream wrongly detects UTF-32LE_BOM files as
+UTF-16LE_BOM files in method getBOM(). Thanks to ggregory.
+o IO-327:  Add byteCountToDisplaySize(BigInteger). Thanks to ggregory.
+o IO-326:  Add new FileUtils.sizeOf[Directory] APIs to return BigInteger.
+Thanks to ggregory.
+o IO-325:  Add IOUtils.toByteArray methods to work with URL and URI. Thanks
+to raviprak.
+o IO-324:  Add missing Charset sister APIs to method that take a String
+charset name. Thanks to raviprak.
+
+Fixed Bugs:
+o IO-336:  Yottabyte (YB) incorrectly defined in FileUtils. Thanks to
+rleavelle.
+o IO-279:  Tailer erroneously considers file as new. Thanks to Sergio
+Bossa, Chris Baron.
+o IO-335:  Tailer#readLines - incorrect CR handling.
+o IO-334:  FileUtils.toURLs throws NPE for null parameter; document the
+behavior.
+o IO-332:  Improve tailer's reading performance. Thanks to liangly.
+o IO-279:  Improve Tailer performance with buffered reads (see IO-332).
+o IO-329:  FileUtils.writeLines uses unbuffered IO. Thanks to tivv.
+o IO-319:  FileUtils.sizeOfDirectory follows symbolic links. Thanks to
+raviprak.
+
+
+Compatibility with 2.3:
+Binary compatible: Yes.
+Source compatible: Yes.
+Semantic compatible: Yes.
+
+Compatibility with 2.2 and 1.4:
+Binary compatible: Yes.
+Source compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+Semantic compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+
+Commons IO 2.4 requires Java 6 or later.
+Commons IO 2.3 requires Java 6 or later.
+Commons IO 2.2 requires Java 5 or later.
+Commons IO 1.4 requires Java 1.3 or later.
+
+==============================================================================
+Apache Commons IO Version 2.3
+==============================================================================
+
+Changes in this version include:
+
+New features:
+o IO-322:  Add and use class Charsets. Thanks to ggregory. 
+o IO-321:  ByteOrderMark UTF_32LE is incorrect. Thanks to ggregory. 
+o IO-318:  Add Charset sister APIs to method that take a String charset name. Thanks to ggregory. 
+
+Compatibility with 2.2 and 1.4:
+Binary compatible: Yes.
+Source compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+Semantic compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+
+Commons IO 2.3 requires Java 6 or later.
+Commons IO 2.2 requires Java 5 or later.
+Commons IO 1.4 requires Java 1.3 or later.
+
+==============================================================================
+Apache Commons IO Version 2.2
+==============================================================================
+
+Changes in this version include:
+
+New features:
+o Add IOUtils.toBufferedReader(Reader)  Issue: IO-313. Thanks to ggregory.
+o Allow applications to provide buffer (or size) for copyLarge methods.  Issue: IO-308. Thanks to Manoj Mokashi. 
+o New copyLarge() method in IOUtils that takes additional offset, length arguments  Issue: IO-305. Thanks to Manoj Mokashi. 
+o Use terabyte (TB), petabyte (PB) and exabyte (EB) in FileUtils.byteCountToDisplaySize(long size)  Issue: IO-287. Thanks to Ron Kuris, Gary Gregory. 
+o FileUtils.listFiles() doesn't return directories  Issue: IO-173. Thanks to Marcos Vinícius da Silva. 
+o CharSequenceInputStream to efficiently stream content of a CharSequence  Issue: IO-297. Thanks to Oleg Kalnichevski. 
+o The second constructor of Tailer class does not pass 'delay' to the third one  Issue: IO-304. Thanks to liangly. 
+o TeeOutputStream does not call branch.close() when main.close() throws an exception  Issue: IO-303. Thanks to fabian.barney. 
+o ArrayIndexOutOfBoundsException in BOMInputStream when reading a file without BOM multiple times  Issue: IO-302. Thanks to jsteuerwald, detinho. 
+o Add IOUtils.closeQuietly(Selector) necessary  Issue: IO-301. Thanks to kaykay.unique. 
+o IOUtils.closeQuietly() should take a ServerSocket as a parameter  Issue: IO-292. Thanks to sebb. 
+o Add read/readFully methods to IOUtils  Issue: IO-290. Thanks to sebb. 
+o Supply a ReversedLinesFileReader  Issue: IO-288. Thanks to Georg Henzler. 
+o Add new function FileUtils.directoryContains.  Issue: IO-291. Thanks to ggregory. 
+o FileUtils.contentEquals and IOUtils.contentEquals - Add option to ignore "line endings"
+        Added contentEqualsIgnoreEOL methods to both classes  Issue: IO-275. Thanks to CJ Aspromgos. 
+
+Fixed Bugs:
+o IOUtils.read(InputStream/Reader) ignores the offset parameter  Issue: IO-311. Thanks to Robert Muir. 
+o CharSequenceInputStream(CharSequence s, Charset charset, int bufferSize) ignores bufferSize  Issue: IO-312. 
+o FileUtils.moveDirectoryToDirectory removes source directory if destination is a subdirectory  Issue: IO-300. 
+o ReaderInputStream#read(byte[] b, int off, int len) should check for valid parameters  Issue: IO-307. 
+o ReaderInputStream#read(byte[] b, int off, int len) should always return 0 for length == 0  Issue: IO-306. 
+o "FileUtils#deleteDirectoryOnExit(File)" does not work  Issue: IO-276. Thanks to nkami. 
+o BoundedInputStream.read() treats max differently from BoundedInputStream.read(byte[]...)  Issue: IO-273. Thanks to sebb. 
+o Various methods of class 'org.apache.commons.io.FileUtils' incorrectly suppress 'java.io.IOException'  Issue: IO-298. Thanks to Christian Schulte. 
+
+Changes:
+o ReaderInputStream optimization: more efficient reading of small chunks of data  Issue: IO-296. Thanks to Oleg Kalnichevski. 
+
+
+Compatibility with 2.1 and 1.4:
+Binary compatible: Yes
+Source compatible: Yes
+Semantic compatible: Yes. Check the bug fixes section for semantic bug fixes
+
+Commons IO 2.2 requires a minimum of Java 5. 
+Commons IO 1.4 requires a minimum of Java 1.3. 
+
+==============================================================================
+Apache Commons IO Version 2.1
+==============================================================================
+
+New features:
+o Use standard Maven directory layout  Issue: IO-285. Thanks to ggregory. 
+o Add IOUtils API toString for URL and URI to get contents  Issue: IO-284. Thanks to ggregory. 
+o Add API FileUtils.copyFile(File input, OutputStream output)  Issue: IO-282. Thanks to ggregory. 
+o FileAlterationObserver has no getter for FileFilter  Issue: IO-262. 
+o Add FileUtils.getFile API with varargs parameter  Issue: IO-261. 
+o Add new APPEND parameter for writing string into files  Issue: IO-182. 
+o Add new read method "toByteArray" to handle InputStream with known size.  Issue: IO-251. Thanks to Marco Albini. 
+
+Fixed Bugs:
+o Dubious use of mkdirs() return code  Issue: IO-280. Thanks to sebb. 
+o ReaderInputStream enters infinite loop when it encounters an unmappable character  Issue: IO-277. 
+o FileUtils.moveFile() Javadoc should specify FileExistsException thrown  Issue: IO-264. 
+o ClassLoaderObjectInputStream does not handle Proxy classes  Issue: IO-260. 
+o Tailer returning partial lines when reaching EOF before EOL  Issue: IO-274. Thanks to Frank Grimes. 
+o FileUtils.copyFile() throws IOException when copying large files to a shared directory (on Windows)  Issue: IO-266. Thanks to Igor Smereka. 
+o FileSystemUtils.freeSpaceKb throws exception for Windows volumes with no visible files.
+        Improve coverage by also looking for hidden files.  Issue: IO-263. Thanks to Gil Adam. 
+
+Changes:
+o FileAlterationMonitor.stop(boolean allowIntervalToFinish)  Issue: IO-259. 
+
+==============================================================================
+Apache Commons IO Package 2.0.1
+==============================================================================
+
+Compatibility with 2.0 and 1.4
+------------------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+  Check the bug fixes section for semantic bug fixes
+
+Commons IO 2.0.1 requires a minimum of Java 5
+ (Commons IO 1.4 had a minimum of Java 1.3) 
+
+Enhancements from 2.0
+---------------------
+
+   * [IO-256] - Provide thread factory for FileAlternationMonitor
+
+Bug fixes from 2.0
+------------------
+
+   * [IO-257] - BOMInputStream.read(byte[]) can return 0 which it should not
+   * [IO-258] - XmlStreamReader consumes the stream during encoding detection
+
+==============================================================================
+Apache Commons IO Package 2.0
+==============================================================================
+
+Compatibility with 1.4
+----------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+  Check the bug fixes section for semantic bug fixes
+
+Commons IO 2.0 requires a minimum of Java 5
+ (Commons IO 1.4 had a minimum of Java 1.3) 
+
+Deprecations from 1.4
+---------------------
+
+- IOUtils
+  - write(StringBuffer, Writer) in favour of write(CharSequence, Writer)
+  - write(StringBuffer, OutputStream)  in favour of write(CharSequence, OutputStream)
+  - write(StringBuffer, OutputStream, String) in favour of write(CharSequence, OutputStream, String)
+
+- FileFilterUtils
+  - andFileFilter(IOFileFilter, IOFileFilter) in favour of and(IOFileFilter...) 
+  - orFileFilter(IOFileFilter, IOFileFilter)  in favour of or(IOFileFilter...)
+
+Enhancements from 1.4
+---------------------
+
+  * [IO-140] Move minimum Java requirement from Java 1.3 to Java 5
+             - use Generics
+             - add new CharSequence write() flavour methods to IOUtils and FileUtils
+             - replace StringBuffer with StringBuilder, where appropriate
+             - add new Reader/Writer methods to ProxyReader and ProxyWriter
+             - Annotate with @Override and @Deprecated
+
+  * [IO-178] New BOMInputStream and ByteOrderMark implementations - to detect and optionally exclude an initial Byte Order mark (BOM)
+  * [IO-197] New BoundedInputStream (copied from Apache JackRabbit)
+  * [IO-193] New Broken Input and Output streams
+  * [IO-132] New File Listener/Monitor facility
+  * [IO-158] New ReaderInputStream and WriterOutputStream implementations
+  * [IO-139] New StringBuilder Writer implementation
+  * [IO-192] New Tagged Input and Output streams
+  * [IO-177] New Tailer class - simple implementation of the Unix "tail -f" functionality
+  * [IO-162] New XML Stream Reader/Writer implementations (from ROME via plexus-utils)
+
+  * [IO-142] Comparators - add facility to sort file lists/arrays
+  * [IO-186] Comparators - new Composite and Directory File Comparator implementations
+  * [IO-176] DirectoryWalker - add filterDirectoryContents() callback method for filtering directory contents
+  * [IO-210] FileFilter - new Magic Number FileFilter
+  * [IO-221] FileFilterUtils - add methods for suffix and prefix filters which take an IOCase object
+  * [IO-232] FileFilterUtils - add method for name filters which take an IOCase object
+  * [IO-229] FileFilterUtils - add varargs and() and or() methods
+  * [IO-198] FileFilterUtils - add ability to apply file filters to collections and arrays
+  * [IO-156] FilenameUtils - add normalize() and normalizeNoEndSeparator() methods which allow the separator character to be specified
+  * [IO-194] FileSystemUtils - add freeSpaceKb() method with no input arguments
+  * [IO-185] FileSystemUtils - add freeSpaceKb() methods that take a timeout parameter - fixes freeSpaceWindows() blocks
+  * [IO-155] FileUtils - use NIO to copy files
+  * [IO-168] FileUtils - add new isSymlink() method
+  * [IO-219] FileUtils - throw FileExistsException when moving a file or directory if the destination already exists
+  * [IO-234] FileUtils - add Methods for retrieving System User/Temp directories/paths
+  * [IO-208] FileUtils - add timeout (connection and read) support for copyURLToFile() method 
+  * [IO-238] FileUtils - add sizeOf(File) method
+  * [IO-181] LineIterator now implements Iterable
+  * [IO-224] IOUtils - add closeQuietly(Closeable) and closeQuietly(Socket) methods
+  * [IO-203] IOUtils - add skipFully() method for InputStreams
+  * [IO-137] IOUtils and ByteArrayOutputStream - add toBufferedInputStream() method to avoid unnecessary array allocation/copy
+  * [IO-195] Proxy streams/Reader/Writer - provide exception handling methods
+  * [IO-211] Proxy Input/Output streams - add pre/post processing support
+  * [IO-242] Proxy Reader/Writer - add pre/post processing support
+
+Bug fixes from 1.4
+------------------
+  * [IO-214] ByteArrayOutputStream - fix inconsistent synchronization of fields
+  * [IO-201] Counting Input/Output streams - fix inconsistent synchronization
+  * [IO-159] FileCleaningTracker - fix remove() never returns null
+  * [IO-220] FileCleaningTracker - fix Vector performs badly under load
+  * [IO-167] FilenameUtils - fix case-insensitive string handling in FilenameUtils and FilesystemUtils
+  * [IO-179] FilenameUtils - fix StringIndexOutOfBounds exception in getPathNoEndSeparator()
+  * [IO-248] FilenameUtils - fix getFullPathNoEndSeparator() returns empty while path is a one level directory
+  * [IO-246] FilenameUtils - fix wildcardMatch gives incorrect results 
+  * [IO-187] FileSystemUtils - fix freeSpaceKb() doesn't work with relative paths on Linux
+  * [IO-160] FileSystemUtils - fix freeSpace() fails on solaris
+  * [IO-209] FileSystemUtils - fix freeSpaceKb() fails to return correct size for a windows mount point
+  * [IO-163] FileUtils - fix toURLs() using deprecated method of conversion to URL
+  * [IO-168] FileUtils - fix Symbolic links followed when deleting directory
+  * [IO-231] FileUtils - fix wrong exception message generated in isFileNewer() method
+  * [IO-207] FileUtils - fix race condition in forceMkdir() method
+  * [IO-217] FileUtils - fix copyDirectoryToDirectory() makes infinite loops
+  * [IO-166] FileUtils - fix URL decoding in toFile(URL)
+  * [IO-190] FileUtils - fix copyDirectory not preserving lastmodified date on subdirectories
+  * [IO-240] FileFilterUtils - ensure cvsFilter and svnFilter are only created once.
+  * [IO-175] IOUtils - fix copyFile() issues with very large files
+  * [IO-191] Improvements from static analysis
+  * [IO-216] LockableFileWriter - delete files quietly when an exception is thrown during initialization
+  * [IO-243] SwappedDataInputStream - fix readBoolean is inverted
+  * [IO-235] Tests - remove unused YellOnFlushAndCloseOutputStream from CopyUtilsTest
+  * [IO-161] Tests - fix FileCleaningTrackerTestCase hanging
+
+Documentation changes from 1.4
+------------------------------
+  * [IO-183 FilenameUtils.getExtension() method documentation improvements
+  * [IO-226 FileUtils.byteCountToDisplaySize() documentation corrections
+  * [IO-205 FileUtils.forceMkdir() documentation improvements
+  * [IO-215 FileUtils copy file/directory improve documentation regarding preserving the last modified date
+  * [IO-189 HexDump.dump() method documentation improvements
+  * [IO-171 IOCase document that it assumes there are only two OSes: Windows and Unix
+  * [IO-223 IOUtils.copy() documentation corrections
+  * [IO-247 IOUtils.closeQuietly() improve documentation with examples
+  * [IO-202 NotFileFilter documentation corrections
+  * [IO-206 ProxyInputStream - fix misleading parameter names
+  * [IO-212 ProxyInputStream.skip() documentation corrections
+
+==============================================================================
+Apache Commons IO Version 1.4
+==============================================================================
+
+Compatibility with 1.3.2
+------------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+  Check the bug fixes section for semantic bug fixes
+
+Commons IO 1.4 introduces four new implementations which depend on Java 4 features
+(CharSequenceReader, FileWriterWithEncoding, IOExceptionWithCause and RegexFileFilter).
+It has been built with the JDK source and target options set to Java 1.3 and, except for
+those implementations, can be used with Java 1.3 (see IO-127).
+
+Deprecations from 1.3.2
+-----------------------
+- FileCleaner deprecated in favour of FileCleaningTracker [see IO-116]
+
+Bug fixes from 1.3.2
+--------------------
+- FileUtils
+  - forceDelete of orphaned Softlinks does not work [IO-147]
+  - Infinite loop on FileUtils.copyDirectory when the destination directory is within
+    the source directory [IO-141]
+  - Add a copyDirectory() method that makes use of FileFilter [IO-105]
+  - Add moveDirectory() and moveFile() methods [IO-77]
+
+- HexDump
+  - HexDump's use of static StringBuffers isn't thread-safe [IO-136]
+
+Enhancements from 1.3.2
+-----------------------
+- FileUtils
+  - Add a deleteQuietly method [IO-135]
+
+- FilenameUtils
+  - Add file name extension separator constants[IO-149]
+
+- IOExceptionWithCause [IO-148]
+  - Add a new IOException implementation with constructors which take a cause
+
+- TeeInputStream [IO-129]
+  - Add new Tee input stream implementation
+
+- FileWriterWithEncoding [IO-153]
+  - Add new File Writer implementation that accepts an encoding
+
+- CharSequenceReader [IO-138]
+  - Add new Reader implementation that handles any CharSequence (String,
+    StringBuffer, StringBuilder or CharBuffer) 
+
+- ThresholdingOutputStream [IO-121]
+  - Add a reset() method which sets the count of the bytes written back to zero.
+
+- DeferredFileOutputStream [IO-130]
+  - Add support for temporary files
+
+- ByteArrayOutputStream
+  - Add a new write(InputStream) method [IO-152]
+
+- New Closed Input/Output stream implementations [IO-122]
+  - AutoCloseInputStream - automatically closes and discards the underlying input stream
+  - ClosedInputStream - returns -1 for any read attempts
+  - ClosedOutputStream - throws an IOException for any write attempts
+  - CloseShieldInputStream - prevents the underlying input stream from being closed.
+  - CloseShieldOutputStream - prevents the underlying output stream from being closed.
+
+- Add Singleton Constants to several stream classes [IO-143]
+
+- PrefixFileFilter [IO-126]
+  - Add facility to specify case sensitivity on prefix matching
+
+- SuffixFileFilter [IO-126]
+  - Add facility to specify case sensitivity on suffix matching
+
+- RegexFileFilter [IO-74]
+  - Add new regular expression file filter implementation
+
+- Make IOFileFilter implementations Serializable [IO-131]
+
+- Improve IOFileFilter toString() methods [IO-120]
+
+- Make fields final so classes are immutable/threadsafe [IO-133]
+  - changes to Age, Delegate, Name, Not, Prefix, Regex, Size, Suffix and Wildcard IOFileFilter
+    implementations.
+
+- IOCase
+  - Add a compare method to IOCase [IO-144]
+
+- Add a package of java.util.Comparator implementations for files [IO-145]
+  - DefaultFileComparator - compare files using the default File.compareTo(File) method.
+  - ExtensionFileComparator - compares files using file name extensions.
+  - LastModifiedFileComparator - compares files using the last modified date/time.
+  - NameFileComparator - compares files using file names.
+  - PathFileComparator - compares files using file paths.
+  - SizeFileComparator - compares files using file sizes.
+  
+==============================================================================
+Apache Commons IO Version 1.3.2
+==============================================================================
+
+Compatibility with 1.3.1
+----------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+
+Compatibility with 1.3
+----------------------
+Binary compatible - No
+  See [IO-113]
+
+Source compatible - No
+  See [IO-113]
+
+Semantic compatible - Yes
+
+Enhancements since 1.3.1
+------------------------
+
+- Created the FileCleaningTracker, basically a non-static version of the
+  FileCleaner, which can be controlled by the user. [IO-116]
+- The FileCleaner is deprecated.
+
+Bug fixes from 1.3.1
+--------------------
+
+- Some tests, which are implicitly assuming a Unix-like file system, are
+  now skipped on Windows. [IO-115]
+- EndianUtils
+  - Both readSwappedUnsignedInteger(...) methods could return negative 
+    numbers due to int/long casting. [IO-117]
+
+Bug fixes from 1.3
+------------------
+
+- FileUtils
+  - NPE in openOutputStream(File) when file has no parent in path [IO-112]
+  - readFileToString(File) is not static [IO-113]
+
+==============================================================================
+Apache Commons IO Version 1.3.1
+==============================================================================
+
+Compatibility with 1.3
+----------------------
+Binary compatible - No
+  See [IO-113]
+
+Source compatible - No
+  See [IO-113]
+
+Semantic compatible - Yes
+
+Bug fixes from 1.3
+------------------
+
+- FileUtils
+  - NPE in openOutputStream(File) when file has no parent in path [IO-112]
+  - readFileToString(File) is not static [IO-113]
+  
+==============================================================================
+Apache Commons IO Version 1.3
+==============================================================================
+
+Compatibility with 1.2
+----------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+  Check the bug fixes section for semantic bug fixes
+
+Deprecations from 1.2
+---------------------
+- WildcardFilter deprecated, replaced by WildcardFileFilter
+  - old class only accepted files, thus had a confusing dual purpose
+
+- FileSystemUtils.freeSpace deprecated, replaced by freeSpaceKb
+  - freeSpace returns a result that varies by operating system and
+    thus isn't that useful
+  - freeSpaceKb returns much better and more consistent results
+  - freeSpaceKb existed in v1.2, so this is a gentle cutover
+
+Bug fixes from 1.2
+------------------
+- LineIterator now implements Iterator
+  - It was always supposed to...
+
+- FileSystemUtils.freeSpace/freeSpaceKb [IO-83]
+  - These should now work on AIX and HP-UX
+
+- FileSystemUtils.freeSpace/freeSpaceKb [IO-90]
+  - Avoid infinite looping in Windows
+  - Catch more errors with nice messages
+
+- FileSystemUtils.freeSpace [IO-91]
+  - This is now documented not to work on SunOS 5
+
+- FileSystemUtils [IO-93]
+  - Fixed resource leak leading to 'Too many open files' error
+  - Previously did not destroy Process instances (as JDK Javadoc is so poor)
+  - http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4801027
+
+- FileUtils.touch [IO-100]
+  - The touch method previously gave no indication when the file could not
+    be touched successfully (such as due to access restrictions) - it now
+    throws an IOException if the last modified date cannot be changed
+
+- FileCleaner
+  - This now handles the situation where an error occurs when deleting the file
+
+- IOUtils.copy [IO-84]
+  - Copy methods could return inaccurate byte/char count for large streams
+  - The copy(InputStream, OutputStream) method now returns -1 if the count is greater than an int
+  - The copy(Reader, Writer) method now throws now returns -1 if the count is greater than an int
+  - Added a new copyLarge(InputStream, OutputStream) method that returns a long
+  - Added a new copyLarge(Reader, Writer) method that returns a long
+
+- CountingInputStream/CountingOutputStream [IO-84]
+  - Methods were declared as int thus the count was inaccurate for large streams
+  - new long based methods getByteCount()/resetByteCount() added
+  - existing methods changed to throw an exception if the count is greater than an int
+
+- FileBasedTestCase
+  - Fixed bug in compare content methods identified by GNU classpath
+
+- EndianUtils.writeSwappedLong(byte[], int) [IO-101]
+  - An int overrun in the bit shifting when it should have been a long
+
+- EndianUtils.writeSwappedLong(InputStream) [IO-102]
+  - The return of input.read(byte[]) was not being checked to ensure all 8 bytes were read
+
+Enhancements from 1.2
+---------------------
+- DirectoryWalker [IO-86]
+  - New class designed for subclassing to walk through a set of files.
+    DirectoryWalker provides the walk of the directories, filtering of
+    directories and files, and cancellation support. The subclass must provide
+    the specific behavior, such as text searching or image processing.
+
+- IOCase
+  - New class/enumeration for case-sensitivity control
+
+- FilenameUtils
+  - New methods to handle case-sensitivity
+  - wildcardMatch - new method that has IOCase as a parameter
+  - equals - new method that has IOCase as a parameter
+
+- FileUtils [IO-108] - new default encoding methods for:
+  - readFileToString(File)
+  - readLines(File)
+  - lineIterator(File)
+  - writeStringToFile(File, String)
+  - writeLines(File, Collection)
+  - writeLines(File, Collection, String)
+
+- FileUtils.openOutputStream  [IO-107]
+  - new method to open a FileOutputStream, creating parent directories if required
+- FileUtils.touch
+- FileUtils.copyURLToFile
+- FileUtils.writeStringToFile
+- FileUtils.writeByteArrayToFile
+- FileUtils.writeLines
+  - enhanced to create parent directories if required
+- FileUtils.openInputStream  [IO-107]
+  - new method to open a FileInputStream, providing better error messages than the JDK
+
+- FileUtils.isFileOlder
+  - new methods to check if a file is older (i.e. isFileOlder()) - counterparts
+    to the existing isFileNewer() methods.
+
+- FileUtils.checksum, FileUtils.checksumCRC32
+  - new methods to create a checksum of a file
+
+- FileUtils.copyFileToDirectory  [IO-104]
+  - new variant that optionally retains the file date
+
+- FileDeleteStrategy
+- FileCleaner    [IO-56,IO-70]
+  - FileDeleteStrategy is a strategy for handling file deletion
+  - This can be used as a callback in FileCleaner
+  - Together these allow FileCleaner to do a forceDelete to kill directories
+
+- FileCleaner.exitWhenFinished [IO-99]
+  - A new method that allows the internal cleaner thread to be cleanly terminated
+
+- WildcardFileFilter
+  - Replacement for WildcardFilter
+  - Accepts both files and directories
+  - Ability to control case-sensitivity
+
+- NameFileFilter
+  - Ability to control case-sensitivity
+
+- FileFileFilter
+  - New IOFileFilter implementation
+  - Accepts files where File.isFile() is true
+  - In other words it filters out directories
+  - Singleton instance provided (FILE)
+
+- CanReadFileFilter
+  - New IOFileFilter implementation
+  - Accepts files where File.canRead() is true
+  - Singleton instances provided (CAN_READ/CANNOT_READ/READ_ONLY)
+
+- CanWriteFileFilter
+  - New IOFileFilter implementation
+  - Accepts files where File.canWrite() is true
+  - Singleton instances provided (CAN_WRITE/CANNOT_WRITE)
+
+- HiddenFileFilter
+  - New IOFileFilter implementation
+  - Accepts files where File.isHidden() is true
+  - Singleton instances provided (HIDDEN/VISIBLE)
+
+- EmptyFileFilter
+  - New IOFileFilter implementation
+  - Accepts files or directories that are empty
+  - Singleton instances provided (EMPTY/NOT_EMPTY)
+
+- TrueFileFilter/FalseFileFilter/DirectoryFileFilter
+  - New singleton instance constants (TRUE/FALSE/DIRECTORY)
+  - The new constants are more Java 5 friendly with regards to static imports
+    (whereas if everything uses INSTANCE, then they just clash)
+  - The old INSTANCE constants are still present and have not been deprecated
+
+- FileFilterUtils.sizeRangeFileFilter
+  - new sizeRangeFileFilter(long minimumSize, long maximumSize) method which 
+    creates a filter that accepts files within the specified size range.
+
+- FileFilterUtils.makeDirectoryOnly/makeFileOnly
+  - two new methods that decorate a file filter to make it apply to
+    directories only or files only
+
+- NullWriter
+  - New writer that acts as a sink for all data, as per /dev/null
+
+- NullInputStream
+  - New input stream that emulates a stream of a specified size
+
+- NullReader
+  - New reader that emulates a reader of a specified size
+
+- ByteArrayOutputStream  [IO-97]
+  - Performance enhancements
+
+==============================================================================
+Apache Commons IO Version 1.2
+==============================================================================
+
+Compatibility with 1.1
+----------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+
+Deprecations from 1.1
+---------------------
+
+Bug fixes from 1.1
+------------------
+- FileSystemUtils.freeSpace(drive)
+  Fix to allow Windows based command to function in French locale
+
+- FileUtils.read*
+  Increase certainty that files are closed in case of error
+
+- LockableFileWriter
+  Locking mechanism was broken and only provided limited protection [38942]
+  File deletion and locking in case of constructor error was broken
+
+Enhancements from 1.1
+---------------------
+- AgeFileFilter/SizeFileFilter
+  New file filters that compares against the age and size of the file
+
+- FileSystemUtils.freeSpaceKb(drive)
+  New method that unifies result to be in kilobytes [38574]
+
+- FileUtils.contentEquals(File,File)
+  Performance improved by adding length and file location checking
+
+- FileUtils.iterateFiles
+  Two new method to provide direct access to iterators over files
+
+- FileUtils.lineIterator
+  IOUtils.lineIterator
+  New methods to provide an iterator over the lines in a file [38083]
+
+- FileUtils.copyDirectoryToDirectory
+  New method to copy a directory to within another directory [36315]
+  
+==============================================================================
+Apache Commons IO Version 1.1
+==============================================================================
+
+Incompatible changes from 1.0
+-----------------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes, except:
+- FileUtils.writeStringToFile()
+    A null encoding previously used 'ISO-8859-1', now it uses the platform default
+    Generally this will make no difference
+
+- LockableFileWriter
+    Improved validation and now create directories if necessary
+
+plus these bug fixes may affect you semantically:
+- FileUtils.touch()  (Bug fix 29821)
+    Now creates the file if it did not previously exist
+
+- FileUtils.toFile(URL) (Bug fix 32575)
+    Now handles escape syntax such as %20
+
+- FileUtils.sizeOfDirectory()  (Bug fix 36801)
+    May now return a size of 0 if the directory is security restricted
+
+Deprecations from 1.0
+---------------------
+- CopyUtils has been deprecated.
+    Its methods have been moved to IOUtils.
+    The new IOUtils methods handle nulls better, and have clearer names.
+
+- IOUtils.toByteArray(String) - Use {@link String#getBytes()}
+- IOUtils.toString(byte[]) - Use {@link String#String(byte[])}
+- IOUtils.toString(byte[],String) - Use {@link String#String(byte[],String)}
+
+Bug fixes from 1.0
+------------------
+- FileUtils - touch()  [29821]
+    Now creates the file if it did not previously exist
+
+- FileUtils - toFile(URL)  [32575]
+    Now handles escape syntax such as %20
+
+- FileFilterUtils - makeCVSAware(IOFileFilter)  [33023]
+    Fixed bug that caused method to be completely broken
+
+- CountingInputStream  [33336]
+    Fixed bug that caused the count to reduce by one at the end of the stream
+
+- CountingInputStream - skip(long)  [34311]
+    Bytes from calls to this method were not previously counted
+
+- NullOutputStream  [33481]
+    Remove unnecessary synchronization
+
+- AbstractFileFilter - accept(File, String)  [30992]
+    Fixed broken implementation
+
+- FileUtils  [36801]
+    Previously threw NPE when listing files in a security restricted directory
+    Now throw IOException with a better message
+
+- FileUtils - writeStringToFile()
+    Null encoding now correctly uses the platform default
+
+Enhancements from 1.0
+---------------------
+- FilenameUtils - new class  [33303,29351]
+    A static utility class for working with filenames
+    Seeks to ease the pain of developing on Windows and deploying on Unix
+
+- FileSystemUtils - new class  [32982,36325]
+    A static utility class for working with file systems
+    Provides one method at present, to get the free space on the filing system
+
+- IOUtils - new public constants
+    Constants for directory and line separators on Windows and Unix
+
+- IOUtils - toByteArray(Reader,encoding)
+    Handles encodings when reading to a byte array
+
+- IOUtils - toCharArray(InputStream)  [28979]
+          - toCharArray(InputStream,encoding)
+          - toCharArray(Reader)
+    Reads a stream/reader into a character array
+
+- IOUtils - readLines(InputStream)  [36214]
+          - readLines(InputStream,encoding)
+          - readLines(Reader)
+    Reads a stream/reader line by line into a List of Strings
+
+- IOUtils - toInputStream(String)  [32958]
+          - toInputStream(String,encoding)
+    Creates an input stream that uses the string as a source of data
+
+- IOUtils - writeLines(Collection,lineEnding,OutputStream)  [36214]
+          - writeLines(Collection,lineEnding,OutputStream,encoding)
+          - writeLines(Collection,lineEnding,Writer)
+    Writes a collection to a stream/writer line by line
+
+- IOUtils - write(...)
+    Write data to a stream/writer (moved from CopyUtils with better null handling)
+
+- IOUtils - copy(...)
+    Copy data between streams (moved from CopyUtils with better null handling)
+
+- IOUtils - contentEquals(Reader,Reader)
+    Method to compare the contents of two readers
+
+- FileUtils - toFiles(URL[])
+    Converts an array of URLs to an array of Files
+
+- FileUtils - copyDirectory()  [32944]
+    New methods to copy a directory
+
+- FileUtils - readFileToByteArray(File)
+    Reads an entire file into a byte array
+
+- FileUtils - writeByteArrayToFile(File,byte[])
+    Writes a byte array to a file
+
+- FileUtils - readLines(File,encoding)  [36214]
+    Reads a file line by line into a List of Strings
+
+- FileUtils - writeLines(File,encoding,List)
+              writeLines(File,encoding,List,lineEnding)
+    Writes a collection to a file line by line
+
+- FileUtils - EMPTY_FILE_ARRAY
+    Constant for an empty array of File objects
+
+- ConditionalFileFilter - new interface  [30705]
+    Defines the behavior of list based filters
+
+- AndFileFilter, OrFileFilter  [30705]
+    Now support a list of filters to and/or
+
+- WildcardFilter  [31115]
+    New filter that can match using wildcard file names
+
+- FileFilterUtils - makeSVNAware(IOFileFilter)
+    New method, like makeCVSAware, that ignores Subversion source control directories
+
+- ClassLoaderObjectInputStream
+    An ObjectInputStream that supports a ClassLoader
+
+- CountingInputStream,CountingOutputStream - resetCount()  [28976]
+    Adds the ability to reset the count part way through reading/writing the stream
+
+- DeferredFileOutputStream - writeTo(OutputStream)  [34173]
+    New method to allow current contents to be written to a stream
+
+- DeferredFileOutputStream  [34142]
+    Performance optimizations avoiding double buffering
+
+- LockableFileWriter - encoding support [36825]
+    Add support for character encodings to LockableFileWriter
+    Improve the validation
+    Create directories if necesssary
+
+- IOUtils and EndianUtils are no longer final  [28978]
+    Allows developers to have subclasses if desired   
+
+==============================================================================
+Feedback
+==============================================================================
+
+Open source works best when you give feedback:
+https://commons.apache.org/io/
+
+Please direct all bug reports to JIRA
+https://issues.apache.org/jira/browse/IO
+
+Or subscribe to the commons-user mailing list (prefix emails by [io])
+https://commons.apache.org/mail-lists.html
+
+The Commons-IO Team
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..51943ba
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,17 @@
+<!---
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+The Apache Commons security page is [https://commons.apache.org/security.html](https://commons.apache.org/security.html).
diff --git a/TEST_MAPPING b/TEST_MAPPING
new file mode 100644
index 0000000..44c538d
--- /dev/null
+++ b/TEST_MAPPING
@@ -0,0 +1,7 @@
+{
+  "presubmit": [
+    {
+       "name": "OnDevicePersonalizationManagingServicesTests"
+    }
+ ]
+}
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..2f4af08
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,644 @@
+<?xml version="1.0"?>
+<!--
+   Licensed to the Apache Software Foundation (ASF) under one or more
+   contributor license agreements.  See the NOTICE file distributed with
+   this work for additional information regarding copyright ownership.
+   The ASF licenses this file to You under the Apache License, Version 2.0
+   (the "License"); you may not use this file except in compliance with
+   the License.  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+  <parent>
+    <groupId>org.apache.commons</groupId>
+    <artifactId>commons-parent</artifactId>
+    <version>56</version>
+  </parent>
+  <modelVersion>4.0.0</modelVersion>
+  <groupId>commons-io</groupId>
+  <artifactId>commons-io</artifactId>
+  <version>2.12.0-SNAPSHOT</version>
+  <name>Apache Commons IO</name>
+
+  <inceptionYear>2002</inceptionYear>
+  <description>
+The Apache Commons IO library contains utility classes, stream implementations, file filters,
+file comparators, endian transformation classes, and much more.
+  </description>
+
+  <url>https://commons.apache.org/proper/commons-io/</url>
+
+  <issueManagement>
+    <system>jira</system>
+    <url>https://issues.apache.org/jira/browse/IO</url>
+  </issueManagement>
+
+  <distributionManagement>
+    <site>
+      <id>apache.website</id>
+      <name>Apache Commons Site</name>
+      <url>scm:svn:https://svn.apache.org/repos/infra/websites/production/commons/content/proper/commons-io/</url>
+    </site>
+  </distributionManagement>
+
+  <scm>
+    <connection>scm:git:https://gitbox.apache.org/repos/asf/commons-io.git</connection>
+    <developerConnection>scm:git:https://gitbox.apache.org/repos/asf/commons-io.git</developerConnection>
+    <url>https://gitbox.apache.org/repos/asf?p=commons-io.git</url>
+    <tag>rel/commons-io-2.11.0</tag>
+  </scm>
+
+  <developers>
+    <developer>
+      <name>Scott Sanders</name>
+      <id>sanders</id>
+      <email>sanders@apache.org</email>
+      <organization />
+      <roles>
+        <role>Java Developer</role>
+      </roles>
+    </developer>
+    <developer>
+      <name>dIon Gillard</name>
+      <!-- Note: first name is correctly capitalised above -->
+      <id>dion</id>
+      <email>dion@apache.org</email>
+      <organization />
+      <roles>
+        <role>Java Developer</role>
+      </roles>
+    </developer>
+    <developer>
+      <name>Nicola Ken Barozzi</name>
+      <id>nicolaken</id>
+      <email>nicolaken@apache.org</email>
+      <organization />
+      <roles>
+        <role>Java Developer</role>
+      </roles>
+    </developer>
+    <developer>
+      <name>Henri Yandell</name>
+      <id>bayard</id>
+      <email>bayard@apache.org</email>
+      <organization />
+      <roles>
+        <role>Java Developer</role>
+      </roles>
+    </developer>
+    <developer>
+      <name>Stephen Colebourne</name>
+      <id>scolebourne</id>
+      <organization />
+      <roles>
+        <role>Java Developer</role>
+      </roles>
+      <timezone>0</timezone>
+    </developer>
+    <developer>
+      <name>Jeremias Maerki</name>
+      <id>jeremias</id>
+      <email>jeremias@apache.org</email>
+      <organization />
+      <roles>
+        <role>Java Developer</role>
+      </roles>
+      <timezone>+1</timezone>
+    </developer>
+    <developer>
+      <name>Matthew Hawthorne</name>
+      <id>matth</id>
+      <email>matth@apache.org</email>
+      <organization />
+      <roles>
+        <role>Java Developer</role>
+      </roles>
+    </developer>
+    <developer>
+      <name>Martin Cooper</name>
+      <id>martinc</id>
+      <email>martinc@apache.org</email>
+      <organization />
+      <roles>
+        <role>Java Developer</role>
+      </roles>
+    </developer>
+    <developer>
+      <name>Rob Oxspring</name>
+      <id>roxspring</id>
+      <email>roxspring@apache.org</email>
+      <organization />
+      <roles>
+        <role>Java Developer</role>
+      </roles>
+    </developer>
+    <developer>
+      <name>Jochen Wiedmann</name>
+      <id>jochen</id>
+      <email>jochen.wiedmann@gmail.com</email>
+    </developer>
+    <developer>
+      <name>Niall Pemberton</name>
+      <id>niallp</id>
+      <roles>
+        <role>Java Developer</role>
+      </roles>
+    </developer>
+    <developer>
+      <name>Jukka Zitting</name>
+      <id>jukka</id>
+      <roles>
+        <role>Java Developer</role>
+      </roles>
+    </developer>
+    <developer>
+      <id>ggregory</id>
+      <name>Gary Gregory</name>
+      <email>ggregory at apache.org</email>
+      <url>https://www.garygregory.com</url>
+      <organization>The Apache Software Foundation</organization>
+      <organizationUrl>https://www.apache.org/</organizationUrl>      
+      <roles>
+        <role>PMC Member</role>
+      </roles>
+      <timezone>America/New_York</timezone>
+      <properties>
+        <picUrl>https://people.apache.org/~ggregory/img/garydgregory80.png</picUrl>
+      </properties>
+    </developer>
+    <developer>
+      <name>Kristian Rosenvold</name>
+      <id>krosenvold</id>
+      <email>krosenvold@apache.org</email>
+      <timezone>+1</timezone>
+    </developer>
+  </developers>
+
+  <contributors>
+    <contributor>
+      <name>Rahul Akolkar</name>
+    </contributor>
+    <contributor>
+      <name>Jason Anderson</name>
+    </contributor>
+    <contributor>
+      <name>Nathan Beyer</name>
+    </contributor>
+    <contributor>
+      <name>Emmanuel Bourg</name>
+    </contributor>
+    <contributor>
+      <name>Chris Eldredge</name>
+    </contributor>
+    <contributor>
+      <name>Magnus Grimsell</name>
+    </contributor>
+    <contributor>
+      <name>Jim Harrington</name>
+    </contributor>
+    <contributor>
+      <name>Thomas Ledoux</name>
+    </contributor>
+    <contributor>
+      <name>Andy Lehane</name>
+    </contributor>
+    <contributor>
+      <name>Marcelo Liberato</name>
+    </contributor>
+    <contributor>
+      <name>Alban Peignier</name>
+      <email>alban.peignier at free.fr</email>
+    </contributor>
+    <contributor>
+      <name>Adam Retter</name>
+      <organization>Evolved Binary</organization>
+    </contributor>
+    <contributor>
+      <name>Ian Springer</name>
+    </contributor>
+    <contributor>
+      <name>Dominik Stadler</name>
+    </contributor>
+    <contributor>
+      <name>Masato Tezuka</name>
+    </contributor>
+    <contributor>
+      <name>James Urie</name>
+    </contributor>
+    <contributor>
+      <name>Frank W. Zammetti</name>
+    </contributor>
+    <contributor>
+      <name>Martin Grigorov</name>
+      <email>mgrigorov@apache.org</email>
+    </contributor>
+    <contributor>
+      <name>Arturo Bernal</name>
+    </contributor>
+  </contributors>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.junit-pioneer</groupId>
+      <artifactId>junit-pioneer</artifactId>
+      <version>1.9.1</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-inline</artifactId>
+      <version>4.11.0</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.google.jimfs</groupId>
+      <artifactId>jimfs</artifactId>
+      <version>1.2</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.commons</groupId>
+      <artifactId>commons-lang3</artifactId>
+      <version>3.12.0</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.openjdk.jmh</groupId>
+      <artifactId>jmh-core</artifactId>
+      <version>${jmh.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.openjdk.jmh</groupId>
+      <artifactId>jmh-generator-annprocess</artifactId>
+      <version>${jmh.version}</version>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+
+  <properties>
+    <maven.compiler.source>1.8</maven.compiler.source>
+    <maven.compiler.target>1.8</maven.compiler.target>
+    <commons.componentid>io</commons.componentid>
+    <commons.module.name>org.apache.commons.io</commons.module.name>
+    <commons.rc.version>RC1</commons.rc.version>
+    <commons.bc.version>2.11.0</commons.bc.version>
+    <commons.release.version>2.12.0</commons.release.version>
+    <commons.release.desc>(requires Java 8)</commons.release.desc>
+    <commons.jira.id>IO</commons.jira.id>
+    <commons.jira.pid>12310477</commons.jira.pid>
+    <commons.osgi.export>
+        <!-- Explicit list of packages from IO 1.4 -->
+        org.apache.commons.io;
+        org.apache.commons.io.comparator;
+        org.apache.commons.io.filefilter;
+        org.apache.commons.io.input;
+        org.apache.commons.io.output;version=1.4.9999;-noimport:=true,
+        <!-- Same list plus * for new packages -->
+        org.apache.commons.io;
+        org.apache.commons.io.comparator;
+        org.apache.commons.io.filefilter;
+        org.apache.commons.io.input;
+        org.apache.commons.io.output;
+        org.apache.commons.io.*;version=${project.version};-noimport:=true
+    </commons.osgi.export>
+    <commons.osgi.import>
+        <!-- IO-734 - Make the sun.* references from BufferedFileChannelInputStream optional -->
+        sun.nio.ch;resolution:=optional,
+        sun.misc;resolution:=optional,
+        *
+    </commons.osgi.import>
+    <commons.scmPubUrl>https://svn.apache.org/repos/infra/websites/production/commons/content/proper/commons-io/</commons.scmPubUrl>
+    <commons.scmPubCheckoutDirectory>site-content</commons.scmPubCheckoutDirectory>
+    <commons.javadoc.java.link>${commons.javadoc8.java.link}</commons.javadoc.java.link>
+    <commons.enforcer.version>3.2.1</commons.enforcer.version>
+    <commons.moditect.version>1.0.0.RC2</commons.moditect.version>
+    <jmh.version>1.36</jmh.version>
+    <japicmp.skip>false</japicmp.skip>
+    <jacoco.skip>${env.JACOCO_SKIP}</jacoco.skip>
+    <commons.release.isDistModule>true</commons.release.isDistModule>
+    <commons.releaseManagerName>Gary Gregory</commons.releaseManagerName>
+    <commons.releaseManagerKey>86fdc7e2a11262cb</commons.releaseManagerKey>
+  </properties>
+
+  <build>
+    <!-- japicmp:cmp needs package to work from a jar -->
+    <defaultGoal>clean verify apache-rat:check japicmp:cmp checkstyle:check pmd:check javadoc:javadoc</defaultGoal>
+    <pluginManagement>
+      <plugins>
+        <plugin>
+          <groupId>org.apache.rat</groupId>
+          <artifactId>apache-rat-plugin</artifactId>
+          <version>0.15</version>
+          <configuration>
+            <excludes>
+              <exclude>src/test/resources/**/*.bin</exclude>
+              <exclude>src/test/resources/dir-equals-tests/**</exclude>
+              <exclude>test/**</exclude>
+            </excludes>
+          </configuration>
+        </plugin>
+        <plugin>
+          <groupId>org.apache.maven.plugins</groupId>
+          <artifactId>maven-checkstyle-plugin</artifactId>
+          <configuration>
+            <configLocation>${basedir}/src/conf/checkstyle.xml</configLocation>
+            <suppressionsLocation>${basedir}/src/conf/checkstyle-suppressions.xml</suppressionsLocation>
+            <enableRulesSummary>false</enableRulesSummary>
+            <includeTestSourceDirectory>true</includeTestSourceDirectory>
+          </configuration>
+        </plugin>      
+      </plugins>
+    </pluginManagement>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-enforcer-plugin</artifactId>
+        <version>${commons.enforcer.version}</version>
+        <executions>
+          <execution>
+            <id>enforce-maven</id>
+            <goals>
+              <goal>enforce</goal>
+            </goals>
+            <configuration>
+              <rules>
+                <requireMavenVersion>
+                  <version>3.0.5</version>
+                </requireMavenVersion>
+              </rules>    
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-jar-plugin</artifactId>
+        <executions>
+          <execution>
+            <goals>
+              <goal>test-jar</goal>
+            </goals>
+          </execution>
+        </executions>
+        <configuration>
+          <archive combine.children="append">
+            <manifestEntries>
+              <Automatic-Module-Name>${commons.module.name}</Automatic-Module-Name>
+            </manifestEntries>
+          </archive>
+        </configuration>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-surefire-plugin</artifactId>
+        <configuration>
+          <classpathDependencyExcludes>
+            <classpathDependencyExclude>xerces:xercesImpl</classpathDependencyExclude>
+          </classpathDependencyExcludes>
+          <forkCount>1</forkCount>
+          <reuseForks>false</reuseForks>
+          <!-- limit memory size see IO-161 -->
+          <argLine>${argLine} -Xmx25M</argLine>
+          <includes>
+            <!-- Only include test classes, not test data -->
+            <include>**/*Test*.class</include>
+          </includes>
+          <excludes>
+            <exclude>**/*AbstractTestCase*</exclude>
+            <exclude>**/testtools/**</exclude>
+            <!-- https://issues.apache.org/jira/browse/SUREFIRE-44 -->
+            <exclude>**/*$*</exclude>
+          </excludes>
+        </configuration>
+      </plugin>
+      <plugin>
+        <artifactId>maven-assembly-plugin</artifactId>
+        <configuration>
+          <descriptors>
+            <descriptor>src/assembly/bin.xml</descriptor>
+            <descriptor>src/assembly/src.xml</descriptor>
+          </descriptors>
+          <tarLongFileMode>gnu</tarLongFileMode>
+        </configuration>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-checkstyle-plugin</artifactId>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-scm-publish-plugin</artifactId>
+        <configuration>
+          <ignorePathsToDelete>
+            <ignorePathToDelete>javadocs</ignorePathToDelete>
+          </ignorePathsToDelete>
+        </configuration>
+      </plugin>
+      <plugin>
+        <groupId>com.github.spotbugs</groupId>
+        <artifactId>spotbugs-maven-plugin</artifactId>
+        <configuration>
+          <excludeFilterFile>${basedir}/src/conf/spotbugs-exclude-filter.xml</excludeFilterFile>
+        </configuration>
+      </plugin>
+      <plugin>
+        <groupId>com.github.siom79.japicmp</groupId>
+        <artifactId>japicmp-maven-plugin</artifactId>
+      </plugin>
+    </plugins>
+  </build>
+
+  <reporting>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-checkstyle-plugin</artifactId>
+      </plugin>
+      <plugin>
+        <groupId>com.github.spotbugs</groupId>
+        <artifactId>spotbugs-maven-plugin</artifactId>
+        <configuration>
+          <excludeFilterFile>${basedir}/src/conf/spotbugs-exclude-filter.xml</excludeFilterFile>
+        </configuration>
+      </plugin>
+      <plugin>
+        <groupId>com.github.siom79.japicmp</groupId>
+        <artifactId>japicmp-maven-plugin</artifactId>
+      </plugin>    
+    </plugins>
+  </reporting>
+  <profiles>
+    <profile>
+      <id>setup-checkout</id>
+      <activation>
+        <file>
+          <missing>site-content</missing>
+        </file>
+      </activation>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-antrun-plugin</artifactId>
+            <version>3.1.0</version>
+            <executions>
+              <execution>
+                <id>prepare-checkout</id>
+                <phase>pre-site</phase>
+                <goals>
+                  <goal>run</goal>
+                </goals>
+                <configuration>
+                  <target>
+                    <exec executable="svn">
+                      <arg line="checkout --depth immediates ${commons.scmPubUrl} ${commons.scmPubCheckoutDirectory}" />
+                    </exec>
+
+                    <exec executable="svn">
+                      <arg line="update --set-depth exclude ${commons.scmPubCheckoutDirectory}/javadocs" />
+                    </exec>
+
+                    <pathconvert pathsep=" " property="dirs">
+                      <dirset dir="${commons.scmPubCheckoutDirectory}" includes="*" />
+                    </pathconvert>
+                    <exec executable="svn">
+                      <arg line="update --set-depth infinity ${dirs}" />
+                    </exec>
+                  </target>
+                </configuration>
+              </execution>
+            </executions>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+    <profile>
+      <id>java9-compile</id>
+      <activation>
+        <jdk>[9,)</jdk>
+      </activation>
+      <properties>
+        <!-- coverall version 4.3.0 does not work with java 9, see https://github.com/trautonen/coveralls-maven-plugin/issues/112 -->
+        <coveralls.skip>true</coveralls.skip>
+        <maven.compiler.release>8</maven.compiler.release>
+      </properties>
+    </profile>
+    <profile>
+      <id>java9-moditect</id>
+      <activation>
+        <!-- 
+        Fails on Java 11 and Windows:
+        Error:  Failed to execute goal org.moditect:moditect-maven-plugin:1.0.0.RC2:add-module-info (add-module-infos) on project commons-io: Execution add-module-infos of goal org.moditect:moditect-maven-plugin:1.0.0.RC2:add-module-info failed: Couldn't add module-info.class to JAR: D:\a\commons-io\commons-io\target\commons-io-2.12.0-SNAPSHOT.jar: The process cannot access the file because it is being used by another process. -> [Help 1]
+        -->
+        <jdk>[9,11)</jdk>
+      </activation>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.moditect</groupId>
+            <artifactId>moditect-maven-plugin</artifactId>
+            <version>${commons.moditect.version}</version>
+            <executions>
+              <execution>
+                <id>add-module-infos</id>
+                <phase>package</phase>
+                <goals>
+                  <goal>add-module-info</goal>
+                </goals>
+                <configuration>
+                  <jvmVersion>9</jvmVersion>
+                  <outputDirectory>${project.build.directory}</outputDirectory>
+                  <overwriteExistingFiles>true</overwriteExistingFiles>
+                  <module>
+                    <moduleInfo>
+                      <name>org.apache.commons.io</name>
+                    </moduleInfo>
+                  </module>
+                </configuration>
+              </execution>
+            </executions>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+    <profile>
+      <id>benchmark</id>
+      <properties>
+        <skipTests>true</skipTests>
+        <benchmark>org.apache</benchmark>
+      </properties>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.codehaus.mojo</groupId>
+            <artifactId>exec-maven-plugin</artifactId>
+            <version>3.1.0</version>
+            <executions>
+              <execution>
+                <id>benchmark</id>
+                <phase>test</phase>
+                <goals>
+                  <goal>exec</goal>
+                </goals>
+                <configuration>
+                  <classpathScope>test</classpathScope>
+                  <executable>java</executable>
+                  <arguments>
+                    <argument>-classpath</argument>
+                    <classpath/>
+                    <argument>org.openjdk.jmh.Main</argument>
+                    <argument>-rf</argument>
+                    <argument>json</argument>
+                    <argument>-rff</argument>
+                    <argument>target/jmh-result.${benchmark}.json</argument>
+                    <argument>${benchmark}</argument>
+                  </arguments>
+                </configuration>
+              </execution>
+            </executions>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+    <profile>
+      <id>release</id>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-enforcer-plugin</artifactId>
+            <version>${commons.enforcer.version}</version>
+            <executions>
+              <execution>
+                <id>enforce-versions</id>
+                <goals>
+                  <goal>enforce</goal>
+                </goals>
+                <configuration>
+                  <rules>
+                   <requireJavaVersion>
+                      <version>9</version>
+                    </requireJavaVersion>
+                  </rules>
+                </configuration>
+              </execution>
+            </executions>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+  </profiles>
+</project>
diff --git a/src/assembly/bin.xml b/src/assembly/bin.xml
new file mode 100644
index 0000000..cf3f008
--- /dev/null
+++ b/src/assembly/bin.xml
@@ -0,0 +1,49 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<assembly>
+    <id>bin</id>
+    <formats>
+        <format>tar.gz</format>
+        <format>zip</format>
+    </formats>
+    <includeSiteDirectory>false</includeSiteDirectory>
+    <fileSets>
+        <fileSet>
+            <includes>
+                <include>LICENSE.txt</include>
+                <include>NOTICE.txt</include>
+                <include>RELEASE-NOTES.txt</include>
+            </includes>
+        </fileSet>
+        <fileSet>
+            <directory>target</directory>
+            <outputDirectory></outputDirectory>
+            <includes>
+                <include>*.jar</include>
+            </includes>
+        </fileSet>
+        <fileSet>
+            <directory>target/site/apidocs</directory>
+            <outputDirectory>docs</outputDirectory>
+            <excludes>
+                <exclude>**/customsorttypes.js</exclude>
+                <exclude>**/sortabletable.js</exclude>
+                <exclude>**/stringbuilder.js</exclude>
+            </excludes>
+        </fileSet>
+    </fileSets>
+</assembly>
diff --git a/src/assembly/src.xml b/src/assembly/src.xml
new file mode 100644
index 0000000..f5373b0
--- /dev/null
+++ b/src/assembly/src.xml
@@ -0,0 +1,43 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<assembly>
+    <id>src</id>
+    <formats>
+        <format>tar.gz</format>
+        <format>zip</format>
+    </formats>
+    <baseDirectory>${artifactId}-${commons.release.version}-src</baseDirectory>
+    <fileSets>
+        <fileSet>
+            <includes>
+                <include>checkstyle.xml</include>
+                <include>CONTRIBUTING.md</include>
+                <include>LICENSE.txt</include>
+                <include>NOTICE.txt</include>
+                <include>pom.xml</include>
+                <include>PROPOSAL.html</include>
+                <include>README.md</include>
+                <include>RELEASE-NOTES.txt</include>
+                <include>SECURITY.md</include>
+                <include>spotbugs-exclude-filter.xml</include>
+            </includes>
+        </fileSet>
+        <fileSet>
+            <directory>src</directory>
+        </fileSet>
+    </fileSets>
+</assembly>
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
new file mode 100644
index 0000000..10240f2
--- /dev/null
+++ b/src/changes/changes.xml
@@ -0,0 +1,1723 @@
+<?xml version="1.0"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!--
+This file is used by the maven-changes-plugin to generate the release notes.
+Useful ways of finding items to add to this file are:
+
+1.  Add items when you fix a bug or add a feature (this makes the
+release process easy :-).
+
+2.  Do a Jira search for tickets closed since the previous release.
+
+3.  Use the report generated by the maven-changelog-plugin to see all
+CVS commits.  Set the project.properties' maven.changelog.range
+property to the number of days since the last release.
+
+
+To generate the release notes from this file:
+
+mvn changes:announcement-generate -Prelease-notes [-Dchanges.version=nnn]
+
+then tweak the source formatting if necessary and regenerate, then commit
+
+The <action> type attribute can be add,update,fix,remove.
+-->
+
+<document>
+  <properties>
+    <title>Apache Commons IO Release Notes</title>
+  </properties>
+
+  <body>
+    <release version="2.12.0" date="2021-MM-DD" description="Java 8 required.">
+      <!-- FIX -->
+      <action issue="IO-697" dev="kinow" type="fix" due-to="otter606">
+        IOUtils.toByteArray size validation does not match documentation.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Gary Gregory">
+        Fix Javadoc links to the JRE Javadoc 8.
+      </action>
+      <action issue="IO-744" dev="ggregory" type="fix" due-to="RBRi, Gary Gregory">
+        FileWriterWithEncoding for an existing file no longer truncates the file. #251.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Gary Gregory">
+        A null Charset or Charset name in FileWriterWithEncoding constructors uses the default Charset.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Davide Angelocola">
+        Fix usage of assertNotNull #269.
+      </action>
+      <action issue="IO-727" dev="ggregory" type="fix" due-to="trungPa, Gary Gregory">
+        FilenameUtils directoryContains() should handle files with the same prefix #217.
+      </action>
+      <action issue="IO-746" dev="ggregory" type="add" due-to="Davide Angelocola">
+        Drop unnecessary casts and conversions #267.
+      </action>
+      <action issue="IO-748" dev="ggregory" type="fix" due-to="Dirk Heinrichs, Gary Gregory, Elango Ravi">
+        FileUtils.moveToDirectory() exception documentation and exception message error.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Gary Gregory">
+        ThreadMonitor.sleep(Duration) ignores nanoseconds.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Arturo Bernal">
+        Fix Javadoc in ThreadMonitor#run() method. #273.
+      </action>
+      <action issue="IO-749" dev="ggregory" type="fix" due-to="haihuiyang, Gary Gregory">
+        FileUtils.listFiles() does not list matching files if File parameter is a symbolic link.
+      </action>
+      <action dev="ggregory" type="fix" due-to="niranjanghule, Gary Gregory">
+        Fix typo in Javadocs for FileUtils#convertFileCollectionToFileArray() #276.
+      </action>
+      <action dev="ggregory" type="fix" due-to="DaGeRe, Gary Gregory">
+        Avoid Code Duplication: Reuse Sleep from ThreadMonitor #66.
+      </action>
+      <action issue="IO-750" dev="ggregory" type="fix" due-to="Sita Geßner, Sebastian Peters, Gary Gregory">
+        FileUtils.iterateFiles also lists directories.
+      </action>
+      <action issue="IO-721" dev="ggregory" type="fix" due-to="Dirk Heinrichs, Gary Gregory">
+        Wrong exception message in FileUtils.setLastModified(File, File).
+      </action>
+      <action issue="IO-717" dev="ggregory" type="fix" due-to="Marcono1234, Gary Gregory">
+        Infinite loop in ReaderInputStream instead of throwing exception for CodingErrorAction.REPORT.
+      </action>
+      <action issue="IO-716" dev="ggregory" type="fix" due-to="Marcono1234, Gary Gregory">
+        ReaderInputStream enter infinite loop for too small buffer sizes.
+      </action>
+      <action issue="IO-638" dev="ggregory" type="fix" due-to="Thayne McCombs, Gary Gregory">
+        Infinite loop in CharSequenceInputStream.read for 4-byte characters with UTF-8 and 3-byte buffer.
+      </action>
+      <action issue="IO-638" dev="ggregory" type="fix" due-to="Gary Gregory">
+        PathUtils.setReadOnly(Path, boolean, LinkOption...) should add READ_* file attributes when using POSIX.
+      </action>
+      <action issue="IO-638" dev="ggregory" type="fix" due-to="Gary Gregory">
+        PathUtils.setReadOnly(Path, boolean, LinkOption...) readOnly argument is always assumed true on POSIX.
+      </action>
+      <action issue="IO-729" dev="ggregory" type="fix" due-to="Rob Spoor, Gary Gregory">
+        Prevent IllegalArgumentExceptions in BrokenInputStream/Reader/OutputStream/Writer #278.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Gary Gregory">
+        FileUtils.copyURLToFile(URL, File, int, int) leaks its URLConnection.
+        Called by FileUtils.copyURLToFile(URL, File).
+      </action>
+      <action issue="IO-714" dev="ggregory" type="fix" due-to="kevinwang1975, Gary Gregory">
+        Fixed ReaderInputStream not calling CharsetEncoder.flush issue #283.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Arturo Bernal">
+        Minor changes #287.
+      </action>
+      <action issue="IO-756" dev="ggregory" type="fix" due-to="wodencafe, Gary Gregory, Bruno P. Kinoshita">
+        Update FileWriterWithEncoding to extend ProxyWriter #296.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Gary Gregory">
+        Initialize the message of an IOExceptionList to a default if null.
+      </action>
+      <action issue="IO-751" dev="ggregory" type="fix" due-to="Gary Gregory, Richard Cyganiak">
+        When deleting symlinks, File/PathUtils.deleteDirectory() changes file permissions of the target.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Gary Gregory">
+        ReaderInputStream maps null Charset, Charset name, and CharsetEncoder to the platform default instead of throwing a NullPointerException.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Gary Gregory">
+        CharSequenceInputStream maps null Charset and Charset name to the platform default instead of throwing a NullPointerException.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Gary Gregory">
+        WriterOutputStream maps null Charset, Charset name, and CharsetEncoder name to the platform default instead of throwing a NullPointerException.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Chad Wilson, Gary Gregory">
+        FileUtils.copyURLToFile should create target parent directories and overwrite target file #319.
+      </action>
+      <action issue="IO-484" dev="ggregory" type="fix" due-to="Marcono1234, Gary Gregory">
+        Fix incorrect FilenameUtils Javadoc for null bytes #310.
+      </action>
+      <action issue="IO-484" dev="ggregory" type="fix" due-to="Arturo Bernal">
+        Change to uppercase variable constant. #323.
+      </action>
+      <action issue="IO-484" dev="ggregory" type="fix" due-to="David Huang, Gary Gregory">
+        IOCase.isCaseSensitive(IOCase) result is backward #325.
+      </action>
+      <action issue="IO-758" dev="ggregory" type="fix" due-to="Marcono1234, Gary Gregory">
+        Deprecate PathUtils.NOFOLLOW_LINK_OPTION_ARRAY in favor of noFollowLinkOptionArray().
+      </action>
+      <action dev="ggregory" type="fix" due-to="Marcono1234, Gary Gregory">
+        Improve ReaderInputStream documentation #291.
+      </action>
+      <action dev="ggregory" type="fix" due-to="richarda23">
+        Fix misleading comments in FileFilterTest #334.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Diego Marcilio">
+        Add missing javadoc for exceptions thrown for invalid arguments #339.
+      </action>
+      <action dev="ggregory" type="fix" due-to="richarda23">
+        FileFilterTest minor fixes #340.
+      </action>
+      <action issue="IO-764" dev="ggregory" type="fix" due-to="DaGeRe, Gary Gregory">
+        IOUtils.write() throws OutOfMemoryError/NegativeArraySizeException while writing big strings #343.
+      </action>
+      <action issue="IO-768" dev="ggregory" type="fix" due-to="Marcono1234, Michael Osipov">
+        Add reserved Windows file names CONIN$ and CONOUT$ to FileSystem #355.
+      </action>
+      <action issue="IO-773" dev="ggregory" type="fix" due-to="Dominik Reinarz, Gary Gregory">
+        RegexFileFilter is no longer Serializable.
+      </action>
+      <action issue="IO-763" dev="ggregory" type="fix" due-to="Richard Adams, Gary Gregory">
+        [Javadoc] FileFilterUtils doc does not match impl: missing some file filters.
+      </action>
+      <action issue="IO-762" dev="ggregory" type="fix" due-to="Leonidas Chiron, Gary Gregory">
+        FileSystem.WINDOWS.isReservedFileName doesn't check for file extension.
+      </action>
+      <action issue="IO-772" dev="ggregory" type="fix" due-to="Dan Ziemba, Gary Gregory">
+        Confusing Javadoc on IOUtils#resourceToURL() and other resource* methods.
+      </action>
+      <action issue="IO-443" dev="ggregory" type="fix" due-to="Dan Ziemba, Gary Gregory">
+        FileUtils.copyFile methods throw an unnecessary "Failed to copy full contents from" exception.
+      </action>
+      <action issue="IO-564" dev="ggregory" type="fix" due-to="Hao Zhong, Bernd Eckenfels, Pascal Schumacher, Gary Gregory">
+        Pick up Javadoc from super for override write() methods in AbstractByteArrayOutputStream and ByteArrayOutputStream.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Marc Wrobel">
+        Fix minor typos #367.
+      </action>
+      <action issue="IO-776" dev="kinow" type="fix" due-to="Chris Povirk">
+        Fix parameters to requireNonNull call in DeferredOutputSteam #368.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Gary Gregory">
+        Fix PathUtils.copyFileToDirectory(URL,Path,CopyOption[]).
+      </action>
+      <action issue="IO-386" dev="ggregory" type="fix" due-to="Sebb, Bernd Eckenfels, zhipengxu, Gary Gregory">
+        FileUtils.doCopyFile uses different methods to check the file sizes.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Michael Ernst">
+        Fix typos #375.
+      </action>
+      <action issue="IO-611" dev="ggregory" type="fix" due-to="Fedor Urvanov">
+        FilenameUtils.normalize javadoc and tests #383.
+      </action>
+      <action issue="IO-611" dev="ggregory" type="fix" due-to="ArdenL-Liu, Bruno P. Kinoshita, Gary Gregory">
+        Better docs in IOUtils and IOUtils.byteArray(int size) #374.
+      </action>
+      <action issue="IO-782" dev="ggregory" type="fix" due-to="Matteo Di Giovinazzo, Gary Gregory">
+        SequenceReader should close readers when its close method is called #391.
+      </action>
+      <!-- ADD -->
+      <action type="add" dev="ggregory" due-to="Gary Gregory">
+        Add GitHub coverage.yml.
+      </action>
+      <action issue="IO-726" dev="ggregory" type="fix" due-to="shollander, Gary Gregory">
+        Add MemoryMappedFileInputStream #215.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add BrokenReader.INSTANCE.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add UncheckedBufferedReader.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add UncheckedFilterReader.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add UncheckedFilterWriter.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add StringInputStream.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add UncheckedFilterInputStream.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add UncheckedFilterOutputStream.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add BrokenInputStream.INSTANCE.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add ClosedInputStream.INSTANCE and deprecate CLOSED_INPUT_STREAM.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add ClosedReader.INSTANCE and deprecate CLOSED_READER.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add BrokenWriter.INSTANCE.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add ClosedOutputStream.INSTANCE and deprecate CLOSED_OUTPUT_STREAM.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add ClosedWriter.INSTANCE and deprecate CLOSED_WRITER.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add NullOutputStream.INSTANCE and deprecate NULL_OUTPUT_STREAM.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add NullPrintStream.INSTANCE and deprecate NULL_PRINT_STREAM.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add NullWriter.INSTANCE and deprecate NULL_WRITER.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add NullInputStream.INSTANCE.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add NullReader.INSTANCE.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add PathUtils.readString(Path, Charset).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add FileUtils.newOutputStream(File, boolean).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add PathUtils.newOutputStream(Path, boolean).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add UncheckedAppendable.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add and reuse UncheckedIOExceptions.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add PathUtils.getTempDirectory().
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add FileSystem.getNameSeparator().
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add FileSystem.normalizeSeparators().
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add PathUtils.isNewer(Path, FileTime, LinkOption...).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add PathUtils.isNewer(Path, Instant, LinkOption...).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add AgeFileFilter.AgeFileFilter(Instant).
+        Add AgeFileFilter.AgeFileFilter(Instant, boolean).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add FileUtils.lastModifiedFileTime(File).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add FileTimes.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add PathUtils.waitFor(Path, Duration, LinkOption...).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add org.apache.commons.io.input.Tailer.getDelayDuration().
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add FileUtils.current().
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add and use PathUtils.setLastModifiedTime(Path) for more precision.
+        Add and use PathUtils.setLastModifiedTime(Path, Path) for more precision.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add and use PathUtils.isNewer(Path, ChronoZonedDateTime, LinkOption...) for more precision.
+        Add and use PathUtils.isNewer(Path, Path) for more precision.
+        Add and use FileUtils.isNewer(File, FileTime) for more precision.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add and use PathUtils.isOlder(Path, FileTime, LinkOption...).
+        Add and use PathUtils.isOlder(Path, Instant, LinkOption...).
+        Add and use PathUtils.isOlder(Path, long, LinkOption...).
+        Add and use PathUtils.isOlder(Path, Path).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add and use PathUtils.sizeOf(Path).
+        Add and use PathUtils.sizeOfAsBigInteger(Path).
+        Add and use PathUtils.sizeOfDirectory(Path).
+        Add and use PathUtils.sizeOfDirectoryAsBigInteger(Path).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add and use IOCase.value(IOCase, IOCase).
+      </action>
+      <action dev="jonfreedman" type="add" due-to="Jon Freedman, Gary Gregory">
+        Add Tailer.Tailable interface to allow tailing of remote files for example using jCIFS.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add and use RandomAccessFileMode.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add PathUtils.writeString(Path, CharSequence, Charset, OpenOption...).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add PathUtiFiles.getFileAttributeView() shorthands:
+          - PathUtils.getAclFileAttributeView(Path, LinkOption...)
+          - PathUtils.getDosFileAttributeView(Path, LinkOption...)
+          - PathUtils.getPosixFileAttributeView(Path, LinkOption...)
+      </action>
+      <action issue="IO-747" dev="mgrigorov" type="add">
+        Make commons-io a JPMS module by adding module-info.class.
+      </action>
+      <action issue="IO-753" dev="ggregory" type="add" due-to="SebastianDietrich, Gary Gregory">
+        Add IOUtils method to copy output stream to input stream #281.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add PathUtils.isPosix(Path, LinkOption...). #290
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add PathUtils.readAttributes(Path, Class, LinkOption...). #290
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add IOExceptionList.checkEmpty(List, Object).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add IOBiConsumer, IOTriConsumer, IOComparator, IOUnaryOperator, IOBinaryOperator.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add and reuse IOConsumer forAll(*), forEach(*), and forEachIndexed(*).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add CharsetEncoders.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add CharsetDecoders.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add PathUtils.createParentDirectories(Path, LinkOption, FileAttribute...).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Update FileEntry to use FileTime instead of long for file time stamps.
+      </action>
+      <action issue="IO-680" dev="ggregory" type="add" due-to="XenoAmess, sebbASF, Gary Gregory">
+        Add more tests for IOUtils.contentEqualsIgnoreEOL #137.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Reduce boilerplate through new UncheckedIO class and friends in org.apache.commons.io.function.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add PathUtils.touch(Path).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add FileSystem.getIllegalFileNameCodePoints().
+      </action>
+      <action dev="ggregory" type="add" due-to="Isira Seneviratne, Gary Gregory">
+        Add FileUtils.isFileNewer(File, ChronoLocalDate, OffsetTime).
+        Add FileUtils.isFileNewer(File, OffsetDateTime).
+        Add FileUtils.isFileOlder(File, ChronoLocalDate, OffsetTime).
+        Add FileUtils.isFileOlder(File, OffsetDateTime).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add IOBiConsumer.noop().
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add IOUtils.closeQuietly(Iterable&lt;Closeable&gt;).
+        Add IOUtils.closeQuietly(Stream&lt;Closeable&gt;).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add Charsets.toCharset(Charset, Charset).
+        Add Charsets.toCharset(String, Charset).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add XmlStreamWriter(OutputStream, Charset).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add PathUtils.getLastModifiedFileTime(*).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add IOBiFunction, IOTriFunction, IOQuadFunction, IOPredicate, IOIterator, IOSpliterator, IOBaseStream, IOStream, FilesUncheck.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add IOUtils.consume(Reader).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add IOSupplier.asSupplier().
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add IOFunction.asFunction().
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add IOConsumer.asConsumer().
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add TimestampedObserver.isClosed().
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        IOExceptionList implements Iterable.
+      </action>
+      <action issue="IO-784" dev="ggregory" type="add" due-to="Fredrik Kjellberg, Gary Gregory">
+        Add support for Appendable to HexDump #418.
+      </action>
+      <action dev="ggregory" type="add" due-to="DaGeRe, Gary Gregory">
+        Add and use ThreadUtils.
+      </action>
+      <action issue="IO-786" dev="ggregory" type="add" due-to="Gary Gregory">
+        Add UnsynchronizedFilterInputStream.
+      </action>
+      <action issue="IO-786" dev="ggregory" type="add" due-to="Gary Gregory, Benoit Tellier">
+        Add UnsynchronizedBufferedInputStream.
+      </action>
+      <!-- UPDATE -->
+      <action dev="kinow" type="update" due-to="Dependabot, Gary Gregory">
+        Bump actions/cache from 2.1.6 to 3.0.10 #307, #337, #393.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot, Gary Gregory">
+        Bump actions/checkout from 2.3.4 to 3.1.0 #286, #298, #330, #392.
+      </action>
+      <action dev="kinow" type="update" due-to="Dependabot">
+        Bump actions/setup-java from 2 to 3.6.0 #346, #397.
+      </action>
+      <action dev="kinow" type="update" due-to="Dependabot">
+        Bump github/codeql-action from 1 to 2 #353.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot, Gary Gregory">
+        Bump Maven Javadoc plugin from 3.2.0 to 3.4.1.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump spotbugs-maven-plugin from 4.2.3 to 4.7.3.0 #250, #259, #272, #274, #285, #288, #289, #305, #315, #326, #338, #360, #366, #370, #380, #395, #403.
+      </action>
+      <action dev="kinow" type="update" due-to="Gary Gregory, Dependabot">
+        Bump spotbugs from 4.5.2 to 4.7.3 #313, #317, #357, #382, #398.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Bump JUnit from 5.7.2 to 5.8.2.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump maven-enforcer-plugin from 3.0.0-M3 to 3.2.1 #255, #363, #431.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot, Gary Gregory">
+        Bump checkstyle from 8.44 to 9.3 #256, #257, #266, #279, #292. #308.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump junit-bom from 5.8.0-M1 to 5.9.1 #260, #271, #275, #309, #386.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot, Gary Gregory">
+        Bump mockito-inline from 3.11.2 to 4.11.0 #262, #264, #282, #306, #314, #331, #348, #359, #381, #399, #405, #414, #420.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump jmh.version from 1.32 to 1.36 #258, #316, #342, #404.
+      </action>
+      <action dev="kinow" type="update" due-to="Dependabot">
+        Bump moditect-maven-plugin from 1.0.0.RC1 to 1.0.0.RC2 #280.
+      </action>
+      <action dev="kinow" type="update" due-to="Dependabot, Gary Gregory">
+        Bump junit-pioneer from 1.4.2 to 1.9.1 #304. #335, #362, #402, #406, #409.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Bump japicmp-maven-plugin from 0.15.3 to 0.16.0.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory, Dependabot">
+        Bump commons-parent from 52 to 56 #388, #415, #421.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Bump jacoco-maven-plugin from 0.8.7 to 0.8.8.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump maven-antrun-plugin from 3.0.0 to 3.1.0 #354.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Bump commons.surefire.version 3.0.0-M5 to 3.0.0-M7.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Bump PMD from 6.44.0 to 6.52.0.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Bump maven-pmd-plugin from 3.16.0 to 3.19.0.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Bump apache-rat from 0.13 to 0.14.
+      </action>
+      <action dev="kinow" type="update" due-to="Dependabot">
+        Bump exec-maven-plugin from 3.0.0 to 3.1.0 #369.
+      </action>
+      <action dev="kinow" type="update" due-to="Dependabot">
+        Bump maven-checkstyle-plugin from 3.1.2 to 3.2.0 #376.
+      </action>
+      <action dev="kinow" type="update" due-to="Dependabot">
+        Bump apache-rat-plugin from 0.14 to 0.15 #387.
+      </action>
+    </release>
+    <release version="2.11.0" date="2021-07-09" description="Java 8 required.">
+      <!-- FIX -->
+      <action issue="IO-741" dev="ggregory" type="fix" due-to="Zach Sherman">
+        FileUtils.listFiles does not list matching files if File parameter is a symbolic link.
+      </action>
+      <action issue="IO-724" dev="ggregory" type="fix" due-to="liran2000">
+        FileUtils#deleteDirectory(File) exception Javadoc inaccurate update #245.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Arturo Bernal">
+        Minor changes #243.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Arturo Bernal">
+        Replace construction of FileInputStream and FileOutputStream objects with Files NIO APIs. #221.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Gary Gregory">
+        Fix IndexOutOfBoundsException in IOExceptionList constructors.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Gary Gregory">
+        Remove IOException from the method signatures that no longer throw IOException.
+        This maintains binary compatibility but not source compatibility.
+        - FilenameUtils
+            directoryContains(String, String)
+        - BoundedReader
+            BoundedReader(java.io.Reader, int)
+        - IOUtils
+            lineIterator(java.io.InputStream, Charset)
+            lineIterator(java.io.InputStream, String)
+            toByteArray(String)
+            toInputStream(CharSequence, String)
+            toInputStream(String, String)
+            toString(byte[])
+            toString(byte[], String)
+      </action>
+      <!-- ADD -->
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Add SymbolicLinkFileFilter.
+      </action>
+      <action dev="ggregory" type="update" due-to="trncate">
+        Add test to make sure the setter of AndFileFilter works correctly #244.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Add XmlStreamReader(Path).
+      </action>
+      <!-- UPDATE -->
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump mockito-inline from 3.11.0 to 3.11.2 #247.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump jmh.version from 1.27 to 1.32 #237.
+      </action>
+    </release>
+    <!-- The release date is the date RC is cut -->
+    <release version="2.10.0" date="2021-06-10" description="Java 8 required.">
+      <!-- FIX -->
+      <action issue="IO-733" dev="ggregory" type="fix" due-to="Jim Sellers, Gary Gregory">
+        RegexFileFilter uses the path and file name instead of just the file name.
+      </action>
+      <action issue="IO-734" dev="ggregory" type="fix" due-to="Eric Norman">
+        The OSGi manifest now contains sun.* import packages #239.
+      </action>
+      <action issue="IO-585" dev="ggregory" type="fix" due-to="Adam McClenaghan">
+        Sanitize double slash after prefix #79.
+      </action>
+      <!-- ADD -->
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add and use RegexFileFilter.toString().
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add and use RegexFileFilter.RegexFileFilter(Pattern, Function&lt;Path&gt;, String>)
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add and use IOCase.isCaseSensitive(IOCase).
+      </action>
+      <!-- UPDATES -->
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump actions/cache from 2.1.5 to 2.1.6 #238.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump junit-pioneer from 1.4.1 to 1.4.2 #240.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump checkstyle from 8.42 to 8.44 #241, #248.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump mockito-inline from 3.10.0 to 3.11.0 #242.
+      </action>
+    </release>
+    <release version="2.9.0" date="2021-05-22" description="Java 8 required.">
+      <!-- FIX -->
+      <action issue="IO-686" dev="ggregory" type="fix" due-to="Alan Moffat, Gary Gregory">
+        IOUtils.toByteArray(InputStream) Javadoc does not match code.
+      </action>
+      <action issue="IO-689" dev="aherbert" type="fix" due-to="Uwe Schindler">
+        FileUtils: Remove Instant->ZonedDateTime->Instant round-trip.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Michael Ernst, Gary Gregory">
+        Make FilenameUtils.equals() not throw an exception #154.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Jan Peter Stotz, Bernd Eckenfels, Gary Gregory">
+        Un-deprecate IOUtils.closeQuietly() methods.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Michiel Kalkman">
+        FileUtils#copyDirectory(File, File, FileFilter, preserveFileDate) clean up #163.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Gary Gregory">
+        AccumulatorPathVisitor does not track directories properly.
+      </action>
+      <action issue="IO-597" dev="ggregory" type="fix" due-to="Gary Gregory, Arvind, Rob Spoor">
+        FileUtils.iterateFiles runs out of memory when executed for a directory with large number of files.
+        Re-implement FileUtils' iterateFiles(), iterateFilesAndDirs(), listFiles(), listFilesAndDirs() to use NIO
+        file tree walking instead of IO file listings to avoid memory consumption issues on large file trees.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Gary Gregory">
+        FileUtils.forceDelete(File) actually forces deletion of read-only files as it did in version 2.6.
+      </action>
+      <action issue="IO-692" dev="ebourg" type="fix" due-to="Matthew Rooney, Emmanuel Bourg">
+        PathUtils.deleteFile() no longer throws a NoSuchFileException when applied on a symbolic link pointing
+        to a file that doesn't exist.
+      </action>
+      <action issue="IO-694" dev="ggregory" type="fix" due-to="Tan Yee Fan, Gary Gregory">
+        Behavior change in FileUtils.copyDirectory() file last modified timestamp preservation. Match Javadoc to code.
+      </action>
+      <action issue="IO-600" dev="ggregory" type="fix" due-to="Abhyankar Chaubey, Gary Gregory">
+        Fix getPrefixLength method for Linux filename #179.
+      </action>
+      <action issue="IO-699" dev="ggregory" type="fix" due-to="tza, Gary Gregory">
+        Wrong logging in FileUtils.setLastModified.
+      </action>
+      <action issue="IO-686" dev="ggregory" type="fix" due-to="Alan Moffat, Sebb, Gary Gregory">
+        IOUtils.toByteArray(InputStream) Javadoc does not match code.
+      </action>
+      <action issue="IO-688" dev="ggregory" type="fix" due-to="Michael Ernst, Gary Gregory">
+        CopyUtils deprecation message gives wrong version.
+      </action>
+      <action issue="IO-701" dev="ggregory" type="fix" due-to="Gary Gregory">
+        Make PathUtils.setReadOnly deal with LinuxDosFileAttributeView #186.
+      </action>
+      <action issue="IO-702" dev="ggregory" type="fix" due-to="Boris Unckel, Gary Gregory">
+        FileUtils.forceDelete does not delete invalid links. #187.
+      </action>
+      <action issue="IO-690" dev="ggregory" type="fix" due-to="Chris Heisterkamp, Gary Gregory">
+        IOUtils.toByteArray(null) no longer throws a NullPointerException.
+      </action>
+      <action issue="IO-705" dev="ggregory" type="fix" due-to="Hao Zhong, Gary Gregory">
+        MarkShieldInputStream#reset should throw UnsupportedOperationException.
+      </action>
+      <action issue="IO-705" dev="ggregory" type="fix" due-to="Hao Zhong, Gary Gregory">
+        LockableFileWriter.close() should fail when the lock file cannot be deleted.
+      </action>
+      <action issue="IO-705" dev="ggregory" type="fix" due-to="Hao Zhong, Gary Gregory">
+        Fix infinite loops in ObservableInputStream read(*) when an exception is caught but not re-thrown.
+      </action>
+      <action issue="IO-719" dev="ggregory" type="fix" due-to="Andrew Shcheglov, Gary Gregory">
+        Fixed error of copying directories between different file systems #203.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Felix Rilling">
+        Fix Typos in JavaDoc, Comments and Tests #201.
+      </action>
+      <action issue="IO-718" dev="ggregory" type="fix" due-to="Robert Cooper, Gary Gregory">
+        FileUtils.checksumCRC32 and FileUtils.checksum are not thread safe.
+      </action>
+      <action issue="IO-720" dev="ggregory" type="fix" due-to="XenoAmess">
+        Fix error about usage of DirectBuffer in JRE 16/17 #205.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Rob Spoor, Gary Gregory">
+        Prevent infinite loop with AbstractCharacterFilterReader if EOF is filtered out #226.
+      </action>
+      <action issue="IO-429" dev="ggregory" type="fix" due-to="Ivan Leskin, Ivan Leskin">
+        Check for long streams in IOUtils.toByteArray #175.
+      </action>
+      <!-- ADD -->
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add FileSystemProviders class.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Let org.apache.commons.io.filefilter classes work with java.nio.file.Files.walk* APIs.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Let org.apache.commons.io.filefilter classes work with java.nio.file.Files#newDirectoryStream(Path, DirectoryStream.Filter).
+      </action>
+      <action issue="IO-510" dev="ggregory" type="add" due-to="Gary Gregory, Apache Spark, David Mollitor">
+        Add and adapt ReadAheadInputStream and BufferedFileChannelInputStream from Apache Spark.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add PathUtils.createParentDirectories(Path, FileAttribute...).
+      </action>
+      <action dev="ggregory" type="add" due-to="Rob Spoor, Gary Gregory">
+        Add factory methods to CloseShieldInputStream, CloseShieldReader, CloseShieldOutputStream, CloseShieldWriter, #173.
+      </action>
+      <action dev="ggregory" type="add" due-to="maxxedev, Gary Gregory">
+        Add QueueInputStream and QueueOutputStream as simpler alternatives to PipedInputStream and PipedOutputStream #171.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add StandardLineSeparator.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Replace magic numbers with constants with the new IOUtils.CR and LF.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add FileSystem#supportsDriveLetter().
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add FileUtils.delete(File).
+      </action>
+      <action issue="IO-700" dev="ggregory" type="add" due-to="Gary Gregory">
+        Add FileUtils.moveFile(File, File, CopyOption...) #185.
+      </action>
+      <action issue="IO-700" dev="ggregory" type="add" due-to="Gary Gregory">
+        Add FileUtils.isEmptyDirectory(File).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add FileUtils.lastModified[Unchecked](File) to workaround https://bugs.openjdk.java.net/browse/JDK-8177809.
+      </action>
+      <action issue="IO-709" dev="ggregory" type="add" due-to="Boris Unckel, Gary Gregory">
+        Add null safe variants of isDirectory and isRegularFile.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add and use IOExceptionList(String, List).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add and use ObservableInputStream.ObservableInputStream(InputStream, Observer...).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Make ObservableInputStream.getObservers() public.
+      </action>
+      <action  issue="IO-706" dev="ggregory" type="add" due-to="Gary Gregory">
+        Add TimestampedObserver.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add and use IOUtils.byteArray(*).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Make public and reuse IOUtils.EMPTY_BYTE_ARRAY.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add IOUtils.copy(URL, File).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add copy(URL, OutputStream).
+      </action>
+      <action issue="IO-651" dev="ggregory" type="add" due-to="jmark109, Gary Gregory">
+        Add DeferredFileOutputStream.toInputStream() #206.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add CharacterSetFilterReader.CharacterSetFilterReader(Reader, Integer...).
+      </action>
+      <action dev="ggregory" type="add" due-to="Rob Spoor, Gary Gregory">
+        Add AbstractCharacterFilterReader(Reader, IntPredicate), #227.
+        Add CharacterFilterReader(Reader, IntPredicate), #227.
+        Add CharacterFilterReaderIntPredicateTest, #227.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add IOConsumer.noop().
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add constructor ThresholdingOutputStream(int, IOConsumer, IOFunction) and make the class concrete.
+      </action>
+      <action dev="ggregory" type="add" due-to="nstdspace, Gary Gregory">
+        Add constructor accepting collection of file alteration observers #236.
+      </action>
+      <!-- UPDATES -->
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Update junit-jupiter from 5.6.2 to 5.7.0 #153.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Update mockito-core from 3.5.9 to 3.10.0, #152, #155, #157, #166, #167, #169, #182.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump mockito-inline from 3.7.0 to 3.10.0 #188, #207, #230.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Update commons.jacoco.version 0.8.5 to 0.8.7, fixes Java 15 builds and up.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Update spotbugs from 4.1.2 to 4.5.0, #158, #164, #165, #180, #199, #213, #224, #302.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump spotbugs-maven-plugin from 4.0.4 to 4.2.3, #161, #172, #223.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory, Dependabot">
+        Update org.junit-pioneer:junit-pioneer 0.9.0 -> 1.4,1, #159, #162, #170, #189, #191, #210, #229.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Update actions/checkout from v2.3.2 to v2.3.4, #156, #168.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot, Gary Gregory">
+        Bump actions/setup-java from v1.4.2 to v2 #160.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Update maven-surefire-plugin from 2.22.2 to 3.0.0-M5.
+      </action>
+      <action dev="ggregory" type="update" due-to="Arturo Bernal">
+        Minor improvements, #176, 177, #190.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Update commons.japicmp.version 0.14.4 -> 0.15.3.
+      </action>
+      <action dev="ggregory" type="update" due-to="Michiel Kalkman">
+        Tiny performance improvement in FileUtils#moveDirectoryToDirectory() #174.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump checkstyle from 8.38 to 8.42 #689, #209, #225.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump maven-checkstyle-plugin from 3.1.1 to 3.1.2 #198.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump jimfs from 1.1 to 1.2 #183.
+      </action>
+      <action dev="ggregory" type="update" due-to="XenoAmess, Gary Gregory">
+        Improve performance of IOUtils.contentEquals(InputStream, InputStream).
+      </action>
+      <action dev="ggregory" type="update" due-to="XenoAmess, Gary Gregory">
+        Improve performance of IOUtils.contentEquals(Reader, Reader).
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump actions/cache from v2 to v2.1.5 #202, #228.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Bump junit-bom from 5.7.0 to 5.7.2 #200, #232.
+      </action>
+      <action type="update" dev="ggregory" due-to="Gary Gregory">
+        Update from Apache Commons Lang 3.11 to 3.12.0.
+      </action>
+      <action type="update" dev="ggregory" due-to="Arturo Bernal">
+        Minor improvements #233.
+      </action>
+      <action type="update" dev="ggregory" due-to="Arturo Bernal">
+        Simplify Assertions in tests #234.
+      </action>
+    </release>
+    <!-- The release date is the date RC is cut -->
+    <release version="2.8.0" date="2020-09-05" description="Java 8 required.">
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add org.apache.commons.io.input.CircularInputStream.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add org.apache.commons.io.file.PathUtils.cleanDirectory(Path, FileVisitOption...).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add org.apache.commons.io.file.PathUtils.deleteDirectory(Path, FileVisitOption...).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add NullAppendable.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Rob Spoor, Jochen Wiedmann">
+        CharSequenceReader.skip should return 0 instead of EOF on stream end #123.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Rob Spoor">
+        Implement CharSequenceReader.ready() #122.
+      </action>
+      <action issue="IO-669" dev="ggregory" type="fix" due-to="XenoAmess, Gary Gregory">
+        Fix code smells; fix typos #115.
+      </action>
+      <action dev="ggregory" type="fix" due-to="Jerome Wolff, Gary Gregory">
+        Add caching for required charsets #120.
+      </action>
+      <action issue="IO-673" type="fix" dev="ggregory" due-to="Jerome Wolff">
+        Make some simplifications #121.
+      </action>
+      <action issue="IO-674" dev="ggregory" type="fix" due-to="Gary Gregory">
+        InfiniteCircularInputStream is not infinite if its input buffer contains -1.
+      </action>
+      <action issue="IO-675" dev="ggregory" type="fix" due-to="Gary Gregory">
+        InfiniteCircularInputStream throws a divide-by-zero exception when reading if its input buffer is size 0.
+      </action>
+      <action issue="IO-677" dev="ggregory" type="fix" due-to="Gary Gregory">
+        FileSystem.getCurrent() does not return the correct enum.
+      </action>
+      <action issue="IO-679" dev="ggregory" type="fix" due-to="proneel">
+        input.AbstractCharacterFilterReader passes count of chars read #132.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add PathUtils.getAclEntryList(Path).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Null-guard IOUtils.close(Closeable, IOConsumer).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add ReversedLinesFileReader.readLines(int).
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add ReversedLinesFileReader.toString(int).
+      </action>
+      <action issue="IO-684" dev="ggregory" type="add" due-to="Gary Gregory, Robin Jansohn">
+        Add PathUtils.delete(Path, DeleteOption...).
+        Add PathUtils.deleteDirectory(Path, DeleteOption...).
+        Add PathUtils.deleteFile(Path, DeleteOption...).
+        Add PathUtils.setReadOnly(Path, boolean, LinkOption...).
+        Add CleaningPathVisitor.CleaningPathVisitor(PathCounters, DeleteOption[], String...).
+        Add DeletingPathVisitor.DeletingPathVisitor(PathCounters, DeleteOption[], String...).
+      </action>
+      <action issue="IO-683" dev="sebb" type="fix">
+        CircularBufferInputStream.read() fails to convert byte to unsigned int
+      </action>
+      <action dev="ggregory" type="fix" due-to="Gary Gregory">
+        Fix SpotBugs issues in org.apache.commons.io.FileUtils.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add RandomAccessFileInputStream.
+      </action>
+      <action issue="IO-681" dev="sebb" type="add">
+        IOUtils.close(Closeable) should allow a list of closeables.
+      </action>
+      <action issue="IO-672" dev="sebb" type="fix">
+        Copying a File sets last modified date to 01 January 1970.
+      </action>
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Add IOUtils.consume(InputStream).
+      </action>
+      <action issue="IO-676" dev="ggregory" type="add" due-to="Isira Seneviratne, Gary Gregory">
+        Add isFileNewer() and isFileOlder() methods that support the Java 8 Date/Time API. #124.
+      </action>
+      <action issue="IO-676" dev="ggregory" type="fix" due-to="Michael Ernst, Gary Gregory">
+        Prevent NullPointerException in ReversedLinesFileReader constructors #117.
+      </action>
+      <action dev="ggregory" type="add" due-to="Adam Retter, Gary Gregory">
+        Add a MarkShieldInputStream #119.
+      </action>
+      <!-- UPDATES -->
+      <action dev="ggregory" type="add" due-to="Gary Gregory">
+        Deprecate IOUtils.LINE_SEPARATOR in favor of Java 7's System.lineSeparator().
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Replace FindBugs with SpotBugs.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        maven-checkstyle-plugin 3.1.0 -> 3.1.1.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Update tests from org.apache.commons:commons-lang3 3.10 to 3.11.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Update commons-parent from 50 to 51 #129.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Update actions/checkout from v1 to v2.3.1 #126.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Update junit-pioneer from 0.6.0 to 0.9.0, #127, #135, #138.
+      </action>
+      <action dev="ggregory" type="update" due-to="Gary Gregory">
+        Update mockito-core from 3.3.3 to 3.5.9 #128, #133, #145, #149, #151.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Update spotbugs from 4.0.6 to 4.6.0 #134, #332.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Update actions/checkout from v2.3.1 to v2.3.2 #140.
+      </action>
+      <action dev="ggregory" type="update" due-to="Dependabot">
+        Update actions/setup-java from v1.4.0 to v1.4.2 #141, #148.
+      </action>
+      <action type="update" dev="ggregory" due-to="Gary Gregory">
+        Update com.github.siom79.japicmp:japicmp-maven-plugin 0.14.3 -> 0.14.4.
+      </action>
+    </release>
+    <!-- The release date is the date RC is cut -->
+    <release version="2.7" date="2020-05-24" description="Java 8 required.">
+      <action issue="IO-589" dev="sebb" type="fix">
+        Some tests fail if the base path contains a space.
+      </action>
+      <action dev="jochen" type="add">
+        Adding the CircularBufferInputStream, and the PeekableInputStream.
+      </action>
+      <action issue="IO-582" dev="jochen" type="fix" due-to="Bruno Palos">
+        Make methods in ObservableInputStream.Observer public.
+      </action>
+      <action issue="IO-535" dev="pschumacher" type="fix" due-to="Svetlin Zarev, Anthony Raymond">
+        Thread bug in FileAlterationMonitor#stop(int).
+      </action>
+      <action issue="IO-553" dev="ggregory" type="add">
+        Add org.apache.commons.io.FilenameUtils.isIllegalWindowsFileName(char).
+      </action>
+      <action issue="IO-557" dev="pschumacher" type="fix" due-to="luccioman">
+        Perform locale independent upper case conversions.
+      </action>
+      <action issue="IO-570" dev="ggregory" type="fix" due-to="Pranet Verma">
+        Missing Javadoc in FilenameUtils causing Travis-CI build to fail.
+      </action>
+      <action issue="IO-571" dev="ggregory" type="fix" due-to="pranet">
+        Remove redundant isDirectory() check in org.apache.commons.io.FileUtils.listFilesAndDirs(File, IOFileFilter, IOFileFilter).
+      </action>
+      <action issue="IO-572" dev="ggregory" type="update" due-to="Pranet Verma">
+        Refactor duplicate code in org.apache.commons.io.FileUtils.
+      </action>
+      <action issue="IO-577" dev="ggregory" type="add" due-to="Gary Gregory">
+        Add readers to filter out given characters: CharacterSetFilterReader and CharacterFilterReader.
+      </action>
+      <action issue="IO-559" type="fix">
+        FilenameUtils.normalize now verifies hostname syntax in UNC path.
+      </action>
+      <action issue="IO-580" dev="ggregory" type="update">
+        Update org.apache.commons.io.FilenameUtils.isExtension(String, String[]) to use var args.
+      </action>
+      <action issue="IO-554" dev="ggregory" type="fix" due-to="Michele Mariotti">
+        FileUtils.copyToFile(InputStream source, File destination) should not close input stream.
+      </action>
+      <action issue="IO-594" dev="ggregory" type="add" due-to="Gary Gregory">
+        Add IOUtils copy methods with java.lang.Appendable as the target.
+      </action>
+      <action issue="IO-604" dev="ggregory" type="fix" due-to="Gary Gregory">
+        FileUtils.doCopyFile(File, File, boolean) can throw ClosedByInterruptException.
+      </action>
+      <action issue="IO-605" dev="ggregory" type="add" due-to="Gary Gregory">
+        Add class CanExecuteFileFilter.
+      </action>
+      <action issue="IO-701" dev="ggregory" type="update" due-to="Raymond Tan">
+        Make array declaration in ThresholdingOutputStream consistent with other array declarations in the library #77.
+      </action>
+      <action issue="IO-578" dev="ggregory" type="add" due-to="Mark Chesney">
+        Support java.nio.Path and non-default file systems for ReversedLinesFileReader (#62).
+      </action>
+      <action issue="IO-608" dev="ggregory" type="add" due-to="Gary Gregory">
+        Add a convenience NullPrintStream.
+      </action>
+      <action issue="IO-607" dev="ggregory" type="update" due-to="Gary Gregory">
+        Update from Java 7 to Java 8.
+      </action>
+      <action issue="IO-610" dev="ggregory" type="update" due-to="Sebastian">
+        Remove throws IOException in method isSymlink() #80.
+      </action>
+      <action issue="IO-612" dev="ggregory" type="add" due-to="Rob Spoor, Gary Gregory">
+        Add class TeeReader.
+      </action>
+      <action issue="IO-613" dev="ggregory" type="add" due-to="Rob Spoor, Gary Gregory">
+        Add classes ClosedReader and CloseShieldReader. #84.
+      </action>
+      <action issue="IO-614" dev="ggregory" type="add" due-to="Rob Spoor">
+        Add classes TaggedWriter, ClosedWriter and BrokenWriter. #86.
+      </action>
+      <action issue="IO-615" dev="ggregory" type="add" due-to="Gary Gregory, Rob Spoor">
+        Add classes TeeWriter, FilterCollectionWriter, ProxyCollectionWriter, IOExceptionList, IOIndexedException.
+      </action>
+      <action issue="IO-616" dev="ggregory" type="add" due-to="Rob Spoor">
+        Add class AppendableWriter. #87.
+      </action>
+      <action issue="IO-617" dev="ggregory" type="add" due-to="Rob Spoor, Gary Gregory">
+        Add class CloseShieldWriter. #83.
+      </action>
+      <action issue="IO-618" dev="ggregory" type="add" due-to="Rob Spoor">
+        Add classes Added TaggedReader, ClosedReader and BrokenReader. #85.
+      </action>
+      <action issue="IO-619" dev="ggregory" type="add" due-to="Rob Spoor">
+        Support sub sequences in CharSequenceReader. #91.
+      </action>
+      <action issue="IO-625" dev="ggregory" type="fix" due-to="Mikko Maunu">
+        Corrected misleading exception message for FileUtils.copyDirectoryToDirectory.
+      </action>
+      <action issue="IO-626" dev="ggregory" type="fix" due-to="Yuji Konishi">
+        A mistake in the FilenameUtils.concat()'s Javadoc about an absolute path.
+      </action>
+      <action issue="IO-628" dev="ggregory" type="update" due-to="Allon Mureinik">
+        Migration to JUnit Jupiter #97.
+      </action>
+      <action issue="IO-630" dev="ggregory" type="update" due-to="Gary Gregory">
+        Deprecate org.apache.commons.io.output.NullOutputStream.NullOutputStream() in favor of org.apache.commons.io.output.NullOutputStream.NULL_OUTPUT_STREAM.
+      </action>
+      <action issue="IO-631" dev="ggregory" type="add" due-to="Gary Gregory">
+        Add a CountingFileVisitor (as the basis for a forthcoming DeletingFileVisitor).
+      </action>
+      <action issue="IO-632" dev="ggregory" type="add" due-to="Gary Gregory">
+        Add PathUtils for operations on NIO Path.
+      </action>
+      <action issue="IO-633" dev="ggregory" type="add" due-to="Gary Gregory">
+        Add DeletingFileVisitor.
+      </action>
+      <action issue="IO-629" dev="ggregory" type="update" due-to="Ian Springer, Ian Springer, Gary Gregory">
+        FileUtils#forceDelete should use Files#delete rather than File#delete so exception messages includes reason for failure.
+      </action>
+      <action issue="IO-634" dev="ggregory" type="update" due-to="Václav Haisman, Bruno P. Kinoshita, Gary Gregory">
+        Make getCause synchronized and use a Deque instead of a Stack #64.
+      </action>
+      <action issue="IO-635" dev="ggregory" type="add" due-to="Gary Gregory">
+        Add org.apache.commons.io.IOUtils.close(Closeable).
+      </action>
+      <action issue="IO-636" dev="ggregory" type="add" due-to="Gary Gregory">
+        Add and reuse org.apache.commons.io.IOUtils.closeQuitely(Closeable, Consumer&lt;IOException&gt;).
+        Add and reuse org.apache.commons.io.IOUtils.close(Closeable, IOConsumer&lt;IOException&gt;).
+      </action>
+      <action issue="IO-640" dev="ggregory" type="fix" due-to="Gary Gregory">
+        NPE in org.apache.commons.io.IOUtils.contentEquals(InputStream, InputStream) when only one input is null.
+      </action>
+      <action issue="IO-641" dev="ggregory" type="fix" due-to="Gary Gregory">
+        NPE in org.apache.commons.io.IOUtils.contentEquals(Reader, Reader) when only one input is null.
+      </action>
+      <action issue="IO-643" dev="ggregory" type="fix" due-to="Gary Gregory">
+        NPE in org.apache.commons.io.IOUtils.contentEqualsIgnoreEOL(Reader, Reader) when only one input is null.
+      </action>
+      <action issue="IO-644" dev="ggregory" type="fix" due-to="Gary Gregory">
+        NPE in org.apache.commons.io.FileUtils.contentEqualsIgnoreEOL(File, File) when only one input is null.
+      </action>
+      <action issue="IO-645" dev="ggregory" type="add" due-to="Gary Gregory">
+        Add org.apache.commons.io.file.PathUtils.fileContentEquals(Path, Path, OpenOption...).
+      </action>
+      <action issue="IO-458" dev="ggregory" type="add" due-to="Gary Gregory, Joshua Gitlin">
+        Add a SequenceReader similar to java.io.SequenceInputStream.
+      </action>
+      <action issue="IO-648" dev="ggregory" type="add" due-to="Gary Gregory">
+        Implement directory content equality. 100#.
+      </action>
+      <action type="update" dev="ggregory" due-to="Gary Gregory">
+         Update tests from Apache Commons Lang 3.9 to 3.10.
+      </action>
+      <action type="update" dev="ggregory" due-to="Gary Gregory">
+         Update tests org.junit-pioneer:junit-pioneer 0.3.0 -> 0.6.0.
+      </action>
+      <action type="update" dev="ggregory" due-to="Gary Gregory">
+         Update tests org.junit.jupiter:junit-jupiter 5.5.2 -> 5.6.2.
+      </action>
+      <action type="update" dev="ggregory" due-to="Gary Gregory">
+         Update tests org.mockito:mockito-core 3.0.0 -> 3.3.3.
+      </action>
+      <action issue="IO-648" dev="ggregory" type="add" due-to="Adam Retter, Alex Herbert, Gary Gregory">
+         Refactor ByteArrayOutputStream into synchronized and unsynchronized versions #108.
+      </action>
+      <action issue="IO-662" dev="ggregory" type="add" due-to="Adam Retter, Gary Gregory">
+         Refactor ByteArrayOutputStream into synchronized and unsynchronized versions #108.
+      </action>
+      <action issue="IO-664" dev="ggregory" type="fix" due-to="Gary Gregory">
+         org.apache.commons.io.FileUtils.copyURLToFile(*) open but do not close streams.
+      </action>
+      <action issue="IO-666" dev="ggregory" type="update" due-to="Gary Gregory">
+         Normalize internal buffers to 8192 bytes.
+      </action>
+      <action issue="IO-665" dev="ggregory" type="update" due-to="Otto Fowler, Gary Gregory">
+         Ensure that passing a null InputStream results in NPE with tests #112.
+      </action>
+      <action type="update" dev="ggregory" due-to="Gary Gregory">
+         commons.jacoco.version 0.8.4 -> 0.8.5.
+      </action>
+      <action type="update" dev="ggregory" due-to="Gary Gregory">
+         com.github.siom79.japicmp:japicmp-maven-plugin 0.14.1 -> 0.14.3.
+      </action>
+      <action issue="IO-667" dev="ggregory" type="update" due-to="Adam Retter, Gary Gregory">
+         Add functional interfaces IOFunction and IOSupplier #110.
+      </action>
+      <action dev="ggregory" type="update" due-to="Rob Spoor, Gary Gregory">
+         Support sub sequences in CharSequenceReader #91.
+      </action>
+      <action dev="ggregory" type="update" due-to="dengliming">
+         Remove deprecated sudo setting. #113.
+      </action>
+    </release>
+
+    <release version="2.6" date="2017-10-15" description="Java 7 required, Java 9 supported.">
+      <action issue="IO-553" dev="britter" type="update" due-to="Michael Ernst">
+        Make code style of hasBOM() consistent with getBOMCharsetName()
+      </action>
+       <action issue="IO-546" dev="pschumacher" type="fix" due-to="Tomas Celaya">
+        ClosedOutputStream#flush should throw
+      </action>
+      <action issue="IO-551" dev="britter" type="add">
+        Add Automatic-Module-Name MANIFEST entry for Java 9 compatibility
+      </action>
+      <action issue="IO-550" dev="kinow" type="fix" due-to="Jimi Adrian">
+        Documentation issue, fix 404 Javadoc issues in the description page
+      </action>
+      <action issue="IO-542" dev="pschumacher" type="update" due-to="Ilmars Poikans">
+        FileUtils#readFileToByteArray: optimize reading of files with known size
+      </action>
+      <action issue="IO-547" dev="ggregory" type="update" due-to="Nikhil Shinde, Michael Ernst, Gary Greory">
+        Throw a IllegalArgumentException instead of NullPointerException in FileSystemUtils.freeSpaceWindows().
+      </action>
+      <action issue="IO-367" dev="pschumacher" type="add" due-to="James Sawle">
+        Add convenience methods for copyToDirectory
+      </action>
+      <action issue="IO-442" dev="pschumacher" type="fix" due-to="Simon Robinson">
+        Javadoc contradictory for FileFilterUtils.ageFileFilter(cutoff) and the filter it constructs: AgeFileFilter(cutoff)
+      </action>
+      <action issue="IO-534" dev="sebb" type="fix">
+        FileUtilTestCase.testForceDeleteDir() should not delete testDirectory parent
+      </action>
+      <action issue="IO-528" dev="pschumacher" type="fix" due-to="Dave Moten">
+        fix Tailer.run race condition runaway logging
+      </action>
+      <action issue="IO-483" dev="kinow" type="fix" due-to="Marko Vasic">
+        getPrefixLength return -1 if Unix file contains colon
+      </action>
+      <action issue="IO-520" dev="pschumacher" type="fix">
+        FileUtilsTestCase#testContentEqualsIgnoreEOL fails on Windows
+      </action>
+      <action issue="IO-516" dev="pschumacher" type="fix" due-to="Jason Pyeron">
+        .gitattributes not correctly applied
+      </action>
+      <action issue="IO-515" dev="ggregory" type="fix" due-to="Brett Lounsbury, Gary Gregory">
+        Allow Specifying Initial Buffer Size of DeferredFileOutputStream.
+      </action>
+      <action issue="IO-512" dev="ggregory" type="fix" due-to="Ralf Hauser">
+        ThresholdingOutputStream.thresholdReached() results in FileNotFoundException.
+      </action>
+      <action issue="IO-511" dev="britter" type="fix" due-to="Ahmet Celik">
+        After a few unit tests, a few newly created directories not cleaned completely.
+      </action>
+      <action issue="IO-502" dev="ggregory" type="fix" due-to="Christian Schulte">
+        Exceptions are suppressed incorrectly when copying files.
+      </action>
+      <action issue="IO-503" dev="ggregory" type="fix">
+        Update platform requirement to Java 7.
+      </action>
+      <action issue="IO-537" dev="ggregory" type="fix" due-to="Borys Zibrov">
+        BOMInputStream shouldn't sort array of BOMs in-place.
+      </action>
+      <action issue="IO-506" dev="ggregory" type="update" due-to="Christian Schulte">
+        Deprecate methods FileSystemUtils.freeSpaceKb().
+      </action>
+      <action issue="IO-505" dev="ggregory" type="update" due-to="Christian Schulte">
+        Make LineIterator implement Closeable to support try-with-resources statements.
+      </action>
+      <action issue="IO-504" dev="ggregory" type="update" due-to="Christian Schulte">
+        Deprecated of all IOUtils.closeQuietly() methods and use try-with-resources internally.
+      </action>
+      <action issue="IO-493" dev="pschumacher" type="add" due-to="Piotr Turski">
+        Add infinite circular input stream
+      </action>
+      <action issue="IO-507" dev="ggregory" type="add">
+        Add a ByteOrderParser class.
+      </action>
+      <action issue="IO-518" dev="jochen" type="add">
+        Add ObservableInputStream
+      </action>
+      <action issue="IO-519" dev="jochen" type="add">
+        Add MessageDigestCalculatingInputStream
+      </action>
+      <action issue="IO-513" dev="ggregory" type="add" due-to="Behrang Saeedzadeh">
+        Add convenience methods for reading class path resources.
+      </action>
+      <action issue="IO-514" dev="pschumacher" type="remove">
+        Remove org.apache.commons.io.Java7Support
+      </action>
+      <action issue="IO-567" dev="jochen" type="fix">
+        Implement special case handling for NTFS ADS names: FilenameUtils.getExtension(String),
+        and FilenameUtils.indexOfExtension(String) are now throwing an IllegalArgumentException,
+        if the file name in question appears to identify an alternate data stream (Windows only).
+      </action>
+    </release>
+
+    <release version="2.5" date="2016-04-22" description="New features and bug fixes.">
+      <action issue="IO-492" dev="ggregory" type="fix" due-to="Santiago Castro">
+        Typo: In an IOUtils.java comment it says "focussed" instead of "focused".
+      </action>
+      <action issue="IO-433" dev="krosenvold" type="update">
+        Converted all test cases to JUnit 4
+      </action>
+      <action issue="IO-487" dev="bdelacretaz" type="add">
+        Add ValidatingObjectInputStream for controlled deserialization
+      </action>
+      <action issue="IO-446" dev="krosenvold" type="fix" due-to="Jeffrey Barrus">
+        adds an endOfFileReached method to the TailerListener
+      </action>
+      <action issue="IO-484" dev="krosenvold" type="fix" due-to="Philippe Arteau">
+        FilenameUtils should handle embedded null bytes
+      </action>
+      <action issue="IO-481" dev="krosenvold" type="fix">
+        Changed/Corrected algorithm for waitFor
+      </action>
+      <action issue="IO-471" dev="krosenvold" type="add" due-to="Leandro Reis">
+        Support for additional encodings in ReversedLinesFileReader
+      </action>
+      <action issue="IO-428" dev="krosenvold" type="fix" due-to="Stefan Gmeiner">
+        BOMInputStream.skip returns wrong count if stream contains no BOM
+      </action>
+      <action issue="IO-425" dev="krosenvold" type="add" due-to="Craig Swank">
+        Setter method for threshold on ThresholdingOutputStream
+      </action>
+      <action issue="IO-488" dev="krosenvold" type="fix" due-to="Björn Buchner">
+        FileUtils.waitFor(...) swallows thread interrupted status
+      </action>
+      <action issue="IO-452" dev="krosenvold" type="fix" due-to="David Standish">
+        Support for symlinks with missing target. Added support for JDK7 symlink features when present
+      </action>
+      <action issue="IO-466" dev="krosenvold" type="update">
+        Added testcase to show this was fixed with IO-423
+      </action>
+      <action issue="IO-479" dev="sebb" type="update" due-to="Zhouce Chen">
+        Correct exception message in FileUtils.getFile(File, String...)
+      </action>
+      <action issue="IO-406" dev="britter" type="add" due-to="Niall Pemberton">
+        Introduce new class AppendableOutputStream
+      </action>
+      <action issue="IO-465" dev="britter" type="update" due-to="based2">
+         Update to JUnit 4.12
+      </action>
+      <action issue="IO-462" dev="sebb" type="update">
+         IOExceptionWithCause no longer needed
+      </action>
+      <action issue="IO-459" dev="olamy" type="add" due-to="Kristian Rosenvold">
+        Add WindowsLineEndingInputStream and UnixLineEndingInputStream.
+      </action>
+      <action issue="IO-457" dev="olamy" type="add" due-to="Kristian Rosenvold">
+        Add a BoundedReader, a wrapper that can be used to constrain access
+        to an underlying stream when used with mark/reset -
+        to avoid overflowing the mark limit of the underlying buffer.
+      </action>
+      <action issue="IO-453" dev="sebb" type="fix" due-to="Steven Christou">
+         Regression in FileUtils.readFileToString from 2.0.1
+      </action>
+      <action issue="IO-451" dev="sebb" type="fix" due-to="David Standish">
+         ant test fails - resources missing from test classpath
+      </action>
+      <action issue="IO-435" dev="tn" type="fix" due-to="Dominik Stadler">
+         Document that FileUtils.deleteDirectory, directoryContains and cleanDirectory
+         may throw an IllegalArgumentException in case the passed directory does not
+         exist or is not a directory.
+      </action>
+      <action issue="IO-426" dev="ggregory" type="add">
+         Add API IOUtils.closeQuietly(Closeable...)
+      </action>
+      <action issue="IO-424" dev="ggregory" type="fix" due-to="Ville Skyttä">
+         Javadoc fixes, mostly to appease 1.8.0
+      </action>
+      <action issue="IO-422" dev="ggregory" type="update">
+         Deprecate Charsets Charset constants in favor of Java 7's java.nio.charset.StandardCharsets
+      </action>
+      <action issue="IO-410" dev="sebb" type="add" due-to="Beluga Behr">
+         Readfully() That Returns A Byte Array
+      </action>
+      <action issue="IO-395" dev="brentworden" type="add" due-to="Beluga Behr">
+         Overload IOUtils buffer methods to accept buffer size
+      </action>
+      <action issue="IO-389" dev="sebb" type="fix" due-to="Austin Doupnik">
+         FileUtils.sizeOfDirectory can throw IllegalArgumentException
+      </action>
+      <action issue="IO-390" dev="sebb" type="fix">
+         FileUtils.sizeOfDirectoryAsBigInteger can overflow.
+         Ensure that recursive calls all use BigInteger
+      </action>
+      <action issue="IO-382" dev="sebb" type="add">
+         Chunked IO for large arrays.
+         Added writeChunked(byte[], OutputStream) and writeChunked(char[] Writer)
+         Added ChunkedOutputStream, ChunkedWriter
+      </action>
+      <action issue="IO-385" dev="sebb" type="fix">
+         FileUtils.doCopyFile can potentially loop forever
+         Exit loop if no data to copy
+      </action>
+      <action issue="IO-383" dev="sebb" type="fix">
+         FileUtils.doCopyFile caches the file size; needs to be documented
+         Added Javadoc; show file lengths in exception message
+      </action>
+      <action issue="IO-239" dev="sebb" type="update">
+         Convert IOCase to a Java 1.5+ Enumeration
+         [N.B. this is binary compatible]
+      </action>
+      <action issue="IO-233" dev="sebb" type="add">
+         Add Methods for Buffering Streams/Writers To IOUtils
+         Added overloaded buffer() methods - see also IO-330
+      </action>
+      <action issue="IO-330" dev="sebb" type="add">
+         IOUtils#toBufferedOutputStream/toBufferedWriter to conditionally wrap the output
+         Added overloaded buffer() methods - see also IO-233
+      </action>
+      <action issue="IO-381" dev="ggregory" type="add">
+        Add FileUtils.copyInputStreamToFile API with option to leave the source open.
+        See copyInputStreamToFile(final InputStream source, final File destination, boolean closeSource)
+      </action>
+      <action issue="IO-380" dev="sebb" type="fix" due-to="claudio_ch">
+        FileUtils.copyInputStreamToFile should document it closes the input source
+      </action>
+      <action issue="IO-279" dev="sebb" type="fix">
+        Tailer erroneously considers file as new.
+        Fix to use file.lastModified() rather than System.currentTimeMillis()
+      </action>
+      <action issue="IO-356" dev="sebb" type="fix">
+         CharSequenceInputStream#reset() behaves incorrectly in case when buffer size is not dividable by data size.
+         Fix code so skip relates to the encoded bytes; reset now re-encodes the data up to the point of the mark
+      </action>
+      <action issue="IO-379" dev="sebb" type="add">
+         CharSequenceInputStream - add tests for available()
+         Fix code so it really does reflect a minimum available.
+      </action>
+      <action issue="IO-328" dev="sebb" type="update">
+        getPrefixLength returns null if filename has leading slashes
+        Javadoc: add examples to show correct behavior; add unit tests
+      </action>
+      <action issue="IO-299" dev="sebb" type="update">
+        FileUtils.listFilesAndDirs includes original dir in results even when it doesn't match filter
+        Javadoc: clarify that original dir is included in the results
+      </action>
+      <action issue="IO-346" dev="sebb" type="add">
+         Add ByteArrayOutputStream.toInputStream()
+      </action>
+      <action issue="IO-368" dev="sebb" type="fix">
+        ClassLoaderObjectInputStream does not handle primitive typed members
+      </action>
+      <action issue="IO-341" dev="sebb" type="add">
+         A constant for holding the BOM character (U+FEFF)
+      </action>
+      <action issue="IO-314" dev="sebb" type="fix">
+        Deprecate all methods that use the default encoding
+      </action>
+      <action issue="IO-338" dev="sebb" type="fix">
+        When a file is rotated, finish reading previous file prior to starting new one
+      </action>
+      <action issue="IO-354" dev="sebb" type="fix">
+        Commons IO Tailer does not respect UTF-8 Charset.
+      </action>
+      <action issue="IO-323" dev="sebb" type="fix">
+        What should happen in FileUtils.sizeOf[Directory] when an overflow takes place?
+        Added Javadoc.
+      </action>
+      <action issue="IO-372" dev="sebb" type="fix">
+        FileUtils.moveDirectory can produce misleading error message on failure
+      </action>
+      <action issue="IO-375" dev="sebb" type="update">
+        FilenameUtils.splitOnTokens(String text) check for '**' could be simplified
+      </action>
+      <action issue="IO-374" dev="sebb" type="update">
+        WildcardFileFilter ctors should not use null to mean IOCase.SENSITIVE when delegating to other ctors
+      </action>
+      <action issue="IO-362" dev="ggregory" type="fix" due-to="mmadson, ggregory">
+        IOUtils.contentEquals* methods returns false if input1 == input2, should return true.
+      </action>
+      <action issue="IO-361" dev="ggregory" type="add">
+        Add API FileUtils.forceMkdirsParent().
+      </action>
+      <action issue="IO-360" dev="ggregory" type="add">
+        Add API Charsets.requiredCharsets().
+      </action>
+      <action issue="IO-359" dev="ggregory" type="add" due-to="yukoba">
+        Add IOUtils.skip and skipFully(ReadableByteChannel, long).
+      </action>
+      <action issue="IO-358" dev="ggregory" type="add" due-to="yukoba">
+        Add IOUtils.read and readFully(ReadableByteChannel, ByteBuffer buffer).
+      </action>
+      <action issue="IO-357" dev="ggregory" type="fix" due-to="mortenh">
+        [Tailer] InterruptedException while the thread is sleeping is silently ignored
+      </action>
+      <action issue="IO-353" dev="ggregory" type="add" due-to="ggregory">
+        Add API IOUtils.copy(InputStream, OutputStream, int)
+      </action>
+      <action issue="IO-349" dev="ggregory" type="add" due-to="scop">
+        Add API with array offset and length argument to FileUtils.writeByteArrayToFile.
+      </action>
+      <action issue="IO-352" dev="ggregory" type="fix" due-to="scop">
+        Spelling fixes.
+      </action>
+      <action issue="IO-348" dev="ggregory" type="add" due-to="plcstpierre">
+        Missing information in IllegalArgumentException thrown by org.apache.commons.io.FileUtils#validateListFilesParameters.
+      </action>
+      <action issue="IO-345" dev="ggregory" type="add" due-to="mkresse">
+        Supply a hook method allowing Tailer actively determining stop condition.
+      </action>
+      <action issue="IO-436" dev="ggregory" type="fix" due-to="christoph.schneegans">
+        Improper Javadoc comment for FilenameUtils.indexOfExtension.
+      </action>
+      <action issue="IO-437" dev="ggregory" type="add">
+        Make IOUtils.EOF public and reuse it in various classes.
+      </action>
+    </release>
+
+    <release version="2.4" date="2012-06-12" description="New features and bug fixes.">
+      <action issue="IO-343" dev="ggregory" type="fix" due-to="igorlash">
+        org.apache.commons.io.comparator Javadoc is inconsistent with real code.
+      </action>
+      <action issue="IO-336" dev="ggregory" type="fix" due-to="rleavelle">
+        Yottabyte (YB) incorrectly defined in FileUtils.
+      </action>
+      <action issue="IO-269" dev="ggregory" type="add" due-to="sebb">
+        Tailer locks file from deletion/rename on Windows.
+      </action>
+      <action issue="IO-279" dev="sebb" type="fix" due-to="Sergio Bossa, Chris Baron">
+        Tailer erroneously considers file as new.
+      </action>
+      <action issue="IO-335" dev="sebb" type="fix">
+        Tailer#readLines - incorrect CR handling.
+      </action>
+      <action issue="IO-334" dev="sebb" type="fix">
+        FileUtils.toURLs throws NPE for null parameter; document the behavior.
+      </action>
+      <action issue="IO-333" dev="ggregory" type="add" due-to="fmeschbe">
+        Export OSGi packages at version 1.x in addition to 2.x.
+      </action>
+      <action issue="IO-320" dev="ggregory" type="add" due-to="ggregory">
+        Add XmlStreamReader support for UTF-32.
+      </action>
+      <action issue="IO-331" dev="ggregory" type="add" due-to="ggregory">
+        BOMInputStream wrongly detects UTF-32LE_BOM files as UTF-16LE_BOM files in method getBOM().
+      </action>
+      <action issue="IO-332" dev="ggregory" type="fix" due-to="liangly">
+        Improve tailer's reading performance.
+      </action>
+      <action issue="IO-279" dev="ggregory" type="fix">
+        Improve Tailer performance with buffered reads (see IO-332).
+      </action>
+      <action issue="IO-329" dev="ggregory" type="fix" due-to="tivv">
+        FileUtils.writeLines uses unbuffered IO.
+      </action>
+      <action issue="IO-327" dev="ggregory" type="add" due-to="ggregory">
+        Add byteCountToDisplaySize(BigInteger).
+      </action>
+      <action issue="IO-326" dev="ggregory" type="add" due-to="ggregory, kinow">
+        Add new FileUtils.sizeOf[Directory] APIs to return BigInteger.
+      </action>
+      <action issue="IO-325" dev="ggregory" type="add" due-to="raviprak">
+        Add IOUtils.toByteArray methods to work with URL and URI.
+      </action>
+      <action issue="IO-324" dev="ggregory" type="add" due-to="raviprak">
+        Add missing Charset sister APIs to method that take a String charset name.
+      </action>
+      <action issue="IO-319" dev="ggregory" type="fix" due-to="raviprak">
+        FileUtils.sizeOfDirectory follows symbolic links.
+      </action>
+    </release>
+
+    <release version="2.3" date="2012-April-10" description="New features and bug fixes.">
+      <action issue="IO-322" dev="ggregory" type="add" due-to="ggregory">
+        Add and use class Charsets.
+      </action>
+      <action issue="IO-321" dev="ggregory" type="add" due-to="ggregory">
+        ByteOrderMark UTF_32LE is incorrect.
+      </action>
+      <action issue="IO-318" dev="ggregory" type="add" due-to="ggregory">
+        Add Charset sister APIs to method that take a String charset name.
+      </action>
+    </release>
+
+    <release version="2.2" date="2012-March-26" description="New features and bug fixes.">
+      <action issue="IO-313" dev="ggregory" type="add" due-to="ggregory">
+        Add IOUtils.toBufferedReader(Reader)
+      </action>
+      <!-- Note: the issue was not raised by Manoj, but arose from IO-305 and tests he performed -->
+      <action issue="IO-308" dev="sebb" type="add" due-to="Manoj Mokashi">
+        Allow applications to provide buffer (or size) for copyLarge methods.
+      </action>
+      <action issue="IO-311" dev="sebb" type="fix" due-to="Robert Muir">
+        IOUtils.read(InputStream/Reader) ignores the offset parameter
+      </action>
+      <action issue="IO-312" dev="sebb" type="fix">
+        CharSequenceInputStream(CharSequence s, Charset charset, int bufferSize) ignores bufferSize
+      </action>
+      <action issue="IO-305" dev="sebb" type="add" due-to="Manoj Mokashi">
+        New copyLarge() method in IOUtils that takes additional offset, length arguments
+      </action>
+      <action issue="IO-300" dev="sebb" type="fix">
+        FileUtils.moveDirectoryToDirectory removes source directory if destination is a sub-directory
+      </action>
+      <action issue="IO-307" dev="sebb" type="fix">
+        ReaderInputStream#read(byte[] b, int off, int len) should check for valid parameters
+      </action>
+      <action issue="IO-287" dev="bayard" type="add" due-to="Ron Kuris, Gary Gregory">
+        Use terabyte (TB), petabyte (PB) and exabyte (EB) in FileUtils.byteCountToDisplaySize(long size)
+      </action>
+      <action issue="IO-306" dev="sebb" type="fix">
+        ReaderInputStream#read(byte[] b, int off, int len) should always return 0 for length == 0
+      </action>
+      <action issue="IO-173" dev="sebb" type="add" due-to="Marcos Vinícius da Silva">
+        FileUtils.listFiles() doesn't return directories
+      </action>
+      <action issue="IO-276" dev="sebb" type="fix" due-to="nkami">
+        "FileUtils#deleteDirectoryOnExit(File)" does not work
+      </action>
+      <action issue="IO-273" dev="sebb" type="fix" due-to="sebb">
+        BoundedInputStream.read() treats max differently from BoundedInputStream.read(byte[]...)
+      </action>
+      <action issue="IO-297" dev="sebb" type="add" due-to="Oleg Kalnichevski">
+        CharSequenceInputStream to efficiently stream content of a CharSequence
+      </action>
+      <action issue="IO-296" dev="sebb" type="update" due-to="Oleg Kalnichevski">
+        ReaderInputStream optimization: more efficient reading of small chunks of data
+      </action>
+      <action issue="IO-298" dev="sebb" type="fix" due-to="Christian Schulte">
+        Various methods of class 'org.apache.commons.io.FileUtils' incorrectly suppress 'java.io.IOException'
+      </action>
+      <action issue="IO-304" dev="ggregory" type="add" due-to="liangly">
+        The second constructor of Tailer class does not pass 'delay' to the third one
+      </action>
+      <action issue="IO-303" dev="ggregory" type="add" due-to="fabian.barney">
+        TeeOutputStream does not call branch.close() when main.close() throws an exception
+      </action>
+      <action issue="IO-302" dev="ggregory" type="add" due-to="jsteuerwald, detinho">
+        ArrayIndexOutOfBoundsException in BOMInputStream when reading a file without BOM multiple times
+      </action>
+      <action issue="IO-301" dev="ggregory" type="add" due-to="kaykay.unique">
+        Add IOUtils.closeQuietly(Selector) necessary
+      </action>
+      <action issue="IO-292" dev="sebb" type="add" due-to="sebb">
+        IOUtils.closeQuietly() should take a ServerSocket as a parameter
+      </action>
+      <action issue="IO-290" dev="sebb" type="add" due-to="sebb">
+        Add read/readFully methods to IOUtils
+      </action>
+      <action issue="IO-288" dev="sebb" type="add" due-to="Georg Henzler">
+        Supply a ReversedLinesFileReader
+      </action>
+      <action issue="IO-291" dev="ggregory" type="add" due-to="ggregory">
+        Add new function FileUtils.directoryContains.
+      </action>
+      <action issue="IO-275" dev="sebb" type="add" due-to="CJ Aspromgos">
+        FileUtils.contentEquals and IOUtils.contentEquals - Add option to ignore "line endings"
+        Added contentEqualsIgnoreEOL methods to both classes
+      </action>
+    </release>
+
+    <release version="2.1" date="2011-Sep-28" description="New features and bug fixes.">
+      <action dev="ggregory" type="add" issue="IO-285" due-to="ggregory">
+        Use standard Maven directory layout
+      </action>
+      <action dev="ggregory" type="add" issue="IO-284" due-to="ggregory">
+        Add IOUtils API toString for URL and URI to get contents
+      </action>
+      <action dev="ggregory" type="add" issue="IO-282" due-to="ggregory">
+        Add API FileUtils.copyFile(File input, OutputStream output)
+      </action>
+      <action dev="sebb" type="fix" issue="IO-280" due-to="sebb">
+        Dubious use of mkdirs() return code
+      </action>
+      <action type="fix" issue="IO-277">
+        ReaderInputStream enters infinite loop when it encounters an unmappable character
+      </action>
+      <action type="fix" issue="IO-264">
+        FileUtils.moveFile() Javadoc should specify FileExistsException thrown
+      </action>
+      <action type="add" issue="IO-262">
+        FileAlterationObserver has no getter for FileFilter
+      </action>
+      <action type="add" issue="IO-261">
+        Add FileUtils.getFile API with varargs parameter
+      </action>
+      <action type="fix" issue="IO-260">
+        ClassLoaderObjectInputStream does not handle Proxy classes
+      </action>
+      <action type="update" issue="IO-259">
+        FileAlterationMonitor.stop(boolean allowIntervalToFinish)
+      </action>
+      <action type="add" issue="IO-182">
+        Add new APPEND parameter for writing string into files
+      </action>
+      <action dev="sebb" type="fix" issue="IO-274" due-to="Frank Grimes">
+        Tailer returning partial lines when reaching EOF before EOL
+      </action>
+      <action dev="sebb" type="fix" issue="IO-266" due-to="Igor Smereka">
+        FileUtils.copyFile() throws IOException when copying large files to a shared directory (on Windows)
+      </action>
+      <action dev="sebb" type="fix" issue="IO-263" due-to="Gil Adam">
+        FileSystemUtils.freeSpaceKb throws exception for Windows volumes with no visible files.
+        Improve coverage by also looking for hidden files.
+      </action>
+      <action dev="sebb" type="add" issue="IO-251" due-to="Marco Albini">
+        Add new read method "toByteArray" to handle InputStream with known size.
+      </action>
+    </release>
+
+    <release version="2.0.1" date="2010-Dec-26">
+      <action type="update">
+        TODO: Convert RELEASE-NOTES.txt from 2.0.1?
+      </action>
+    </release>
+
+    <release version="2.0" date="2010-Oct-18">
+      <action type="update">
+        TODO: Convert RELEASE-NOTES.txt from 2.0?
+      </action>
+    </release>
+
+    <release version="1.4" date="2008-Jan-21">
+      <action type="update">
+        TODO: Convert RELEASE-NOTES.txt from 1.4?
+      </action>
+    </release>
+
+    <release version="1.3.2" date="2007-Jul-02" description="Bug fixes.">
+      <action dev="jochen" type="fix" issue="IO-115">
+        Some tests, which are implicitly assuming a Unix-like file
+        system, are now skipped on Windows.
+      </action>
+      <action dev="jochen" type="fix" issue="IO-116">
+        Created the FileCleaningTracker, basically a non-static
+        version of the FileCleaner, which can be controlled by
+        the user.
+      </action>
+      <action dev="bayard" type="fix" issue="IO-117" due-to="Hiroshi Ikeda">
+        EndianUtils - both readSwappedUnsignedInteger(...) methods could
+        return negative numbers due to int/long casting.
+      </action>
+    </release>
+  </body>
+</document>
diff --git a/src/changes/release-notes.vm b/src/changes/release-notes.vm
new file mode 100644
index 0000000..49e8dea
--- /dev/null
+++ b/src/changes/release-notes.vm
@@ -0,0 +1,1492 @@
+## Licensed to the Apache Software Foundation (ASF) under one
+## or more contributor license agreements.  See the NOTICE file
+## distributed with this work for additional information
+## regarding copyright ownership.  The ASF licenses this file
+## to you under the Apache License, Version 2.0 (the
+## "License"); you may not use this file except in compliance
+## with the License.  You may obtain a copy of the License at
+##
+##  http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing,
+## software distributed under the License is distributed on an
+## "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+## KIND, either express or implied.  See the License for the
+## specific language governing permissions and limitations
+## under the License.
+
+Apache Commons IO 
+Version ${version}
+Release Notes
+
+INTRODUCTION:
+
+Commons IO is a package of Java utility classes like java.io.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+$introduction.replaceAll("(?<!\015)\012", "
+")
+
+==============================================================================
+Apache Commons IO Version ${version}
+==============================================================================
+##
+## N.B. the available variables are described here:
+## http://maven.apache.org/plugins/maven-changes-plugin/examples/using-a-custom-announcement-template.html
+##
+## Hack to improve layout: replace all pairs of spaces with a single new-line
+$release.description.replaceAll("  ", "
+")
+
+##
+#if ($release.getActions().size() == 0)
+No changes defined in this version.
+#else
+Changes in this version include:
+
+## indent to be used if there is no issue attribute.
+## should be the same as the indent in the changes.xml file
+## less 2 spaces for the 'o' and trailing space
+#set($indent='          ')
+#if ($release.getActions('add').size() !=0)
+New features:
+#foreach($actionItem in $release.getActions('add'))
+## Use replaceAll to fix up LF-only line ends on Windows.
+#set($action=$actionItem.getAction().replaceAll("\n","
+"))
+#if ($actionItem.getIssue())
+#set($issue=$actionItem.getIssue())
+#else
+#set($issue="")
+#end
+#if ($actionItem.getDueTo())
+#set($dueto=$actionItem.getDueTo())
+#else
+#set($dueto="")
+#end
+o#if($!issue != "") $issue: #else$indent#end ${action} #if($!dueto != "")Thanks to $dueto. #end
+
+#set($issue="")
+#set($dueto="")
+#end 
+#end
+
+#if ($release.getActions('fix').size() !=0)
+Fixed Bugs:
+#foreach($actionItem in $release.getActions('fix'))
+## Use replaceAll to fix up LF-only line ends on Windows.
+#set($action=$actionItem.getAction().replaceAll("\n","
+"))
+#if ($actionItem.getIssue())
+#set($issue=$actionItem.getIssue())
+#else
+#set($issue="")
+#end
+#if ($actionItem.getDueTo())
+#set($dueto=$actionItem.getDueTo())
+#else
+#set($dueto="")
+#end
+o#if($!issue != "") $issue: #else$indent#end ${action} #if($!dueto != "")Thanks to $dueto. #end
+
+#set($issue="")
+#set($dueto="")
+#end
+#end
+
+#if ($release.getActions('update').size() !=0)
+Changes:
+#foreach($actionItem in $release.getActions('update'))
+## Use replaceAll to fix up LF-only line ends on Windows.
+#set($action=$actionItem.getAction().replaceAll("\n","
+"))
+#if ($actionItem.getIssue())
+#set($issue=$actionItem.getIssue())
+#else
+#set($issue="")
+#end
+#if ($actionItem.getDueTo())
+#set($dueto=$actionItem.getDueTo())
+#else
+#set($dueto="")
+#end
+o#if($!issue != "") $issue: #else$indent#end ${action} #if($!dueto != "")Thanks to $dueto. #end
+
+#set($issue="")
+#set($dueto="")
+#end
+#end
+
+#if ($release.getActions('remove').size() !=0)
+Removed:
+#foreach($actionItem in $release.getActions('remove'))
+## Use replaceAll to fix up LF-only line ends on Windows.
+#set($action=$actionItem.getAction().replaceAll("\n","
+"))
+#if ($actionItem.getIssue())
+#set($issue=$actionItem.getIssue())
+#else
+#set($issue="")
+#end
+#if ($actionItem.getDueTo())
+#set($dueto=$actionItem.getDueTo())
+#else
+#set($dueto="")
+#end
+o#if($!issue != "") $issue: #else$indent#end ${action} #if($!dueto != "")Thanks to $dueto. #end
+##
+#set($issue="")
+#set($dueto="")
+#end
+#end
+## End of main loop
+#end
+##
+Compatibility with 2.6:
+Binary compatible: Yes.
+Source compatible: Yes.
+Semantic compatible: Yes.
+
+Commons IO 2.9.0 requires Java 8.
+Commons IO 2.8.0 requires Java 8.
+Commons IO 2.7 requires Java 8.
+Commons IO 2.6 requires Java 7.
+Commons IO 2.5 requires Java 6.
+Commons IO 2.4 requires Java 6.
+Commons IO 2.3 requires Java 6.
+Commons IO 2.2 requires Java 5.
+Commons IO 1.4 requires Java 1.3.
+
+Historical list of changes: ${project.url}changes-report.html
+
+For complete information on ${project.name}, including instructions on how to submit bug reports,
+patches, or suggestions for improvement, see the ${project.name} website:
+
+${project.url}
+
+Download page: ${project.url}download_io.cgi
+
+Have fun!
+-Apache Commons Team
+
+Apache Commons IO 
+Version 2.8.0
+Release Notes
+
+INTRODUCTION:
+
+Commons IO is a package of Java utility classes like java.io.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+The Apache Commons IO library contains utility classes, stream implementations, file filters,
+file comparators, endian transformation classes, and much more.
+
+==============================================================================
+Apache Commons IO Version 2.8.0
+==============================================================================
+Java 8 required.
+
+Changes in this version include:
+
+New features:
+o Add org.apache.commons.io.input.CircularInputStream. Thanks to Gary Gregory. 
+o Add org.apache.commons.io.file.PathUtils.cleanDirectory(Path, FileVisitOption...). Thanks to Gary Gregory. 
+o Add org.apache.commons.io.file.PathUtils.deleteDirectory(Path, FileVisitOption...). Thanks to Gary Gregory. 
+o Add NullAppendable. Thanks to Gary Gregory. 
+o Add PathUtils.getAclEntryList(Path). Thanks to Gary Gregory. 
+o Null-guard IOUtils.close(Closeable, IOConsumer). Thanks to Gary Gregory. 
+o Add ReversedLinesFileReader.readLines(int). Thanks to Gary Gregory. 
+o Add ReversedLinesFileReader.toString(int). Thanks to Gary Gregory. 
+o IO-684:  Add PathUtils.delete(Path, DeleteOption...).
+        Add PathUtils.deleteDirectory(Path, DeleteOption...).
+        Add PathUtils.deleteFile(Path, DeleteOption...).
+        Add PathUtils.setReadOnly(Path, boolean, LinkOption...).
+        Add CleaningPathVisitor.CleaningPathVisitor(PathCounters, DeleteOption[], String...).
+        Add DeletingPathVisitor.DeletingPathVisitor(PathCounters, DeleteOption[], String...). Thanks to Gary Gregory, Robin Jansohn. 
+o Add RandomAccessFileInputStream. Thanks to Gary Gregory. 
+o IO-681:  IOUtils.close(Closeable) should allow a list of closeables. 
+o Add IOUtils.consume(InputStream). Thanks to Gary Gregory. 
+o IO-676:  Add isFileNewer() and isFileOlder() methods that support the Java 8 Date/Time API. #124. Thanks to Isira Seneviratne, Gary Gregory. 
+o Add a MarkShieldInputStream #119. Thanks to Adam Retter, Gary Gregory. 
+o Deprecate IOUtils.LINE_SEPARATOR in favor of Java 7's System.lineSeparator(). Thanks to Gary Gregory. 
+
+Fixed Bugs:
+o CharSequenceReader.skip should return 0 instead of EOF on stream end #123. Thanks to Rob Spoor, Jochen Wiedmann. 
+o Implement CharSequenceReader.ready() #122. Thanks to Rob Spoor. 
+o IO-669:  Fix code smells; fix typos #115. Thanks to XenoAmess, Gary Gregory. 
+o Add caching for required charsets #120. Thanks to Jerome Wolff, Gary Gregory. 
+o IO-673:  Make some simplifications #121. Thanks to Jerome Wolff. 
+o IO-674:  InfiniteCircularInputStream is not infinite if its input buffer contains -1. Thanks to Gary Gregory. 
+o IO-675:  InfiniteCircularInputStream throws a divide-by-zero exception when reading if its input buffer is size 0. Thanks to Gary Gregory. 
+o IO-677:  FileSystem.getCurrent() does not return the correct enum. Thanks to Gary Gregory. 
+o IO-679:  input.AbstractCharacterFilterReader passes count of chars read #132. Thanks to proneel. 
+o IO-683:  CircularBufferInputStream.read() fails to convert byte to unsigned int 
+o Fix SpotBugs issues in org.apache.commons.io.FileUtils. Thanks to Gary Gregory. 
+o IO-672:  Copying a File sets last modified date to 01 January 1970. 
+o IO-676:  Prevent NullPointerException in ReversedLinesFileReader constructors #117. Thanks to Michael Ernst, Gary Gregory. 
+
+Changes:
+o Replace FindBugs with SpotBugs. Thanks to Gary Gregory. 
+o maven-checkstyle-plugin 3.1.0 -> 3.1.1. Thanks to Gary Gregory. 
+o Update tests from org.apache.commons:commons-lang3 3.10 to 3.11. Thanks to Gary Gregory. 
+o Update commons-parent from 50 to 51 #129. Thanks to Gary Gregory. 
+o Update actions/checkout from v1 to v2.3.1 #126. Thanks to Gary Gregory. 
+o Update junit-pioneer from 0.6.0 to 0.8.0, #127, #135. Thanks to Gary Gregory. 
+o Update mockito-core from 3.3.3 to 3.5.9 #128, #133, #145, #149, #151. Thanks to Gary Gregory. 
+o Update spotbugs from 4.0.6 to 4.1.1 #134. Thanks to Dependabot. 
+o Update junit-pioneer from 0.8.0 to 0.9.0 #138. Thanks to Dependabot. 
+o Update actions/checkout from v2.3.1 to v2.3.2 #140. Thanks to Dependabot. 
+o Update actions/setup-java from v1.4.0 to v1.4.2 #141, #148. Thanks to Dependabot. 
+
+Compatibility with 2.7:
+Binary compatible: Yes.
+Source compatible: Yes.
+Semantic compatible: Yes.
+
+Commons IO 2.7 requires Java 8.
+Commons IO 2.6 requires Java 7.
+Commons IO 2.5 requires Java 6.
+Commons IO 2.4 requires Java 6.
+Commons IO 2.3 requires Java 6.
+Commons IO 2.2 requires Java 5.
+Commons IO 1.4 requires Java 1.3.
+
+Historical list of changes: https://commons.apache.org/proper/commons-io/changes-report.html
+
+For complete information on Apache Commons IO, including instructions on how to submit bug reports,
+patches, or suggestions for improvement, see the Apache Commons IO website:
+
+https://commons.apache.org/proper/commons-io/
+
+Download page: https://commons.apache.org/proper/commons-io/download_io.cgi
+
+Have fun!
+-Apache Commons Team
+
+==============================================================================
+
+Apache Commons IO 
+Version 2.7
+Release Notes
+
+INTRODUCTION:
+
+Commons IO is a package of Java utility classes like java.io.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+The Apache Commons IO library contains utility classes, stream implementations, file filters,
+file comparators, endian transformation classes, and much more.
+
+==============================================================================
+Apache Commons IO Version 2.7
+==============================================================================
+Java 8 required.
+
+Changes in this version include:
+
+New features:
+o           Adding the CircularBufferInputStream, and the PeekableInputStream. 
+o IO-553:  Add org.apache.commons.io.FilenameUtils.isIllegalWindowsFileName(char). 
+o IO-577:  Add readers to filter out given characters: CharacterSetFilterReader and CharacterFilterReader. Thanks to Gary Gregory. 
+o IO-594:  Add IOUtils copy methods with java.lang.Appendable as the target. Thanks to Gary Gregory. 
+o IO-605:  Add class CanExecuteFileFilter. Thanks to Gary Gregory. 
+o IO-578:  Support java.nio.Path and non-default file systems for ReversedLinesFileReader (#62). Thanks to Mark Chesney. 
+o IO-608:  Add a convenience NullPrintStream. Thanks to Gary Gregory. 
+o IO-612:  Add class TeeReader. Thanks to Rob Spoor, Gary Gregory. 
+o IO-613:  Add classes ClosedReader and CloseShieldReader. #84. Thanks to Rob Spoor, Gary Gregory. 
+o IO-614:  Add classes TaggedWriter, ClosedWriter and BrokenWriter. #86. Thanks to Rob Spoor. 
+o IO-615:  Add classes TeeWriter, FilterCollectionWriter, ProxyCollectionWriter, IOExceptionList, IOIndexedException. Thanks to Gary Gregory, Rob Spoor. 
+o IO-616:  Add class AppendableWriter. #87. Thanks to Rob Spoor. 
+o IO-617:  Add class CloseShieldWriter. #83. Thanks to Rob Spoor, Gary Gregory. 
+o IO-618:  Add classes Added TaggedReader, ClosedReader and BrokenReader. #85. Thanks to Rob Spoor. 
+o IO-619:  Support sub sequences in CharSequenceReader. #91. Thanks to Rob Spoor. 
+o IO-631:  Add a CountingFileVisitor (as the basis for a forthcoming DeletingFileVisitor). Thanks to Gary Gregory. 
+o IO-632:  Add PathUtils for operations on NIO Path. Thanks to Gary Gregory. 
+o IO-633:  Add DeletingFileVisitor. Thanks to Gary Gregory. 
+o IO-635:  Add org.apache.commons.io.IOUtils.close(Closeable). Thanks to Gary Gregory. 
+o IO-636:  Add and reuse org.apache.commons.io.IOUtils.closeQuitely(Closeable, Consumer<IOException>).
+           Add and reuse org.apache.commons.io.IOUtils.close(Closeable, IOConsumer<IOException>). Thanks to Gary Gregory. 
+o IO-645:  Add org.apache.commons.io.file.PathUtils.fileContentEquals(Path, Path, OpenOption...). Thanks to Gary Gregory. 
+o IO-458:  Add a SequenceReader similar to java.io.SequenceInputStream. Thanks to Gary Gregory, Joshua Gitlin. 
+o IO-648:  Implement directory content equality. 100#. Thanks to Gary Gregory. 
+o IO-648:  Refactor ByteArrayOutputStream into synchronized and unsynchronized versions #108. Thanks to Adam Retter, Alex Herbert, Gary Gregory. 
+o IO-662:  Refactor ByteArrayOutputStream into synchronized and unsynchronized versions #108. Thanks to Adam Retter, Gary Gregory. 
+
+Fixed Bugs:
+o IO-589:  Some tests fail if the base path contains a space. 
+o IO-582:  Make methods in ObservableInputStream.Obsever public. Thanks to Bruno Palos. 
+o IO-535:  Thread bug in FileAlterationMonitor.stop(int). Thanks to Svetlin Zarev, Anthony Raymond. 
+o IO-557:  Perform locale independent upper case conversions. Thanks to luccioman. 
+o IO-570:  Missing Javadoc in FilenameUtils causing Travis-CI build to fail. Thanks to Pranet Verma. 
+o IO-571:  Remove redundant isDirectory() check in org.apache.commons.io.FileUtils.listFilesAndDirs(File, IOFileFilter, IOFileFilter). Thanks to pranet. 
+o IO-559:  FilenameUtils.normalize now verifies hostname syntax in UNC path. 
+o IO-554:  FileUtils.copyToFile(InputStream source, File destination) should not close input stream. Thanks to Michele Mariotti. 
+o IO-604:  FileUtils.doCopyFile(File, File, boolean) can throw ClosedByInterruptException. Thanks to Gary Gregory. 
+o IO-625:  Corrected misleading exception message for FileUtils.copyDirectoryToDirectory. Thanks to Mikko Maunu. 
+o IO-626:  A mistake in the FilenameUtils.concat()'s Javadoc about an absolute path. Thanks to Yuji Konishi. 
+o IO-640:  NPE in org.apache.commons.io.IOUtils.contentEquals(InputStream, InputStream) when only one input is null. Thanks to Gary Gregory. 
+o IO-641:  NPE in org.apache.commons.io.IOUtils.contentEquals(Reader, Reader) when only one input is null. Thanks to Gary Gregory. 
+o IO-643:  NPE in org.apache.commons.io.IOUtils.contentEqualsIgnoreEOL(Reader, Reader) when only one input is null. Thanks to Gary Gregory. 
+o IO-644:  NPE in org.apache.commons.io.FileUtils.contentEqualsIgnoreEOL(File, File) when only one input is null. Thanks to Gary Gregory. 
+o IO-664:  org.apache.commons.io.FileUtils.copyURLToFile(*) open but do not close streams. Thanks to Gary Gregory. 
+
+Changes:
+o IO-572:  Refactor duplicate code in org.apache.commons.io.FileUtils. Thanks to Pranet Verma. 
+o IO-580:  Update org.apache.commons.io.FilenameUtils.isExtension(String, String[]) to use var args. 
+o IO-701:  Make array declaration in ThresholdingOutputStream consistent with other array declarations in the library #77. Thanks to Raymond Tan. 
+o IO-607:  Update from Java 7 to Java 8. Thanks to Gary Gregory. 
+o IO-610:  Remove throws IOException in method isSymlink() #80. Thanks to Sebastian. 
+o IO-628:  Migration to JUnit Jupiter #97. Thanks to Allon Mureinik. 
+o IO-630:  Deprecate org.apache.commons.io.output.NullOutputStream.NullOutputStream() in favor of org.apache.commons.io.output.NullOutputStream.NULL_OUTPUT_STREAM. Thanks to Gary Gregory. 
+o IO-629:  FileUtils#forceDelete should use Files#delete rather than File#delete so exception messages includes reason for failure. Thanks to Ian Springer, Ian Springer, Gary Gregory. 
+o IO-634:  Make getCause synchronized and use a Deque instead of a Stack #64. Thanks to Václav Haisman, Bruno P. Kinoshita, Gary Gregory. 
+o            Update tests from Apache Commons Lang 3.9 to 3.10. Thanks to Gary Gregory. 
+o            Update tests org.junit-pioneer:junit-pioneer 0.3.0 -> 0.6.0. Thanks to Gary Gregory. 
+o            Update tests org.junit.jupiter:junit-jupiter 5.5.2 -> 5.6.2. Thanks to Gary Gregory. 
+o            Update tests org.mockito:mockito-core 3.0.0 -> 3.3.3. Thanks to Gary Gregory. 
+o IO-666:  Normalize internal buffers to 8192 bytes. Thanks to Gary Gregory. 
+o IO-665:  Ensure that passing a null InputStream results in NPE with tests #112. Thanks to Otto Fowler, Gary Gregory. 
+o            commons.jacoco.version 0.8.4 -> 0.8.5. Thanks to Gary Gregory. 
+o            com.github.siom79.japicmp:japicmp-maven-plugin 0.14.1 -> 0.14.3. Thanks to Gary Gregory. 
+o IO-667:  Add functional interfaces IOFunction and IOSupplier #110. Thanks to Adam Retter, Gary Gregory. 
+o            Support sub sequences in CharSequenceReader #91. Thanks to Rob Spoor, Gary Gregory. 
+o            Remove deprecated sudo setting. #113. Thanks to dengliming. 
+
+Compatibility with 2.6:
+Binary compatible: Yes.
+Source compatible: Yes.
+Semantic compatible: Yes.
+
+Commons IO 2.7 requires Java 8.
+Commons IO 2.6 requires Java 7.
+Commons IO 2.5 requires Java 6.
+Commons IO 2.4 requires Java 6.
+Commons IO 2.3 requires Java 6.
+Commons IO 2.2 requires Java 5.
+Commons IO 1.4 requires Java 1.3.
+
+Historical list of changes: https://commons.apache.org/proper/commons-io/changes-report.html
+
+For complete information on Apache Commons IO, including instructions on how to submit bug reports,
+patches, or suggestions for improvement, see the Apache Commons IO website:
+
+https://commons.apache.org/proper/commons-io/
+
+Download page: https://commons.apache.org/proper/commons-io/download_io.cgi
+
+Have fun!
+-Apache Commons Team
+
+==============================================================================
+Apache Commons IO Version 2.6
+==============================================================================
+
+INTRODUCTION:
+
+Apache Commons IO is a package of Java utility classes like java.io.
+Classes in this package are considered to be so standard and of such high
+reuse as to justify existence in java.io.
+
+The Apache Commons IO library contains utility classes, stream implementations,
+file filters, file comparators, endian transformation classes, and much more.
+
+Apache Commons IO 2.6 requires at least Java 7 to build and run.
+
+
+DEPRECATIONS
+============
+
+All closeQuietly overloads in org.apache.commons.io.IOUtils have been
+deprecated. Use the try-with-resources statement or handle suppressed
+exceptions manually.
+
+The class org.apache.commons.io.FileSystemUtils has been deprecated.
+Use equivalent methods in java.nio.file.FileStore instead, e.g.
+Files.getFileStore(Paths.get("/home")).getUsableSpace() or iterate over
+FileSystems.getDefault().getFileStores().
+
+
+COMPATIBILITY WITH JAVA 9
+==================
+
+The MANIFEST.MF now contains an additional entry:
+
+  Automatic-Module-Name: org.apache.commons.io
+
+This should make it possible to use Commons IO 2.6 as a module in the Java 9
+module system. For more information see the corresponding issue:
+
+    https://issues.apache.org/jira/browse/IO-551
+
+Building Commons IO 2.6 should work out of the box with the latest Java 9
+release. Please report any Java 9 related issues at:
+
+    https://issues.apache.org/jira/browse/IO
+
+
+NEW FEATURES
+============
+
+o IO-551: Add Automatic-Module-Name MANIFEST entry for Java 9 compatibility.
+o IO-367: Add convenience methods for copyToDirectory. Thanks to James Sawle.
+o IO-493: Add infinite circular input stream. Thanks to Piotr Turski.
+o IO-507: Add a ByteOrderUtils class.
+o IO-518: Add ObservableInputStream.
+o IO-519: Add MessageDigestCalculatingInputStream.
+o IO-513: Add convenience methods for reading class path resources.
+          Thanks to Behrang Saeedzadeh.
+
+FIXED BUGS
+==========
+
+o IO-546: ClosedOutputStream#flush should throw. Thanks to Tomas Celaya.
+o IO-550: Documentation issue, fix 404 Javadoc issues in the description page.
+          Thanks to Jimi Adrian.
+o IO-442: Javadoc contradictory for FileFilterUtils.ageFileFilter(cutoff) and
+          the filter it constructs: AgeFileFilter(cutoff).
+          Thanks to Simon Robinson.
+o IO-534: FileUtilTestCase.testForceDeleteDir() should not delete testDirectory
+          parent.
+o IO-528: Fix Tailer.run race condition runaway logging. Thanks to Dave Moten.
+o IO-483: getPrefixLength return -1 if Unix file contains colon.
+          Thanks to Marko Vasic.
+o IO-520: FileUtilsTestCase#testContentEqualsIgnoreEOL fails on Windows.
+o IO-516: .gitattributes not correctly applied. Thanks to Jason Pyeron.
+o IO-515: Allow Specifying Initial Buffer Size of DeferredFileOutputStream.
+          Thanks to Brett Lounsbury, Gary Gregory.
+o IO-512: ThresholdingOutputStream.thresholdReached() results in
+          FileNotFoundException. Thanks to Ralf Hauser.
+o IO-511: After a few unit tests, a few newly created directories not cleaned
+          completely. Thanks to Ahmet Celik.
+o IO-502: Exceptions are suppressed incorrectly when copying files.
+          Thanks to Christian Schulte.
+o IO-503: Update platform requirement to Java 7.
+o IO-537: BOMInputStream shouldn't sort array of BOMs in-place.
+          Thanks to Borys Zibrov.
+
+CHANGES
+=======
+
+o IO-553: Make code style of hasBOM() consistent with getBOMCharsetName().
+          Thanks to Michael Ernst.
+o IO-542: FileUtils#readFileToByteArray: optimize reading of files with known
+          size. Thanks to Ilmars Poikans.
+o IO-547: Throw a IllegalArgumentException instead of NullPointerException in
+          FileSystemUtils.freeSpaceWindows(). Thanks to Nikhil Shinde,
+          Michael Ernst, Gary Greory.
+o IO-506: Deprecate methods FileSystemUtils.freeSpaceKb().
+          Thanks to Christian Schulte.
+o IO-505: Make LineIterator implement Closeable to support try-with-resources
+          statements. Thanks to Christian Schulte.
+o IO-504: Deprecated of all IOUtils.closeQuietly() methods and use
+          try-with-resources internally. Thanks to Christian Schulte.
+
+REMOVED
+=======
+
+o IO-514: Remove org.apache.commons.io.Java7Support.
+
+COMPATIBILITY WITH OLDER VERSIONS
+=================================
+
+Compatibility with 2.5:
+Binary compatible: Yes.
+Source compatible: Yes.
+Semantic compatible: Yes.
+
+Compatibility with 2.6 and 1.4:
+Binary compatible: Yes.
+Source compatible: No, see the rare case in
+  https://issues.apache.org/jira/browse/IO-318.
+Semantic compatible: No, see the rare case in
+  https://issues.apache.org/jira/browse/IO-318.
+
+Commons IO 2.6 requires Java 7 or later.
+Commons IO 2.5 requires Java 6 or later.
+Commons IO 2.4 requires Java 6 or later.
+Commons IO 2.3 requires Java 6 or later.
+Commons IO 2.2 requires Java 5 or later.
+Commons IO 1.4 requires Java 1.3 or later.
+
+==============================================================================
+Apache Commons IO Version 2.5
+==============================================================================
+New features and bug fixes.
+
+Changes in this version include:
+
+New features:
+o IO-487:  Add ValidatingObjectInputStream for controlled deserialization 
+o IO-471:  Support for additional encodings in ReversedLinesFileReader Thanks to Leandro Reis. 
+o IO-425:  Setter method for threshold on ThresholdingOutputStream Thanks to Craig Swank. 
+o IO-406:  Introduce new class AppendableOutputStream Thanks to Niall Pemberton. 
+o IO-459:  Add WindowsLineEndingInputStream and UnixLineEndingInputStream. Thanks to Kristian Rosenvold. 
+o IO-457:  Add a BoundedReader, a wrapper that can be used to constrain access
+        to an underlying stream when used with mark/reset -
+        to avoid overflowing the mark limit of the underlying buffer. Thanks to Kristian Rosenvold. 
+o IO-426:  Add API IOUtils.closeQuietly(Closeable...) 
+o IO-410:  Readfully() That Returns A Byte Array Thanks to Beluga Behr. 
+o IO-395:  Overload IOUtils buffer methods to accept buffer size Thanks to Beluga Behr. 
+o IO-382:  Chunked IO for large arrays.
+         Added writeChunked(byte[], OutputStream) and writeChunked(char[] Writer)
+         Added ChunkedOutputStream, ChunkedWriter 
+o IO-233:  Add Methods for Buffering Streams/Writers To IOUtils
+         Added overloaded buffer() methods - see also IO-330 
+o IO-330:  IOUtils#toBufferedOutputStream/toBufferedWriter to conditionally wrap the output
+         Added overloaded buffer() methods - see also IO-233 
+o IO-381:  Add FileUtils.copyInputStreamToFile API with option to leave the source open.
+        See copyInputStreamToFile(final InputStream source, final File destination, boolean closeSource) 
+o IO-379:  CharSequenceInputStream - add tests for available()
+         Fix code so it really does reflect a minimum available. 
+o IO-346:  Add ByteArrayOutputStream.toInputStream() 
+o IO-341:  A constant for holding the BOM character (U+FEFF) 
+o IO-361:  Add API FileUtils.forceMkdirsParent(). 
+o IO-360:  Add API Charsets.requiredCharsets(). 
+o IO-359:  Add IOUtils.skip and skipFully(ReadableByteChannel, long). Thanks to yukoba. 
+o IO-358:  Add IOUtils.read and readFully(ReadableByteChannel, ByteBuffer buffer). Thanks to yukoba. 
+o IO-353:  Add API IOUtils.copy(InputStream, OutputStream, int) Thanks to ggregory. 
+o IO-349:  Add API with array offset and length argument to FileUtils.writeByteArrayToFile. Thanks to scop. 
+o IO-348:  Missing information in IllegalArgumentException thrown by org.apache.commons.io.FileUtils#validateListFilesParameters. Thanks to plcstpierre. 
+o IO-345:  Supply a hook method allowing Tailer actively determining stop condition. Thanks to mkresse. 
+o IO-437:  Make IOUtils.EOF public and reuse it in various classes. 
+
+Fixed Bugs:
+o IO-446:  adds an endOfFileReached method to the TailerListener Thanks to Jeffrey Barrus. 
+o IO-484:  FilenameUtils should handle embedded null bytes Thanks to Philippe Arteau. 
+o IO-481:  Changed/Corrected algorithm for waitFor 
+o IO-428:  BOMInputStream.skip returns wrong count if stream contains no BOM Thanks to Stefan Gmeiner. 
+o IO-488:  FileUtils.waitFor(...) swallows thread interrupted status Thanks to Björn Buchner. 
+o IO-452:  Support for symlinks with missing target. Added support for JDK7 symlink features when present Thanks to David Standish. 
+o IO-453:  Regression in FileUtils.readFileToString from 2.0.1 Thanks to Steven Christou. 
+o IO-451:  ant test fails - resources missing from test classpath Thanks to David Standish. 
+o IO-435:  Document that FileUtils.deleteDirectory, directoryContains and cleanDirectory
+         may throw an IllegalArgumentException in case the passed directory does not
+         exist or is not a directory. Thanks to Dominik Stadler. 
+o IO-424:  Javadoc fixes, mostly to appease 1.8.0 Thanks to Ville Skyttä. 
+o IO-389:  FileUtils.sizeOfDirectory can throw IllegalArgumentException Thanks to Austin Doupnik. 
+o IO-390:  FileUtils.sizeOfDirectoryAsBigInteger can overflow.
+         Ensure that recursive calls all use BigInteger 
+o IO-385:  FileUtils.doCopyFile can potentially loop for ever
+         Exit loop if no data to copy 
+o IO-383:  FileUtils.doCopyFile caches the file size; needs to be documented
+         Added Javadoc; show file lengths in exception message 
+o IO-380:  FileUtils.copyInputStreamToFile should document it closes the input source Thanks to claudio_ch. 
+o IO-279:  Tailer erroneously considers file as new.
+        Fix to use file.lastModified() rather than System.currentTimeMillis() 
+o IO-356:  CharSequenceInputStream#reset() behaves incorrectly in case when buffer size is not dividable by data size.
+         Fix code so skip relates to the encoded bytes; reset now re-encodes the data up to the point of the mark 
+o IO-368:  ClassLoaderObjectInputStream does not handle primitive typed members 
+o IO-314:  Deprecate all methods that use the default encoding 
+o IO-338:  When a file is rotated, finish reading previous file prior to starting new one 
+o IO-354:  Commons IO Tailer does not respect UTF-8 Charset. 
+o IO-323:  What should happen in FileUtils.sizeOf[Directory] when an overflow takes place?
+        Added Javadoc. 
+o IO-372:  FileUtils.moveDirectory can produce misleading error message on failiure 
+o IO-362:  IOUtils.contentEquals* methods returns false if input1 == input2, should return true. Thanks to mmadson, ggregory. 
+o IO-357:  [Tailer] InterruptedException while the thread is sleeping is silently ignored Thanks to mortenh. 
+o IO-352:  Spelling fixes. Thanks to scop. 
+o IO-436:  Improper Javadoc comment for FilenameUtils.indexOfExtension. Thanks to christoph.schneegans. 
+
+Changes:
+o IO-433:  Converted all testcases to JUnit 4 
+o IO-466:  Added testcase to show this was fixed with IO-423 
+o IO-479:  Correct exception message in FileUtils.getFile(File, String...) Thanks to Zhouce Chen. 
+o IO-465:  Update to JUnit 4.12 Thanks to based2. 
+o IO-462:  IOExceptionWithCause no longer needed 
+o IO-422:  Deprecate Charsets Charset constants in favor of Java 7's java.nio.charset.StandardCharsets 
+o IO-239:  Convert IOCase to a Java 1.5+ Enumeration
+         [N.B. this is binary compatible] 
+o IO-328:  getPrefixLength returns null if filename has leading slashes
+        Javadoc: add examples to show correct behavior; add unit tests 
+o IO-299:  FileUtils.listFilesAndDirs includes original dir in results even when it doesn't match filter
+        Javadoc: clarify that original dir is included in the results 
+o IO-375:  FilenameUtils.splitOnTokens(String text) check for '**' could be simplified 
+o IO-374:  WildcardFileFilter ctors should not use null to mean IOCase.SENSITIVE when delegating to other ctors 
+
+Compatibility with 2.4:
+Binary compatible: Yes.
+Source compatible: Yes.
+Semantic compatible: Yes.
+
+Compatibility with 2.2 and 1.4:
+Binary compatible: Yes.
+Source compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+Semantic compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+
+Commons IO 2.5 requires Java 6 or later.
+Commons IO 2.4 requires Java 6 or later.
+Commons IO 2.3 requires Java 6 or later.
+Commons IO 2.2 requires Java 5 or later.
+Commons IO 1.4 requires Java 1.3 or later.
+
+==============================================================================
+Apache Commons IO Version 2.4
+==============================================================================
+
+Changes in this version include:
+
+New features:
+o IO-269:  Tailer locks file from deletion/rename on Windows. Thanks to
+sebb.
+o IO-333:  Export OSGi packages at version 1.x in addition to 2.x. Thanks
+to fmeschbe.
+o IO-320:  Add XmlStreamReader support for UTF-32. Thanks to ggregory.
+o IO-331:  BOMInputStream wrongly detects UTF-32LE_BOM files as
+UTF-16LE_BOM files in method getBOM(). Thanks to ggregory.
+o IO-327:  Add byteCountToDisplaySize(BigInteger). Thanks to ggregory.
+o IO-326:  Add new FileUtils.sizeOf[Directory] APIs to return BigInteger.
+Thanks to ggregory.
+o IO-325:  Add IOUtils.toByteArray methods to work with URL and URI. Thanks
+to raviprak.
+o IO-324:  Add missing Charset sister APIs to method that take a String
+charset name. Thanks to raviprak.
+
+Fixed Bugs:
+o IO-336:  Yottabyte (YB) incorrectly defined in FileUtils. Thanks to
+rleavelle.
+o IO-279:  Tailer erroneously considers file as new. Thanks to Sergio
+Bossa, Chris Baron.
+o IO-335:  Tailer#readLines - incorrect CR handling.
+o IO-334:  FileUtils.toURLs throws NPE for null parameter; document the
+behavior.
+o IO-332:  Improve tailer's reading performance. Thanks to liangly.
+o IO-279:  Improve Tailer performance with buffered reads (see IO-332).
+o IO-329:  FileUtils.writeLines uses unbuffered IO. Thanks to tivv.
+o IO-319:  FileUtils.sizeOfDirectory follows symbolic links. Thanks to
+raviprak.
+
+
+Compatibility with 2.3:
+Binary compatible: Yes.
+Source compatible: Yes.
+Semantic compatible: Yes.
+
+Compatibility with 2.2 and 1.4:
+Binary compatible: Yes.
+Source compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+Semantic compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+
+Commons IO 2.4 requires Java 6 or later.
+Commons IO 2.3 requires Java 6 or later.
+Commons IO 2.2 requires Java 5 or later.
+Commons IO 1.4 requires Java 1.3 or later.
+
+==============================================================================
+Apache Commons IO Version 2.3
+==============================================================================
+
+Changes in this version include:
+
+New features:
+o IO-322:  Add and use class Charsets. Thanks to ggregory. 
+o IO-321:  ByteOrderMark UTF_32LE is incorrect. Thanks to ggregory. 
+o IO-318:  Add Charset sister APIs to method that take a String charset name. Thanks to ggregory. 
+
+Compatibility with 2.2 and 1.4:
+Binary compatible: Yes.
+Source compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+Semantic compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+
+Commons IO 2.3 requires Java 6 or later.
+Commons IO 2.2 requires Java 5 or later.
+Commons IO 1.4 requires Java 1.3 or later.
+
+==============================================================================
+Apache Commons IO Version 2.2
+==============================================================================
+
+Changes in this version include:
+
+New features:
+o Add IOUTils.toBufferedReader(Reader)  Issue: IO-313. Thanks to ggregory. 
+o Allow applications to provide buffer (or size) for copyLarge methods.  Issue: IO-308. Thanks to Manoj Mokashi. 
+o New copyLarge() method in IOUtils that takes additional offset, length arguments  Issue: IO-305. Thanks to Manoj Mokashi. 
+o Use terabyte (TB), petabyte (PB) and exabyte (EB) in FileUtils.byteCountToDisplaySize(long size)  Issue: IO-287. Thanks to Ron Kuris, Gary Gregory. 
+o FileUtils.listFiles() doesn't return directories  Issue: IO-173. Thanks to Marcos Vinícius da Silva. 
+o CharSequenceInputStream to efficiently stream content of a CharSequence  Issue: IO-297. Thanks to Oleg Kalnichevski. 
+o The second constructor of Tailer class does not pass 'delay' to the third one  Issue: IO-304. Thanks to liangly. 
+o TeeOutputStream does not call branch.close() when main.close() throws an exception  Issue: IO-303. Thanks to fabian.barney. 
+o ArrayIndexOutOfBoundsException in BOMInputStream when reading a file without BOM multiple times  Issue: IO-302. Thanks to jsteuerwald, detinho. 
+o Add IOUtils.closeQuietly(Selector) necessary  Issue: IO-301. Thanks to kaykay.unique. 
+o IOUtils.closeQuietly() should take a ServerSocket as a parameter  Issue: IO-292. Thanks to sebb. 
+o Add read/readFully methods to IOUtils  Issue: IO-290. Thanks to sebb. 
+o Supply a ReversedLinesFileReader  Issue: IO-288. Thanks to Georg Henzler. 
+o Add new function FileUtils.directoryContains.  Issue: IO-291. Thanks to ggregory. 
+o FileUtils.contentEquals and IOUtils.contentEquals - Add option to ignore "line endings"
+        Added contentEqualsIgnoreEOL methods to both classes  Issue: IO-275. Thanks to CJ Aspromgos. 
+
+Fixed Bugs:
+o IOUtils.read(InputStream/Reader) ignores the offset parameter  Issue: IO-311. Thanks to Robert Muir. 
+o CharSequenceInputStream(CharSequence s, Charset charset, int bufferSize) ignores bufferSize  Issue: IO-312. 
+o FileUtils.moveDirectoryToDirectory removes source directory if destination is a subdirectory  Issue: IO-300. 
+o ReaderInputStream#read(byte[] b, int off, int len) should check for valid parameters  Issue: IO-307. 
+o ReaderInputStream#read(byte[] b, int off, int len) should always return 0 for length == 0  Issue: IO-306. 
+o "FileUtils#deleteDirectoryOnExit(File)" does not work  Issue: IO-276. Thanks to nkami. 
+o BoundedInputStream.read() treats max differently from BoundedInputStream.read(byte[]...)  Issue: IO-273. Thanks to sebb. 
+o Various methods of class 'org.apache.commons.io.FileUtils' incorrectly suppress 'java.io.IOException'  Issue: IO-298. Thanks to Christian Schulte. 
+
+Changes:
+o ReaderInputStream optimization: more efficient reading of small chunks of data  Issue: IO-296. Thanks to Oleg Kalnichevski. 
+
+
+Compatibility with 2.1 and 1.4:
+Binary compatible: Yes
+Source compatible: Yes
+Semantic compatible: Yes. Check the bug fixes section for semantic bug fixes
+
+Commons IO 2.2 requires a minimum of Java 5. 
+Commons IO 1.4 requires a minimum of Java 1.3. 
+
+==============================================================================
+Apache Commons IO Version 2.1
+==============================================================================
+
+New features:
+o Use standard Maven directory layout  Issue: IO-285. Thanks to ggregory. 
+o Add IOUtils API toString for URL and URI to get contents  Issue: IO-284. Thanks to ggregory. 
+o Add API FileUtils.copyFile(File input, OutputStream output)  Issue: IO-282. Thanks to ggregory. 
+o FileAlterationObserver has no getter for FileFilter  Issue: IO-262. 
+o Add FileUtils.getFile API with varargs parameter  Issue: IO-261. 
+o Add new APPEND parameter for writing string into files  Issue: IO-182. 
+o Add new read method "toByteArray" to handle InputStream with known size.  Issue: IO-251. Thanks to Marco Albini. 
+
+Fixed Bugs:
+o Dubious use of mkdirs() return code  Issue: IO-280. Thanks to sebb. 
+o ReaderInputStream enters infinite loop when it encounters an unmappable character  Issue: IO-277. 
+o FileUtils.moveFile() Javadoc should specify FileExistsException thrown  Issue: IO-264. 
+o ClassLoaderObjectInputStream does not handle Proxy classes  Issue: IO-260. 
+o Tailer returning partial lines when reaching EOF before EOL  Issue: IO-274. Thanks to Frank Grimes. 
+o FileUtils.copyFile() throws IOException when copying large files to a shared directory (on Windows)  Issue: IO-266. Thanks to Igor Smereka. 
+o FileSystemUtils.freeSpaceKb throws exception for Windows volumes with no visible files.
+        Improve coverage by also looking for hidden files.  Issue: IO-263. Thanks to Gil Adam. 
+
+Changes:
+o FileAlterationMonitor.stop(boolean allowIntervalToFinish)  Issue: IO-259. 
+
+==============================================================================
+Apache Commons IO Package 2.0.1
+==============================================================================
+
+Compatibility with 2.0 and 1.4
+------------------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+  Check the bug fixes section for semantic bug fixes
+
+Commons IO 2.0.1 requires a minimum of Java 5
+ (Commons IO 1.4 had a minimum of Java 1.3) 
+
+Enhancements from 2.0
+---------------------
+
+   * [IO-256] - Provide thread factory for FileAlternationMonitor
+
+Bug fixes from 2.0
+------------------
+
+   * [IO-257] - BOMInputStream.read(byte[]) can return 0 which it should not
+   * [IO-258] - XmlStreamReader consumes the stream during encoding detection
+
+==============================================================================
+Apache Commons IO Package 2.0
+==============================================================================
+
+Compatibility with 1.4
+----------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+  Check the bug fixes section for semantic bug fixes
+
+Commons IO 2.0 requires a minimum of Java 5
+ (Commons IO 1.4 had a minimum of Java 1.3) 
+
+Deprecations from 1.4
+---------------------
+
+- IOUtils
+  - write(StringBuffer, Writer) in favour of write(CharSequence, Writer)
+  - write(StringBuffer, OutputStream)  in favour of write(CharSequence, OutputStream)
+  - write(StringBuffer, OutputStream, String) in favour of write(CharSequence, OutputStream, String)
+
+- FileFilterUtils
+  - andFileFilter(IOFileFilter, IOFileFilter) in favour of and(IOFileFilter...) 
+  - orFileFilter(IOFileFilter, IOFileFilter)  in favour of or(IOFileFilter...)
+
+Enhancements from 1.4
+---------------------
+
+  * [IO-140] Move minimum Java requirement from Java 1.3 to Java 5
+             - use Generics
+             - add new CharSequence write() flavour methods to IOUtils and FileUtils
+             - replace StringBuffer with StringBuilder, where appropriate
+             - add new Reader/Writer methods to ProxyReader and ProxyWriter
+             - Annotate with @Override and @Deprecated
+
+  * [IO-178] New BOMInputStream and ByteOrderMark implementations - to detect and optionally exclude an initial Byte Order mark (BOM)
+  * [IO-197] New BoundedInputStream (copied from Apache JackRabbit)
+  * [IO-193] New Broken Input and Output streams
+  * [IO-132] New File Listener/Monitor facility
+  * [IO-158] New ReaderInputStream and WriterOutputStream implementations
+  * [IO-139] New StringBuilder Writer implementation
+  * [IO-192] New Tagged Input and Output streams
+  * [IO-177] New Tailer class - simple implementation of the Unix "tail -f" functionality
+  * [IO-162] New XML Stream Reader/Writer implementations (from ROME via plexus-utils)
+
+  * [IO-142] Comparators - add facility to sort file lists/arrays
+  * [IO-186] Comparators - new Composite and Directory File Comparator implementations
+  * [IO-176] DirectoryWalker - add filterDirectoryContents() callback method for filtering directory contents
+  * [IO-210] FileFilter - new Magic Number FileFilter
+  * [IO-221] FileFilterUtils - add methods for suffix and prefix filters which take an IOCase object
+  * [IO-232] FileFilterUtils - add method for name filters which take an IOCase object
+  * [IO-229] FileFilterUtils - add varargs and() and or() methods
+  * [IO-198] FileFilterUtils - add ability to apply file filters to collections and arrays
+  * [IO-156] FilenameUtils - add normalize() and normalizeNoEndSeparator() methods which allow the separator character to be specified
+  * [IO-194] FileSystemUtils - add freeSpaceKb() method with no input arguments
+  * [IO-185] FileSystemUtils - add freeSpaceKb() methods that take a timeout parameter - fixes freeSpaceWindows() blocks
+  * [IO-155] FileUtils - use NIO to copy files
+  * [IO-168] FileUtils - add new isSymlink() method
+  * [IO-219] FileUtils - throw FileExistsException when moving a file or directory if the destination already exists
+  * [IO-234] FileUtils - add Methods for retrieving System User/Temp directories/paths
+  * [IO-208] FileUtils - add timeout (connection and read) support for copyURLToFile() method 
+  * [IO-238] FileUtils - add sizeOf(File) method
+  * [IO-181] LineIterator now implements Iterable
+  * [IO-224] IOUtils - add closeQuietly(Closeable) and closeQuietly(Socket) methods
+  * [IO-203] IOUtils - add skipFully() method for InputStreams
+  * [IO-137] IOUtils and ByteArrayOutputStream - add toBufferedInputStream() method to avoid unnecessary array allocation/copy
+  * [IO-195] Proxy streams/Reader/Writer - provide exception handling methods
+  * [IO-211] Proxy Input/Output streams - add pre/post processing support
+  * [IO-242] Proxy Reader/Writer - add pre/post processing support
+
+Bug fixes from 1.4
+------------------
+  * [IO-214] ByteArrayOutputStream - fix inconsistent synchronization of fields
+  * [IO-201] Counting Input/Output streams - fix inconsistent synchronization
+  * [IO-159] FileCleaningTracker - fix remove() never returns null
+  * [IO-220] FileCleaningTracker - fix Vector performs badly under load
+  * [IO-167] FilenameUtils - fix case-insensitive string handling in FilenameUtils and FilesystemUtils
+  * [IO-179] FilenameUtils - fix StringIndexOutOfBounds exception in getPathNoEndSeparator()
+  * [IO-248] FilenameUtils - fix getFullPathNoEndSeparator() returns empty while path is a one level directory
+  * [IO-246] FilenameUtils - fix wildcardMatch gives incorrect results 
+  * [IO-187] FileSystemUtils - fix freeSpaceKb() doesn't work with relative paths on Linux
+  * [IO-160] FileSystemUtils - fix freeSpace() fails on solaris
+  * [IO-209] FileSystemUtils - fix freeSpaceKb() fails to return correct size for a windows mount point
+  * [IO-163] FileUtils - fix toURLs() using deprecated method of conversion to URL
+  * [IO-168] FileUtils - fix Symbolic links followed when deleting directory
+  * [IO-231] FileUtils - fix wrong exception message generated in isFileNewer() method
+  * [IO-207] FileUtils - fix race condition in forceMkdir() method
+  * [IO-217] FileUtils - fix copyDirectoryToDirectory() makes infinite loops
+  * [IO-166] FileUtils - fix URL decoding in toFile(URL)
+  * [IO-190] FileUtils - fix copyDirectory not preserving lastmodified date on sub-directories
+  * [IO-240] FileFilterUtils - ensure cvsFilter and svnFilter are only created once.
+  * [IO-175] IOUtils - fix copyFile() issues with very large files
+  * [IO-191] Improvements from static analysis
+  * [IO-216] LockableFileWriter - delete files quietly when an exception is thrown during initialization
+  * [IO-243] SwappedDataInputStream - fix readBoolean is inverted
+  * [IO-235] Tests - remove unused YellOnFlushAndCloseOutputStream from CopyUtilsTest
+  * [IO-161] Tests - fix FileCleaningTrackerTestCase hanging
+
+Documentation changes from 1.4
+------------------------------
+  * [IO-183 FilenameUtils.getExtension() method documentation improvements
+  * [IO-226 FileUtils.byteCountToDisplaySize() documentation corrections
+  * [IO-205 FileUtils.forceMkdir() documentation improvements
+  * [IO-215 FileUtils copy file/directory improve documentation regarding preserving the last modified date
+  * [IO-189 HexDump.dump() method documentation improvements
+  * [IO-171 IOCase document that it assumes there are only two OSes: Windows and Unix
+  * [IO-223 IOUtils.copy() documentation corrections
+  * [IO-247 IOUtils.closeQuietly() improve documentation with examples
+  * [IO-202 NotFileFilter documentation corrections
+  * [IO-206 ProxyInputStream - fix misleading parameter names
+  * [IO-212 ProxyInputStream.skip() documentation corrections
+
+==============================================================================
+Apache Commons IO Version 1.4
+==============================================================================
+
+Compatibility with 1.3.2
+------------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+  Check the bug fixes section for semantic bug fixes
+
+Commons IO 1.4 introduces four new implementations which depend on Java 4 features
+(CharSequenceReader, FileWriterWithEncoding, IOExceptionWithCause and RegexFileFilter).
+It has been built with the JDK source and target options set to Java 1.3 and, except for
+those implementations, can be used with Java 1.3 (see IO-127).
+
+Deprecations from 1.3.2
+-----------------------
+- FileCleaner deprecated in favour of FileCleaningTracker [see IO-116]
+
+Bug fixes from 1.3.2
+--------------------
+- FileUtils
+  - forceDelete of orphaned Softlinks does not work [IO-147]
+  - Infinite loop on FileUtils.copyDirectory when the destination directory is within
+    the source directory [IO-141]
+  - Add a copyDirectory() method that makes use of FileFilter [IO-105]
+  - Add moveDirectory() and moveFile() methods [IO-77]
+
+- HexDump
+  - HexDump's use of static StringBuffers isn't thread-safe [IO-136]
+
+Enhancements from 1.3.2
+-----------------------
+- FileUtils
+  - Add a deleteQuietly method [IO-135]
+
+- FilenameUtils
+  - Add file name extension separator constants[IO-149]
+
+- IOExceptionWithCause [IO-148]
+  - Add a new IOException implementation with constructors which take a cause
+
+- TeeInputStream [IO-129]
+  - Add new Tee input stream implementation
+
+- FileWriterWithEncoding [IO-153]
+  - Add new File Writer implementation that accepts an encoding
+
+- CharSequenceReader [IO-138]
+  - Add new Reader implementation that handles any CharSequence (String,
+    StringBuffer, StringBuilder or CharBuffer) 
+
+- ThesholdingOuputStream [IO-121]
+  - Add a reset() method which sets the count of the bytes written back to zero.
+
+- DeferredFileOutputStream [IO-130]
+  - Add support for temporary files
+
+- ByteArrayOutputStream
+  - Add a new write(InputStream) method [IO-152]
+
+- New Closed Input/Output stream implementations [IO-122]
+  - AutoCloseInputStream - automatically closes and discards the underlying input stream
+  - ClosedInputStream - returns -1 for any read attempts
+  - ClosedOutputStream - throws an IOException for any write attempts
+  - CloseShieldInputStream - prevents the underlying input stream from being closed.
+  - CloseShieldOutputStream - prevents the underlying output stream from being closed.
+
+- Add Singleton Constants to several stream classes [IO-143]
+
+- PrefixFileFilter [IO-126]
+  - Add faciltiy to specify case sensitivity on prefix matching
+
+- SuffixFileFilter [IO-126]
+  - Add faciltiy to specify case sensitivity on suffix matching
+
+- RegexFileFilter [IO-74]
+  - Add new regular expression file filter implementation
+
+- Make IOFileFilter implementations Serializable [IO-131]
+
+- Improve IOFileFilter toString() methods [IO-120]
+
+- Make fields final so classes are immutable/threadsafe [IO-133]
+  - changes to Age, Delegate, Name, Not, Prefix, Regex, Size, Suffix and Wildcard IOFileFilter
+    implementations.
+
+- IOCase
+  - Add a compare method to IOCase [IO-144]
+
+- Add a package of java.util.Comparator implementations for files [IO-145]
+  - DefaultFileComparator - compare files using the default File.compareTo(File) method.
+  - ExtensionFileComparator - compares files using file name extensions.
+  - LastModifiedFileComparator - compares files using the last modified date/time.
+  - NameFileComparator - compares files using file names.
+  - PathFileComparator - compares files using file paths.
+  - SizeFileComparator - compares files using file sizes.
+  
+==============================================================================
+Apache Commons IO Version 1.3.2
+==============================================================================
+
+Compatibility with 1.3.1
+----------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+
+Compatibility with 1.3
+----------------------
+Binary compatible - No
+  See [IO-113]
+
+Source compatible - No
+  See [IO-113]
+
+Semantic compatible - Yes
+
+Enhancements since 1.3.1
+------------------------
+
+- Created the FileCleaningTracker, basically a non-static version of the
+  FileCleaner, which can be controlled by the user. [IO-116]
+- The FileCleaner is deprecated.
+
+Bug fixes from 1.3.1
+--------------------
+
+- Some tests, which are implicitly assuming a Unix-like file system, are
+  now skipped on Windows. [IO-115]
+- EndianUtils
+  - Both readSwappedUnsignedInteger(...) methods could return negative 
+    numbers due to int/long casting. [IO-117]
+
+Bug fixes from 1.3
+------------------
+
+- FileUtils
+  - NPE in openOutputStream(File) when file has no parent in path [IO-112]
+  - readFileToString(File) is not static [IO-113]
+
+==============================================================================
+Apache Commons IO Version 1.3.1
+==============================================================================
+
+Compatibility with 1.3
+----------------------
+Binary compatible - No
+  See [IO-113]
+
+Source compatible - No
+  See [IO-113]
+
+Semantic compatible - Yes
+
+Bug fixes from 1.3
+------------------
+
+- FileUtils
+  - NPE in openOutputStream(File) when file has no parent in path [IO-112]
+  - readFileToString(File) is not static [IO-113]
+  
+==============================================================================
+Apache Commons IO Version 1.3
+==============================================================================
+
+Compatibility with 1.2
+----------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+  Check the bug fixes section for semantic bug fixes
+
+Deprecations from 1.2
+---------------------
+- WildcardFilter deprecated, replaced by WildcardFileFilter
+  - old class only accepted files, thus had a confusing dual purpose
+
+- FileSystemUtils.freeSpace deprecated, replaced by freeSpaceKb
+  - freeSpace returns a result that varies by operating system and
+    thus isn't that useful
+  - freeSpaceKb returns much better and more consistent results
+  - freeSpaceKb existed in v1.2, so this is a gentle cutover
+
+Bug fixes from 1.2
+------------------
+- LineIterator now implements Iterator
+  - It was always supposed to...
+
+- FileSystemUtils.freeSpace/freeSpaceKb [IO-83]
+  - These should now work on AIX and HP-UX
+
+- FileSystemUtils.freeSpace/freeSpaceKb [IO-90]
+  - Avoid infinite looping in Windows
+  - Catch more errors with nice messages
+
+- FileSystemUtils.freeSpace [IO-91]
+  - This is now documented not to work on SunOS 5
+
+- FileSystemUtils [IO-93]
+  - Fixed resource leak leading to 'Too many open files' error
+  - Previously did not destroy Process instances (as JDK Javadoc is so poor)
+  - http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4801027
+
+- FileUtils.touch [IO-100]
+  - The touch method previously gave no indication when the file could not
+    be touched successfully (such as due to access restrictions) - it now
+    throws an IOException if the last modified date cannot be changed
+
+- FileCleaner
+  - This now handles the situation where an error occurs when deleting the file
+
+- IOUtils.copy [IO-84]
+  - Copy methods could return inaccurate byte/char count for large streams
+  - The copy(InputStream, OutputStream) method now returns -1 if the count is greater than an int
+  - The copy(Reader, Writer) method now throws now returns -1 if the count is greater than an int
+  - Added a new copyLarge(InputStream, OutputStream) method that returns a long
+  - Added a new copyLarge(Reader, Writer) method that returns a long
+
+- CountingInputStream/CountingOutputStream [IO-84]
+  - Methods were declared as int thus the count was innacurate for large streams
+  - new long based methods getByteCount()/resetByteCount() added
+  - existing methods changed to throw an exception if the count is greater than an int
+
+- FileBasedTestCase
+  - Fixed bug in compare content methods identified by GNU classpath
+
+- EndianUtils.writeSwappedLong(byte[], int) [IO-101]
+  - An int overrun in the bit shifting when it should have been a long
+
+- EndianUtils.writeSwappedLong(InputStream) [IO-102]
+  - The return of input.read(byte[]) was not being checked to ensure all 8 bytes were read
+
+Enhancements from 1.2
+---------------------
+- DirectoryWalker [IO-86]
+  - New class designed for subclassing to walk through a set of files.
+    DirectoryWalker provides the walk of the directories, filtering of
+    directories and files, and cancellation support. The subclass must provide
+    the specific behavior, such as text searching or image processing.
+
+- IOCase
+  - New class/enumeration for case-sensitivity control
+
+- FilenameUtils
+  - New methods to handle case-sensitivity
+  - wildcardMatch - new method that has IOCase as a parameter
+  - equals - new method that has IOCase as a parameter
+
+- FileUtils [IO-108] - new default encoding methods for:
+  - readFileToString(File)
+  - readLines(File)
+  - lineIterator(File)
+  - writeStringToFile(File, String)
+  - writeLines(File, Collection)
+  - writeLines(File, Collection, String)
+
+- FileUtils.openOutputStream  [IO-107]
+  - new method to open a FileOutputStream, creating parent directories if required
+- FileUtils.touch
+- FileUtils.copyURLToFile
+- FileUtils.writeStringToFile
+- FileUtils.writeByteArrayToFile
+- FileUtils.writeLines
+  - enhanced to create parent directories if required
+- FileUtils.openInputStream  [IO-107]
+  - new method to open a FileInputStream, providing better error messages than the JDK
+
+- FileUtils.isFileOlder
+  - new methods to check if a file is older (i.e. isFileOlder()) - counterparts
+    to the existing isFileNewer() methods.
+
+- FileUtils.checksum, FileUtils.checksumCRC32
+  - new methods to create a checksum of a file
+
+- FileUtils.copyFileToDirectory  [IO-104]
+  - new variant that optionally retains the file date
+
+- FileDeleteStrategy
+- FileCleaner    [IO-56,IO-70]
+  - FileDeleteStrategy is a strategy for handling file deletion
+  - This can be used as a calback in FileCleaner
+  - Together these allow FileCleaner to do a forceDelete to kill directories
+
+- FileCleaner.exitWhenFinished [IO-99]
+  - A new method that allows the internal cleaner thread to be cleanly terminated
+
+- WildcardFileFilter
+  - Replacement for WildcardFilter
+  - Accepts both files and directories
+  - Ability to control case-sensitivity
+
+- NameFileFilter
+  - Ability to control case-sensitivity
+
+- FileFileFilter
+  - New IOFileFilter implementation
+  - Accepts files where File.isFile() is true
+  - In other words it filters out directories
+  - Singleton instance provided (FILE)
+
+- CanReadFileFilter
+  - New IOFileFilter implementation
+  - Accepts files where File.canRead() is true
+  - Singleton instances provided (CAN_READ/CANNOT_READ/READ_ONLY)
+
+- CanWriteFileFilter
+  - New IOFileFilter implementation
+  - Accepts files where File.canWrite() is true
+  - Singleton instances provided (CAN_WRITE/CANNOT_WRITE)
+
+- HiddenFileFilter
+  - New IOFileFilter implementation
+  - Accepts files where File.isHidden() is true
+  - Singleton instances provided (HIDDEN/VISIBLE)
+
+- EmptyFileFilter
+  - New IOFileFilter implementation
+  - Accepts files or directories that are empty
+  - Singleton instances provided (EMPTY/NOT_EMPTY)
+
+- TrueFileFilter/FalseFileFilter/DirectoryFileFilter
+  - New singleton instance constants (TRUE/FALSE/DIRECTORY)
+  - The new constants are more Java 5 friendly with regards to static imports
+    (whereas if everything uses INSTANCE, then they just clash)
+  - The old INSTANCE constants are still present and have not been deprecated
+
+- FileFilterUtils.sizeRangeFileFilter
+  - new sizeRangeFileFilter(long minimumSize, long maximumSize) method which 
+    creates a filter that accepts files within the specified size range.
+
+- FileFilterUtils.makeDirectoryOnly/makeFileOnly
+  - two new methods that decorate a file filter to make it apply to
+    directories only or files only
+
+- NullWriter
+  - New writer that acts as a sink for all data, as per /dev/null
+
+- NullInputStream
+  - New input stream that emulates a stream of a specified size
+
+- NullReader
+  - New reader that emulates a reader of a specified size
+
+- ByteArrayOutputStream  [IO-97]
+  - Performance enhancements
+
+==============================================================================
+Apache Commons IO Version 1.2
+==============================================================================
+
+Compatibility with 1.1
+----------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+
+Deprecations from 1.1
+---------------------
+
+Bug fixes from 1.1
+------------------
+- FileSystemUtils.freeSpace(drive)
+  Fix to allow Windows based command to function in French locale
+
+- FileUtils.read*
+  Increase certainty that files are closed in case of error
+
+- LockableFileWriter
+  Locking mechanism was broken and only provided limited protection [38942]
+  File deletion and locking in case of constructor error was broken
+
+Enhancements from 1.1
+---------------------
+- AgeFileFilter/SizeFileFilter
+  New file filters that compares against the age and size of the file
+
+- FileSystemUtils.freeSpaceKb(drive)
+  New method that unifies result to be in kilobytes [38574]
+
+- FileUtils.contentEquals(File,File)
+  Performance improved by adding length and file location checking
+
+- FileUtils.iterateFiles
+  Two new method to provide direct access to iterators over files
+
+- FileUtils.lineIterator
+  IOUtils.lineIterator
+  New methods to provide an iterator over the lines in a file [38083]
+
+- FileUtils.copyDirectoryToDirectory
+  New method to copy a directory to within another directory [36315]
+  
+==============================================================================
+Apache Commons IO Version 1.1
+==============================================================================
+
+Incompatible changes from 1.0
+-----------------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes, except:
+- FileUtils.writeStringToFile()
+    A null encoding previously used 'ISO-8859-1', now it uses the platform default
+    Generally this will make no difference
+
+- LockableFileWriter
+    Improved validation and now create directories if necesssary
+
+plus these bug fixes may affect you semantically:
+- FileUtils.touch()  (Bug fix 29821)
+    Now creates the file if it did not previously exist
+
+- FileUtils.toFile(URL) (Bug fix 32575)
+    Now handles escape syntax such as %20
+
+- FileUtils.sizeOfDirectory()  (Bug fix 36801)
+    May now return a size of 0 if the directory is security restricted
+
+Deprecations from 1.0
+---------------------
+- CopyUtils has been deprecated.
+    Its methods have been moved to IOUtils.
+    The new IOUtils methods handle nulls better, and have clearer names.
+
+- IOUtils.toByteArray(String) - Use {@link String#getBytes()}
+- IOUtils.toString(byte[]) - Use {@link String#String(byte[])}
+- IOUtils.toString(byte[],String) - Use {@link String#String(byte[],String)}
+
+Bug fixes from 1.0
+------------------
+- FileUtils - touch()  [29821]
+    Now creates the file if it did not previously exist
+
+- FileUtils - toFile(URL)  [32575]
+    Now handles escape syntax such as %20
+
+- FileFilterUtils - makeCVSAware(IOFileFilter)  [33023]
+    Fixed bug that caused method to be completely broken
+
+- CountingInputStream  [33336]
+    Fixed bug that caused the count to reduce by one at the end of the stream
+
+- CountingInputStream - skip(long)  [34311]
+    Bytes from calls to this method were not previously counted
+
+- NullOutputStream  [33481]
+    Remove unnecessary synchronization
+
+- AbstractFileFilter - accept(File, String)  [30992]
+    Fixed broken implementation
+
+- FileUtils  [36801]
+    Previously threw NPE when listing files in a security restricted directory
+    Now throw IOException with a better message
+
+- FileUtils - writeStringToFile()
+    Null encoding now correctly uses the platform default
+
+Enhancements from 1.0
+---------------------
+- FilenameUtils - new class  [33303,29351]
+    A static utility class for working with filenames
+    Seeks to ease the pain of developing on Windows and deploying on Unix
+
+- FileSystemUtils - new class  [32982,36325]
+    A static utility class for working with file systems
+    Provides one method at present, to get the free space on the filing system
+
+- IOUtils - new public constants
+    Constants for directory and line separators on Windows and Unix
+
+- IOUtils - toByteArray(Reader,encoding)
+    Handles encodings when reading to a byte array
+
+- IOUtils - toCharArray(InputStream)  [28979]
+          - toCharArray(InputStream,encoding)
+          - toCharArray(Reader)
+    Reads a stream/reader into a charatcter array
+
+- IOUtils - readLines(InputStream)  [36214]
+          - readLines(InputStream,encoding)
+          - readLines(Reader)
+    Reads a stream/reader line by line into a List of Strings
+
+- IOUtils - toInputStream(String)  [32958]
+          - toInputStream(String,encoding)
+    Creates an input stream that uses the string as a source of data
+
+- IOUtils - writeLines(Collection,lineEnding,OutputStream)  [36214]
+          - writeLines(Collection,lineEnding,OutputStream,encoding)
+          - writeLines(Collection,lineEnding,Writer)
+    Writes a collection to a stream/writer line by line
+
+- IOUtils - write(...)
+    Write data to a stream/writer (moved from CopyUtils with better null handling)
+
+- IOUtils - copy(...)
+    Copy data between streams (moved from CopyUtils with better null handling)
+
+- IOUtils - contentEquals(Reader,Reader)
+    Method to compare the contents of two readers
+
+- FileUtils - toFiles(URL[])
+    Converts an array of URLs to an array of Files
+
+- FileUtils - copyDirectory()  [32944]
+    New methods to copy a directory
+
+- FileUtils - readFileToByteArray(File)
+    Reads an entire file into a byte array
+
+- FileUtils - writeByteArrayToFile(File,byte[])
+    Writes a byte array to a file
+
+- FileUtils - readLines(File,encoding)  [36214]
+    Reads a file line by line into a List of Strings
+
+- FileUtils - writeLines(File,encoding,List)
+              writeLines(File,encoding,List,lineEnding)
+    Writes a collection to a file line by line
+
+- FileUtils - EMPTY_FILE_ARRAY
+    Constant for an empty array of File objects
+
+- ConditionalFileFilter - new interface  [30705]
+    Defines the behavior of list based filters
+
+- AndFileFilter, OrFileFilter  [30705]
+    Now support a list of filters to and/or
+
+- WildcardFilter  [31115]
+    New filter that can match using wildcard file names
+
+- FileFilterUtils - makeSVNAware(IOFileFilter)
+    New method, like makeCVSAware, that ignores Subversion source control directories
+
+- ClassLoaderObjectInputStream
+    An ObjectInputStream that supports a ClassLoader
+
+- CountingInputStream,CountingOutputStream - resetCount()  [28976]
+    Adds the ability to reset the count part way through reading/writing the stream
+
+- DeferredFileOutputStream - writeTo(OutputStream)  [34173]
+    New method to allow current contents to be written to a stream
+
+- DeferredFileOutputStream  [34142]
+    Performance optimizations avoiding double buffering
+
+- LockableFileWriter - encoding support [36825]
+    Add support for character encodings to LockableFileWriter
+    Improve the validation
+    Create directories if necesssary
+
+- IOUtils and EndianUtils are no longer final  [28978]
+    Allows developers to have subclasses if desired   
+
+==============================================================================
+Feedback
+==============================================================================
+
+Open source works best when you give feedback:
+https://commons.apache.org/io/
+
+Please direct all bug reports to JIRA
+https://issues.apache.org/jira/browse/IO
+
+Or subscribe to the commons-user mailing list (prefix emails by [io])
+https://commons.apache.org/mail-lists.html
+
+The Commons-IO Team
diff --git a/src/conf/checkstyle-suppressions.xml b/src/conf/checkstyle-suppressions.xml
new file mode 100644
index 0000000..14a483b
--- /dev/null
+++ b/src/conf/checkstyle-suppressions.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0"?>
+<!DOCTYPE suppressions PUBLIC
+  "-//Checkstyle//DTD SuppressionFilter Configuration 1.2//EN"
+  "https://checkstyle.org/dtds/suppressions_1_2.dtd">
+<!--
+   Licensed to the Apache Software Foundation (ASF) under one or more
+   contributor license agreements.  See the NOTICE file distributed with
+   this work for additional information regarding copyright ownership.
+   The ASF licenses this file to You under the Apache License, Version 2.0
+   (the "License"); you may not use this file except in compliance with
+   the License.  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+-->
+
+<suppressions>
+  <!-- Suppress test Javadoc checks-->
+  <suppress checks="JavadocMethod" files="src[/\\]test[/\\]java[/\\]" />
+  <suppress checks="JavadocPackage" files="src[/\\]test[/\\]java[/\\]" />
+  <!-- Suppress generated-test-sources checks-->
+  <suppress checks=".*" files="target[/\\]generated-test-sources[/\\]" />
+</suppressions>
diff --git a/src/conf/checkstyle.xml b/src/conf/checkstyle.xml
new file mode 100644
index 0000000..bbc848f
--- /dev/null
+++ b/src/conf/checkstyle.xml
@@ -0,0 +1,48 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<!DOCTYPE module PUBLIC
+    "-//Checkstyle//DTD Checkstyle Configuration 1.2//EN"
+    "https://checkstyle.org/dtds/configuration_1_2.dtd">
+<module name="Checker">
+  <property name="localeLanguage" value="en" />
+  <module name="JavadocPackage">
+    <!-- setting allowLegacy means it will check for package.html instead of just package-info.java -->
+    <property name="allowLegacy" value="true" />
+  </module>
+  <module name="FileTabCharacter">
+    <property name="fileExtensions" value="java,xml" />
+  </module>
+  <module name="LineLength">
+    <property name="max" value="160" />
+  </module>
+  <module name="TreeWalker">
+    <module name="AvoidStarImport" />
+    <module name="RedundantImport" />
+    <module name="UnusedImports" />
+    <module name="NeedBraces" />
+    <module name="LeftCurly" />
+    <module name="JavadocMethod" />
+    <module name="FinalLocalVariable" />
+    <!-- No Trailing whitespace -->
+    <module name="Regexp">
+      <property name="format" value="[ \t]+$" />
+      <property name="illegalPattern" value="true" />
+      <property name="message" value="Trailing whitespace" />
+    </module>
+  </module>
+</module>
diff --git a/src/conf/spotbugs-exclude-filter.xml b/src/conf/spotbugs-exclude-filter.xml
new file mode 100644
index 0000000..000c565
--- /dev/null
+++ b/src/conf/spotbugs-exclude-filter.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+   Licensed to the Apache Software Foundation (ASF) under one or more
+   contributor license agreements.  See the NOTICE file distributed with
+   this work for additional information regarding copyright ownership.
+   The ASF licenses this file to You under the Apache License, Version 2.0
+   (the "License"); you may not use this file except in compliance with
+   the License.  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+-->
+<FindBugsFilter
+    xmlns="https://github.com/spotbugs/filter/3.0.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="https://github.com/spotbugs/filter/3.0.0 https://raw.githubusercontent.com/spotbugs/spotbugs/3.1.0/spotbugs/etc/findbugsfilter.xsd">
+
+  <!--  See discussion on https://issues.apache.org/jira/browse/IO-216 -->
+  <Match>
+    <Class name="org.apache.commons.io.output.LockableFileWriter" />
+    <Method name="close" params="" returns="void" />
+    <Bug pattern="RV_RETURN_VALUE_IGNORED_BAD_PRACTICE" />
+  </Match>
+
+  <!-- The constructors intentionally do not copy the input byte array -->
+  <Match>
+    <Class name="org.apache.commons.io.input.UnsynchronizedByteArrayInputStream" />
+    <Method name="&lt;init&gt;" />
+    <Bug pattern="EI_EXPOSE_REP2" />
+  </Match>
+
+  <!-- The encoding is irrelevant as output is binned -->
+  <Match>
+    <Class name="org.apache.commons.io.output.NullPrintStream" />
+    <Bug pattern="DM_DEFAULT_ENCODING" />
+  </Match>
+
+  <!-- Deprecated -->
+  <Match>
+    <Class name="org.apache.commons.io.file.PathUtils" />
+    <Field name="NOFOLLOW_LINK_OPTION_ARRAY" />
+    <Bug pattern="MS_PKGPROTECT" />
+  </Match>
+
+</FindBugsFilter>
diff --git a/src/main/java/org/apache/commons/io/ByteOrderMark.java b/src/main/java/org/apache/commons/io/ByteOrderMark.java
new file mode 100644
index 0000000..af235c3
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/ByteOrderMark.java
@@ -0,0 +1,195 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.Serializable;
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+import java.util.Objects;
+
+/**
+ * Byte Order Mark (BOM) representation - see {@link org.apache.commons.io.input.BOMInputStream}.
+ *
+ * @see org.apache.commons.io.input.BOMInputStream
+ * @see <a href="http://en.wikipedia.org/wiki/Byte_order_mark">Wikipedia: Byte Order Mark</a>
+ * @see <a href="http://www.w3.org/TR/2006/REC-xml-20060816/#sec-guessing">W3C: Autodetection of Character Encodings
+ *      (Non-Normative)</a>
+ * @since 2.0
+ */
+public class ByteOrderMark implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** UTF-8 BOM. */
+    public static final ByteOrderMark UTF_8 = new ByteOrderMark(StandardCharsets.UTF_8.name(), 0xEF, 0xBB, 0xBF);
+
+    /** UTF-16BE BOM (Big-Endian). */
+    public static final ByteOrderMark UTF_16BE = new ByteOrderMark(StandardCharsets.UTF_16BE.name(), 0xFE, 0xFF);
+
+    /** UTF-16LE BOM (Little-Endian). */
+    public static final ByteOrderMark UTF_16LE = new ByteOrderMark(StandardCharsets.UTF_16LE.name(), 0xFF, 0xFE);
+
+    /**
+     * UTF-32BE BOM (Big-Endian).
+     *
+     * @since 2.2
+     */
+    public static final ByteOrderMark UTF_32BE = new ByteOrderMark("UTF-32BE", 0x00, 0x00, 0xFE, 0xFF);
+
+    /**
+     * UTF-32LE BOM (Little-Endian).
+     *
+     * @since 2.2
+     */
+    public static final ByteOrderMark UTF_32LE = new ByteOrderMark("UTF-32LE", 0xFF, 0xFE, 0x00, 0x00);
+
+    /**
+     * Unicode BOM character; external form depends on the encoding.
+     *
+     * @see <a href="http://unicode.org/faq/utf_bom.html#BOM">Byte Order Mark (BOM) FAQ</a>
+     * @since 2.5
+     */
+    public static final char UTF_BOM = '\uFEFF';
+
+    private final String charsetName;
+    private final int[] bytes;
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param charsetName The name of the charset the BOM represents
+     * @param bytes The BOM's bytes
+     * @throws IllegalArgumentException if the charsetName is zero length
+     * @throws IllegalArgumentException if the bytes are zero length
+     */
+    public ByteOrderMark(final String charsetName, final int... bytes) {
+        Objects.requireNonNull(charsetName, "charsetName");
+        Objects.requireNonNull(bytes, "bytes");
+        if (charsetName.isEmpty()) {
+            throw new IllegalArgumentException("No charsetName specified");
+        }
+        if (bytes.length == 0) {
+            throw new IllegalArgumentException("No bytes specified");
+        }
+        this.charsetName = charsetName;
+        this.bytes = bytes.clone();
+    }
+
+    /**
+     * Indicates if this instance's bytes equals another.
+     *
+     * @param obj The object to compare to
+     * @return true if the bom's bytes are equal, otherwise
+     * false
+     */
+    @Override
+    public boolean equals(final Object obj) {
+        if (!(obj instanceof ByteOrderMark)) {
+            return false;
+        }
+        final ByteOrderMark bom = (ByteOrderMark) obj;
+        if (bytes.length != bom.length()) {
+            return false;
+        }
+        for (int i = 0; i < bytes.length; i++) {
+            if (bytes[i] != bom.get(i)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Gets the byte at the specified position.
+     *
+     * @param pos The position
+     * @return The specified byte
+     */
+    public int get(final int pos) {
+        return bytes[pos];
+    }
+
+    /**
+     * Gets a copy of the BOM's bytes.
+     *
+     * @return a copy of the BOM's bytes
+     */
+    public byte[] getBytes() {
+        final byte[] copy = IOUtils.byteArray(bytes.length);
+        for (int i = 0; i < bytes.length; i++) {
+            copy[i] = (byte) bytes[i];
+        }
+        return copy;
+    }
+
+    /**
+     * Gets the name of the {@link java.nio.charset.Charset} the BOM represents.
+     *
+     * @return the character set name
+     */
+    public String getCharsetName() {
+        return charsetName;
+    }
+
+    /**
+     * Computes the hashcode for this BOM.
+     *
+     * @return the hashcode for this BOM.
+     * @see Object#hashCode()
+     */
+    @Override
+    public int hashCode() {
+        int hashCode = getClass().hashCode();
+        for (final int b : bytes) {
+            hashCode += b;
+        }
+        return hashCode;
+    }
+
+    /**
+     * Gets the length of the BOM's bytes.
+     *
+     * @return the length of the BOM's bytes
+     */
+    public int length() {
+        return bytes.length;
+    }
+
+    /**
+     * Converts this instance to a String representation of the BOM.
+     *
+     * @return the length of the BOM's bytes
+     */
+    @Override
+    public String toString() {
+        final StringBuilder builder = new StringBuilder();
+        builder.append(getClass().getSimpleName());
+        builder.append('[');
+        builder.append(charsetName);
+        builder.append(": ");
+        for (int i = 0; i < bytes.length; i++) {
+            if (i > 0) {
+                builder.append(",");
+            }
+            builder.append("0x");
+            builder.append(Integer.toHexString(0xFF & bytes[i]).toUpperCase(Locale.ROOT));
+        }
+        builder.append(']');
+        return builder.toString();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/ByteOrderParser.java b/src/main/java/org/apache/commons/io/ByteOrderParser.java
new file mode 100644
index 0000000..570a771
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/ByteOrderParser.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io;
+
+import java.nio.ByteOrder;
+
+/**
+ * Converts Strings to {@link ByteOrder} instances.
+ *
+ * @since 2.6
+ */
+public final class ByteOrderParser {
+
+    /**
+     * Parses the String argument as a {@link ByteOrder}.
+     * <p>
+     * Returns {@code ByteOrder.LITTLE_ENDIAN} if the given value is {@code "LITTLE_ENDIAN"}.
+     * </p>
+     * <p>
+     * Returns {@code ByteOrder.BIG_ENDIAN} if the given value is {@code "BIG_ENDIAN"}.
+     * </p>
+     * Examples:
+     * <ul>
+     * <li>{@code ByteOrderParser.parseByteOrder("LITTLE_ENDIAN")} returns {@code ByteOrder.LITTLE_ENDIAN}</li>
+     * <li>{@code ByteOrderParser.parseByteOrder("BIG_ENDIAN")} returns {@code ByteOrder.BIG_ENDIAN}</li>
+     * </ul>
+     *
+     * @param value
+     *            the {@link String} containing the ByteOrder representation to be parsed
+     * @return the ByteOrder represented by the string argument
+     * @throws IllegalArgumentException
+     *             if the {@link String} containing the ByteOrder representation to be parsed is unknown.
+     */
+    public static ByteOrder parseByteOrder(final String value) {
+        if (ByteOrder.BIG_ENDIAN.toString().equals(value)) {
+            return ByteOrder.BIG_ENDIAN;
+        }
+        if (ByteOrder.LITTLE_ENDIAN.toString().equals(value)) {
+            return ByteOrder.LITTLE_ENDIAN;
+        }
+        throw new IllegalArgumentException("Unsupported byte order setting: " + value + ", expected one of " + ByteOrder.LITTLE_ENDIAN +
+                 ", " + ByteOrder.BIG_ENDIAN);
+    }
+
+    /**
+     * ByteOrderUtils is a static utility class, so prevent construction with a private constructor.
+     */
+    private ByteOrderParser() {
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/Charsets.java b/src/main/java/org/apache/commons/io/Charsets.java
new file mode 100644
index 0000000..37275b8
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/Charsets.java
@@ -0,0 +1,221 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.charset.UnsupportedCharsetException;
+import java.util.Collections;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+/**
+ * Charsets required of every implementation of the Java platform.
+ *
+ * From the Java documentation <a href="https://docs.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">
+ * Standard charsets</a>:
+ * <p>
+ * <cite>Every implementation of the Java platform is required to support the following character encodings. Consult
+ * the release documentation for your implementation to see if any other encodings are supported. Consult the release
+ * documentation for your implementation to see if any other encodings are supported. </cite>
+ * </p>
+ *
+ * <ul>
+ * <li>{@code US-ASCII}<br>
+ * Seven-bit ASCII, a.k.a. ISO646-US, a.k.a. the Basic Latin block of the Unicode character set.</li>
+ * <li>{@code ISO-8859-1}<br>
+ * ISO Latin Alphabet No. 1, a.k.a. ISO-LATIN-1.</li>
+ * <li>{@code UTF-8}<br>
+ * Eight-bit Unicode Transformation Format.</li>
+ * <li>{@code UTF-16BE}<br>
+ * Sixteen-bit Unicode Transformation Format, big-endian byte order.</li>
+ * <li>{@code UTF-16LE}<br>
+ * Sixteen-bit Unicode Transformation Format, little-endian byte order.</li>
+ * <li>{@code UTF-16}<br>
+ * Sixteen-bit Unicode Transformation Format, byte order specified by a mandatory initial byte-order mark (either order
+ * accepted on input, big-endian used on output.)</li>
+ * </ul>
+ *
+ * @see <a href="https://docs.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
+ * @since 2.3
+ */
+public class Charsets {
+
+    //
+    // This class should only contain Charset instances for required encodings. This guarantees that it will load
+    // correctly and without delay on all Java platforms.
+    //
+
+    private static final SortedMap<String, Charset> STANDARD_CHARSET_MAP;
+
+    static {
+        final SortedMap<String, Charset> standardCharsetMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+        standardCharsetMap.put(StandardCharsets.ISO_8859_1.name(), StandardCharsets.ISO_8859_1);
+        standardCharsetMap.put(StandardCharsets.US_ASCII.name(), StandardCharsets.US_ASCII);
+        standardCharsetMap.put(StandardCharsets.UTF_16.name(), StandardCharsets.UTF_16);
+        standardCharsetMap.put(StandardCharsets.UTF_16BE.name(), StandardCharsets.UTF_16BE);
+        standardCharsetMap.put(StandardCharsets.UTF_16LE.name(), StandardCharsets.UTF_16LE);
+        standardCharsetMap.put(StandardCharsets.UTF_8.name(), StandardCharsets.UTF_8);
+        STANDARD_CHARSET_MAP = Collections.unmodifiableSortedMap(standardCharsetMap);
+    }
+
+    /**
+     * CharEncodingISO Latin Alphabet No. 1, a.k.a. ISO-LATIN-1.
+     * <p>
+     * Every implementation of the Java platform is required to support this character encoding.
+     * </p>
+     *
+     * @see <a href="https://docs.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
+     * @deprecated Use Java 7's {@link java.nio.charset.StandardCharsets}
+     */
+    @Deprecated
+    public static final Charset ISO_8859_1 = StandardCharsets.ISO_8859_1;
+
+    /**
+     * <p>
+     * Seven-bit ASCII, also known as ISO646-US, also known as the Basic Latin block of the Unicode character set.
+     * </p>
+     * <p>
+     * Every implementation of the Java platform is required to support this character encoding.
+     * </p>
+     *
+     * @see <a href="https://docs.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
+     * @deprecated Use Java 7's {@link java.nio.charset.StandardCharsets}
+     */
+    @Deprecated
+    public static final Charset US_ASCII = StandardCharsets.US_ASCII;
+
+    /**
+     * <p>
+     * Sixteen-bit Unicode Transformation Format, The byte order specified by a mandatory initial byte-order mark
+     * (either order accepted on input, big-endian used on output)
+     * </p>
+     * <p>
+     * Every implementation of the Java platform is required to support this character encoding.
+     * </p>
+     *
+     * @see <a href="https://docs.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
+     * @deprecated Use Java 7's {@link java.nio.charset.StandardCharsets}
+     */
+    @Deprecated
+    public static final Charset UTF_16 = StandardCharsets.UTF_16;
+
+    /**
+     * <p>
+     * Sixteen-bit Unicode Transformation Format, big-endian byte order.
+     * </p>
+     * <p>
+     * Every implementation of the Java platform is required to support this character encoding.
+     * </p>
+     *
+     * @see <a href="https://docs.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
+     * @deprecated Use Java 7's {@link java.nio.charset.StandardCharsets}
+     */
+    @Deprecated
+    public static final Charset UTF_16BE = StandardCharsets.UTF_16BE;
+
+    /**
+     * <p>
+     * Sixteen-bit Unicode Transformation Format, little-endian byte order.
+     * </p>
+     * <p>
+     * Every implementation of the Java platform is required to support this character encoding.
+     * </p>
+     *
+     * @see <a href="https://docs.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
+     * @deprecated Use Java 7's {@link java.nio.charset.StandardCharsets}
+     */
+    @Deprecated
+    public static final Charset UTF_16LE = StandardCharsets.UTF_16LE;
+
+    /**
+     * <p>
+     * Eight-bit Unicode Transformation Format.
+     * </p>
+     * <p>
+     * Every implementation of the Java platform is required to support this character encoding.
+     * </p>
+     *
+     * @see <a href="https://docs.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
+     * @deprecated Use Java 7's {@link java.nio.charset.StandardCharsets}
+     */
+    @Deprecated
+    public static final Charset UTF_8 = StandardCharsets.UTF_8;
+
+    /**
+     * Constructs a sorted map from canonical charset names to charset objects required of every implementation of the
+     * Java platform.
+     * <p>
+     * From the Java documentation <a href="https://docs.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">
+     * Standard charsets</a>:
+     * </p>
+     *
+     * @return An immutable, case-insensitive map from canonical charset names to charset objects.
+     * @see Charset#availableCharsets()
+     * @since 2.5
+     */
+    public static SortedMap<String, Charset> requiredCharsets() {
+        return STANDARD_CHARSET_MAP;
+    }
+
+    /**
+     * Returns the given Charset or the default Charset if the given Charset is null.
+     *
+     * @param charset
+     *            A charset or null.
+     * @return the given Charset or the default Charset if the given Charset is null
+     */
+    public static Charset toCharset(final Charset charset) {
+        return charset == null ? Charset.defaultCharset() : charset;
+    }
+
+    /**
+     * Returns the given charset if non-null, otherwise return defaultCharset.
+     *
+     * @param charset The charset to test, may be null.
+     * @param defaultCharset The charset to return if charset is null, may be null.
+     * @return a Charset .
+     * @since 2.12.0
+     */
+    public static Charset toCharset(final Charset charset, final Charset defaultCharset) {
+        return charset == null ? defaultCharset : charset;
+    }
+
+    /**
+     * Returns a Charset for the named charset. If the name is null, return the default Charset.
+     *
+     * @param charsetName The name of the requested charset, may be null.
+     * @return a Charset for the named charset.
+     * @throws UnsupportedCharsetException If the named charset is unavailable (unchecked exception).
+     */
+    public static Charset toCharset(final String charsetName) throws UnsupportedCharsetException {
+        return toCharset(charsetName, Charset.defaultCharset());
+    }
+
+    /**
+     * Returns a Charset for the named charset. If the name is null, return the given default Charset.
+     *
+     * @param charsetName The name of the requested charset, may be null.
+     * @param defaultCharset The name charset to return if charsetName is null, may be null.
+     * @return a Charset for the named charset.
+     * @throws UnsupportedCharsetException If the named charset is unavailable (unchecked exception).
+     * @since 2.12.0
+     */
+    public static Charset toCharset(final String charsetName, final Charset defaultCharset) throws UnsupportedCharsetException {
+        return charsetName == null ? defaultCharset : Charset.forName(charsetName);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/CloseableURLConnection.java b/src/main/java/org/apache/commons/io/CloseableURLConnection.java
new file mode 100644
index 0000000..7b945b6
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/CloseableURLConnection.java
@@ -0,0 +1,275 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URI;
+import java.net.URL;
+import java.net.URLConnection;
+import java.security.Permission;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Delegates to a URLConnection while implementing AutoCloseable.
+ */
+class CloseableURLConnection extends URLConnection implements AutoCloseable {
+
+    static CloseableURLConnection open(final URI uri) throws IOException {
+        return open(Objects.requireNonNull(uri, "uri").toURL());
+    }
+
+    static CloseableURLConnection open(final URL url) throws IOException {
+        return new CloseableURLConnection(url.openConnection());
+    }
+
+    private final URLConnection urlConnection;
+
+    CloseableURLConnection(final URLConnection urlConnection) {
+        super(Objects.requireNonNull(urlConnection, "urlConnection").getURL());
+        this.urlConnection = urlConnection;
+    }
+
+    @Override
+    public void addRequestProperty(final String key, final String value) {
+        urlConnection.addRequestProperty(key, value);
+    }
+
+    @Override
+    public void close() {
+        IOUtils.close(urlConnection);
+    }
+
+    @Override
+    public void connect() throws IOException {
+        urlConnection.connect();
+    }
+
+    @Override
+    public boolean equals(final Object obj) {
+        return urlConnection.equals(obj);
+    }
+
+    @Override
+    public boolean getAllowUserInteraction() {
+        return urlConnection.getAllowUserInteraction();
+    }
+
+    @Override
+    public int getConnectTimeout() {
+        return urlConnection.getConnectTimeout();
+    }
+
+    @Override
+    public Object getContent() throws IOException {
+        return urlConnection.getContent();
+    }
+
+    @Override
+    public Object getContent(final Class[] classes) throws IOException {
+        return urlConnection.getContent(classes);
+    }
+
+    @Override
+    public String getContentEncoding() {
+        return urlConnection.getContentEncoding();
+    }
+
+    @Override
+    public int getContentLength() {
+        return urlConnection.getContentLength();
+    }
+
+    @Override
+    public long getContentLengthLong() {
+        return urlConnection.getContentLengthLong();
+    }
+
+    @Override
+    public String getContentType() {
+        return urlConnection.getContentType();
+    }
+
+    @Override
+    public long getDate() {
+        return urlConnection.getDate();
+    }
+
+    @Override
+    public boolean getDefaultUseCaches() {
+        return urlConnection.getDefaultUseCaches();
+    }
+
+    @Override
+    public boolean getDoInput() {
+        return urlConnection.getDoInput();
+    }
+
+    @Override
+    public boolean getDoOutput() {
+        return urlConnection.getDoOutput();
+    }
+
+    @Override
+    public long getExpiration() {
+        return urlConnection.getExpiration();
+    }
+
+    @Override
+    public String getHeaderField(final int n) {
+        return urlConnection.getHeaderField(n);
+    }
+
+    @Override
+    public String getHeaderField(final String name) {
+        return urlConnection.getHeaderField(name);
+    }
+
+    @Override
+    public long getHeaderFieldDate(final String name, final long Default) {
+        return urlConnection.getHeaderFieldDate(name, Default);
+    }
+
+    @Override
+    public int getHeaderFieldInt(final String name, final int Default) {
+        return urlConnection.getHeaderFieldInt(name, Default);
+    }
+
+    @Override
+    public String getHeaderFieldKey(final int n) {
+        return urlConnection.getHeaderFieldKey(n);
+    }
+
+    @Override
+    public long getHeaderFieldLong(final String name, final long Default) {
+        return urlConnection.getHeaderFieldLong(name, Default);
+    }
+
+    @Override
+    public Map<String, List<String>> getHeaderFields() {
+        return urlConnection.getHeaderFields();
+    }
+
+    @Override
+    public long getIfModifiedSince() {
+        return urlConnection.getIfModifiedSince();
+    }
+
+    @Override
+    public InputStream getInputStream() throws IOException {
+        return urlConnection.getInputStream();
+    }
+
+    @Override
+    public long getLastModified() {
+        return urlConnection.getLastModified();
+    }
+
+    @Override
+    public OutputStream getOutputStream() throws IOException {
+        return urlConnection.getOutputStream();
+    }
+
+    @Override
+    public Permission getPermission() throws IOException {
+        return urlConnection.getPermission();
+    }
+
+    @Override
+    public int getReadTimeout() {
+        return urlConnection.getReadTimeout();
+    }
+
+    @Override
+    public Map<String, List<String>> getRequestProperties() {
+        return urlConnection.getRequestProperties();
+    }
+
+    @Override
+    public String getRequestProperty(final String key) {
+        return urlConnection.getRequestProperty(key);
+    }
+
+    @Override
+    public URL getURL() {
+        return urlConnection.getURL();
+    }
+
+    @Override
+    public boolean getUseCaches() {
+        return urlConnection.getUseCaches();
+    }
+
+    @Override
+    public int hashCode() {
+        return urlConnection.hashCode();
+    }
+
+    @Override
+    public void setAllowUserInteraction(final boolean allowUserInteraction) {
+        urlConnection.setAllowUserInteraction(allowUserInteraction);
+    }
+
+    @Override
+    public void setConnectTimeout(final int timeout) {
+        urlConnection.setConnectTimeout(timeout);
+    }
+
+    @Override
+    public void setDefaultUseCaches(final boolean defaultUseCaches) {
+        urlConnection.setDefaultUseCaches(defaultUseCaches);
+    }
+
+    @Override
+    public void setDoInput(final boolean doInput) {
+        urlConnection.setDoInput(doInput);
+    }
+
+    @Override
+    public void setDoOutput(final boolean doOutput) {
+        urlConnection.setDoOutput(doOutput);
+    }
+
+    @Override
+    public void setIfModifiedSince(final long ifModifiedSince) {
+        urlConnection.setIfModifiedSince(ifModifiedSince);
+    }
+
+    @Override
+    public void setReadTimeout(final int timeout) {
+        urlConnection.setReadTimeout(timeout);
+    }
+
+    @Override
+    public void setRequestProperty(final String key, final String value) {
+        urlConnection.setRequestProperty(key, value);
+    }
+
+    @Override
+    public void setUseCaches(final boolean useCaches) {
+        urlConnection.setUseCaches(useCaches);
+    }
+
+    @Override
+    public String toString() {
+        return urlConnection.toString();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/CopyUtils.java b/src/main/java/org/apache/commons/io/CopyUtils.java
new file mode 100644
index 0000000..133cc8e
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/CopyUtils.java
@@ -0,0 +1,344 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Reader;
+import java.io.StringReader;
+import java.io.Writer;
+import java.nio.charset.Charset;
+
+/**
+ * This class provides static utility methods for buffered
+ * copying between sources ({@link InputStream}, {@link Reader},
+ * {@link String} and {@code byte[]}) and destinations
+ * ({@link OutputStream}, {@link Writer}, {@link String} and
+ * {@code byte[]}).
+ * <p>
+ * Unless otherwise noted, these {@code copy} methods do <em>not</em>
+ * flush or close the streams. Often doing so would require making non-portable
+ * assumptions about the streams' origin and further use. This means that both
+ * streams' {@code close()} methods must be called after copying. if one
+ * omits this step, then the stream resources (sockets, file descriptors) are
+ * released when the associated Stream is garbage-collected. It is not a good
+ * idea to rely on this mechanism. For a good overview of the distinction
+ * between "memory management" and "resource management", see
+ * <a href="http://www.unixreview.com/articles/1998/9804/9804ja/ja.htm">this
+ * UnixReview article</a>.
+ * <p>
+ * For byte-to-char methods, a {@code copy} variant allows the encoding
+ * to be selected (otherwise the platform default is used). We would like to
+ * encourage you to always specify the encoding because relying on the platform
+ * default can lead to unexpected results.
+ * <p>
+ * We don't provide special variants for the {@code copy} methods that
+ * let you specify the buffer size because in modern VMs the impact on speed
+ * seems to be minimal. We're using a default buffer size of 4 KB.
+ * <p>
+ * The {@code copy} methods use an internal buffer when copying. It is
+ * therefore advisable <em>not</em> to deliberately wrap the stream arguments
+ * to the {@code copy} methods in {@code Buffered*} streams. For
+ * example, don't do the following:
+ * <pre>
+ *  copy( new BufferedInputStream( in ), new BufferedOutputStream( out ) );
+ *  </pre>
+ * The rationale is as follows:
+ * <p>
+ * Imagine that an InputStream's read() is a very expensive operation, which
+ * would usually suggest wrapping in a BufferedInputStream. The
+ * BufferedInputStream works by issuing infrequent
+ * {@link java.io.InputStream#read(byte[] b, int off, int len)} requests on the
+ * underlying InputStream, to fill an internal buffer, from which further
+ * {@code read} requests can inexpensively get their data (until the buffer
+ * runs out).
+ * <p>
+ * However, the {@code copy} methods do the same thing, keeping an
+ * internal buffer, populated by
+ * {@link InputStream#read(byte[] b, int off, int len)} requests. Having two
+ * buffers (or three if the destination stream is also buffered) is pointless,
+ * and the unnecessary buffer management hurts performance slightly (about 3%,
+ * according to some simple experiments).
+ * <p>
+ * Behold, intrepid explorers; a map of this class:
+ * <pre>
+ *       Method      Input               Output          Dependency
+ *       ------      -----               ------          -------
+ * 1     copy        InputStream         OutputStream    (primitive)
+ * 2     copy        Reader              Writer          (primitive)
+ *
+ * 3     copy        InputStream         Writer          2
+ *
+ * 4     copy        Reader              OutputStream    2
+ *
+ * 5     copy        String              OutputStream    2
+ * 6     copy        String              Writer          (trivial)
+ *
+ * 7     copy        byte[]              Writer          3
+ * 8     copy        byte[]              OutputStream    (trivial)
+ * </pre>
+ * <p>
+ * Note that only the first two methods shuffle bytes; the rest use these
+ * two, or (if possible) copy using native Java copy methods. As there are
+ * method variants to specify the encoding, each row may
+ * correspond to up to 2 methods.
+ * <p>
+ * Origin of code: Excalibur.
+ *
+ * @deprecated Use IOUtils. Will be removed in 3.0.
+ *  Methods renamed to IOUtils.write() or IOUtils.copy().
+ *  Null handling behavior changed in IOUtils (null data does not
+ *  throw NullPointerException).
+ */
+@Deprecated
+public class CopyUtils {
+
+    /**
+     * Copies bytes from a {@code byte[]} to an {@link OutputStream}.
+     * @param input the byte array to read from
+     * @param output the {@link OutputStream} to write to
+     * @throws IOException In case of an I/O problem
+     */
+    public static void copy(final byte[] input, final OutputStream output) throws IOException {
+        output.write(input);
+    }
+
+    /**
+     * Copies and convert bytes from a {@code byte[]} to chars on a
+     * {@link Writer}.
+     * The platform's default encoding is used for the byte-to-char conversion.
+     * @param input the byte array to read from
+     * @param output the {@link Writer} to write to
+     * @throws IOException In case of an I/O problem
+     * @deprecated 2.5 use {@link #copy(byte[], Writer, String)} instead
+     */
+    @Deprecated
+    public static void copy(final byte[] input, final Writer output) throws IOException {
+        final ByteArrayInputStream inputStream = new ByteArrayInputStream(input);
+        copy(inputStream, output);
+    }
+
+    /**
+     * Copies and convert bytes from a {@code byte[]} to chars on a
+     * {@link Writer}, using the specified encoding.
+     * @param input the byte array to read from
+     * @param output the {@link Writer} to write to
+     * @param encoding The name of a supported character encoding. See the
+     * <a href="http://www.iana.org/assignments/character-sets">IANA
+     * Charset Registry</a> for a list of valid encoding types.
+     * @throws IOException In case of an I/O problem
+     */
+    public static void copy(final byte[] input, final Writer output, final String encoding) throws IOException {
+        final ByteArrayInputStream inputStream = new ByteArrayInputStream(input);
+        copy(inputStream, output, encoding);
+    }
+
+    /**
+     * Copies bytes from an {@link InputStream} to an
+     * {@link OutputStream}.
+     * @param input the {@link InputStream} to read from
+     * @param output the {@link OutputStream} to write to
+     * @return the number of bytes copied
+     * @throws IOException In case of an I/O problem
+     */
+    public static int copy(final InputStream input, final OutputStream output) throws IOException {
+        final byte[] buffer = IOUtils.byteArray();
+        int count = 0;
+        int n;
+        while (EOF != (n = input.read(buffer))) {
+            output.write(buffer, 0, n);
+            count += n;
+        }
+        return count;
+    }
+
+    /**
+     * Copies and convert bytes from an {@link InputStream} to chars on a
+     * {@link Writer}.
+     * The platform's default encoding is used for the byte-to-char conversion.
+     * @param input the {@link InputStream} to read from
+     * @param output the {@link Writer} to write to
+     * @throws IOException In case of an I/O problem
+     * @deprecated 2.5 use {@link #copy(InputStream, Writer, String)} instead
+     */
+    @Deprecated
+    public static void copy(
+            final InputStream input,
+            final Writer output)
+                throws IOException {
+        // make explicit the dependency on the default encoding
+        final InputStreamReader in = new InputStreamReader(input, Charset.defaultCharset());
+        copy(in, output);
+    }
+
+    /**
+     * Copies and convert bytes from an {@link InputStream} to chars on a
+     * {@link Writer}, using the specified encoding.
+     * @param input the {@link InputStream} to read from
+     * @param output the {@link Writer} to write to
+     * @param encoding The name of a supported character encoding. See the
+     * <a href="http://www.iana.org/assignments/character-sets">IANA
+     * Charset Registry</a> for a list of valid encoding types.
+     * @throws IOException In case of an I/O problem
+     */
+    public static void copy(
+            final InputStream input,
+            final Writer output,
+            final String encoding)
+                throws IOException {
+        final InputStreamReader in = new InputStreamReader(input, encoding);
+        copy(in, output);
+    }
+
+    /**
+     * Serialize chars from a {@link Reader} to bytes on an
+     * {@link OutputStream}, and flush the {@link OutputStream}.
+     * Uses the default platform encoding.
+     * @param input the {@link Reader} to read from
+     * @param output the {@link OutputStream} to write to
+     * @throws IOException In case of an I/O problem
+     * @deprecated 2.5 use {@link #copy(Reader, OutputStream, String)} instead
+     */
+    @Deprecated
+    public static void copy(
+            final Reader input,
+            final OutputStream output)
+                throws IOException {
+        // make explicit the dependency on the default encoding
+        final OutputStreamWriter out = new OutputStreamWriter(output, Charset.defaultCharset());
+        copy(input, out);
+        // XXX Unless anyone is planning on rewriting OutputStreamWriter, we
+        // have to flush here.
+        out.flush();
+    }
+
+    /**
+     * Serialize chars from a {@link Reader} to bytes on an
+     * {@link OutputStream}, and flush the {@link OutputStream}.
+     * @param input the {@link Reader} to read from
+     * @param output the {@link OutputStream} to write to
+     * @param encoding The name of a supported character encoding. See the
+     * <a href="http://www.iana.org/assignments/character-sets">IANA
+     * Charset Registry</a> for a list of valid encoding types.
+     * @throws IOException In case of an I/O problem
+     * @since 2.5
+     */
+    public static void copy(
+            final Reader input,
+            final OutputStream output,
+            final String encoding)
+                throws IOException {
+        final OutputStreamWriter out = new OutputStreamWriter(output, encoding);
+        copy(input, out);
+        // XXX Unless anyone is planning on rewriting OutputStreamWriter, we
+        // have to flush here.
+        out.flush();
+    }
+
+    /**
+     * Copies chars from a {@link Reader} to a {@link Writer}.
+     * @param input the {@link Reader} to read from
+     * @param output the {@link Writer} to write to
+     * @return the number of characters copied
+     * @throws IOException In case of an I/O problem
+     */
+    public static int copy(
+            final Reader input,
+            final Writer output)
+                throws IOException {
+        final char[] buffer = IOUtils.getCharArray();
+        int count = 0;
+        int n;
+        while (EOF != (n = input.read(buffer))) {
+            output.write(buffer, 0, n);
+            count += n;
+        }
+        return count;
+    }
+
+    /**
+     * Serialize chars from a {@link String} to bytes on an
+     * {@link OutputStream}, and
+     * flush the {@link OutputStream}.
+     * Uses the platform default encoding.
+     * @param input the {@link String} to read from
+     * @param output the {@link OutputStream} to write to
+     * @throws IOException In case of an I/O problem
+     * @deprecated 2.5 use {@link #copy(String, OutputStream, String)} instead
+     */
+    @Deprecated
+    public static void copy(
+            final String input,
+            final OutputStream output)
+                throws IOException {
+        final StringReader in = new StringReader(input);
+        // make explicit the dependency on the default encoding
+        final OutputStreamWriter out = new OutputStreamWriter(output, Charset.defaultCharset());
+        copy(in, out);
+        // XXX Unless anyone is planning on rewriting OutputStreamWriter, we
+        // have to flush here.
+        out.flush();
+    }
+
+    /**
+     * Serialize chars from a {@link String} to bytes on an
+     * {@link OutputStream}, and
+     * flush the {@link OutputStream}.
+     * @param input the {@link String} to read from
+     * @param output the {@link OutputStream} to write to
+     * @param encoding The name of a supported character encoding. See the
+     * <a href="http://www.iana.org/assignments/character-sets">IANA
+     * Charset Registry</a> for a list of valid encoding types.
+     * @throws IOException In case of an I/O problem
+     * @since 2.5
+     */
+    public static void copy(
+            final String input,
+            final OutputStream output,
+            final String encoding)
+                throws IOException {
+        final StringReader in = new StringReader(input);
+        final OutputStreamWriter out = new OutputStreamWriter(output, encoding);
+        copy(in, out);
+        // XXX Unless anyone is planning on rewriting OutputStreamWriter, we
+        // have to flush here.
+        out.flush();
+    }
+
+    /**
+     * Copies chars from a {@link String} to a {@link Writer}.
+     * @param input the {@link String} to read from
+     * @param output the {@link Writer} to write to
+     * @throws IOException In case of an I/O problem
+     */
+    public static void copy(final String input, final Writer output)
+                throws IOException {
+        output.write(input);
+    }
+
+    /**
+     * Instances should NOT be constructed in standard programming.
+     */
+    public CopyUtils() { }
+
+}
diff --git a/src/main/java/org/apache/commons/io/DirectoryWalker.java b/src/main/java/org/apache/commons/io/DirectoryWalker.java
new file mode 100644
index 0000000..2269c2a
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/DirectoryWalker.java
@@ -0,0 +1,667 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.Collection;
+import java.util.Objects;
+
+import org.apache.commons.io.file.PathUtils;
+import org.apache.commons.io.filefilter.FileFilterUtils;
+import org.apache.commons.io.filefilter.IOFileFilter;
+import org.apache.commons.io.filefilter.TrueFileFilter;
+
+/**
+ * Abstract class that walks through a directory hierarchy and provides subclasses with convenient hooks to add specific
+ * behavior.
+ * <p>
+ * This class operates with a {@link FileFilter} and maximum depth to limit the files and directories visited. Commons
+ * IO supplies many common filter implementations in the <a href="filefilter/package-summary.html"> filefilter</a>
+ * package.
+ * </p>
+ * <p>
+ * The following sections describe:
+ * </p>
+ * <ul>
+ * <li><a href="#example">1. Example Implementation</a> - example {@link FileCleaner} implementation.</li>
+ * <li><a href="#filter">2. Filter Example</a> - using {@link FileFilter}(s) with {@link DirectoryWalker}.</li>
+ * <li><a href="#cancel">3. Cancellation</a> - how to implement cancellation behavior.</li>
+ * </ul>
+ *
+ * <h2 id="example">1. Example Implementation</h2>
+ *
+ * There are many possible extensions, for example, to delete all files and '.svn' directories, and return a list of
+ * deleted files:
+ *
+ * <pre>
+ * public class FileCleaner extends DirectoryWalker {
+ *
+ *     public FileCleaner() {
+ *         super();
+ *     }
+ *
+ *     public List clean(File startDirectory) {
+ *         List results = new ArrayList();
+ *         walk(startDirectory, results);
+ *         return results;
+ *     }
+ *
+ *     protected boolean handleDirectory(File directory, int depth, Collection results) {
+ *         // delete svn directories and then skip
+ *         if (".svn".equals(directory.getName())) {
+ *             directory.delete();
+ *             return false;
+ *         } else {
+ *             return true;
+ *         }
+ *
+ *     }
+ *
+ *     protected void handleFile(File file, int depth, Collection results) {
+ *         // delete file and add to list of deleted
+ *         file.delete();
+ *         results.add(file);
+ *     }
+ * }
+ * </pre>
+ *
+ * <h2 id="filter">2. Filter Example</h2>
+ *
+ * <p>
+ * Choosing which directories and files to process can be a key aspect of using this class. This information can be
+ * setup in three ways, via three different constructors.
+ * </p>
+ * <p>
+ * The first option is to visit all directories and files. This is achieved via the no-args constructor.
+ * </p>
+ * <p>
+ * The second constructor option is to supply a single {@link FileFilter} that describes the files and directories to
+ * visit. Care must be taken with this option as the same filter is used for both directories and files.
+ * </p>
+ * <p>
+ * For example, if you wanted all directories which are not hidden and files which end in ".txt":
+ * </p>
+ *
+ * <pre>
+ * public class FooDirectoryWalker extends DirectoryWalker {
+ *     public FooDirectoryWalker(FileFilter filter) {
+ *         super(filter, -1);
+ *     }
+ * }
+ *
+ * // Build up the filters and create the walker
+ * // Create a filter for Non-hidden directories
+ * IOFileFilter fooDirFilter = FileFilterUtils.andFileFilter(FileFilterUtils.directoryFileFilter,
+ *     HiddenFileFilter.VISIBLE);
+ *
+ * // Create a filter for Files ending in ".txt"
+ * IOFileFilter fooFileFilter = FileFilterUtils.andFileFilter(FileFilterUtils.fileFileFilter,
+ *     FileFilterUtils.suffixFileFilter(".txt"));
+ *
+ * // Combine the directory and file filters using an OR condition
+ * java.io.FileFilter fooFilter = FileFilterUtils.orFileFilter(fooDirFilter, fooFileFilter);
+ *
+ * // Use the filter to construct a DirectoryWalker implementation
+ * FooDirectoryWalker walker = new FooDirectoryWalker(fooFilter);
+ * </pre>
+ * <p>
+ * The third constructor option is to specify separate filters, one for directories and one for files. These are
+ * combined internally to form the correct {@link FileFilter}, something which is very easy to get wrong when
+ * attempted manually, particularly when trying to express constructs like 'any file in directories named docs'.
+ * </p>
+ * <p>
+ * For example, if you wanted all directories which are not hidden and files which end in ".txt":
+ * </p>
+ *
+ * <pre>
+ *  public class FooDirectoryWalker extends DirectoryWalker {
+ *    public FooDirectoryWalker(IOFileFilter dirFilter, IOFileFilter fileFilter) {
+ *      super(dirFilter, fileFilter, -1);
+ *    }
+ *  }
+ *
+ *  // Use the filters to construct the walker
+ *  FooDirectoryWalker walker = new FooDirectoryWalker(
+ *    HiddenFileFilter.VISIBLE,
+ *    FileFilterUtils.suffixFileFilter(".txt"),
+ *  );
+ * </pre>
+ * <p>
+ * This is much simpler than the previous example, and is why it is the preferred option for filtering.
+ * </p>
+ *
+ * <h2 id="cancel">3. Cancellation</h2>
+ *
+ * <p>
+ * The DirectoryWalker contains some of the logic required for cancel processing. Subclasses must complete the
+ * implementation.
+ * </p>
+ * <p>
+ * What {@link DirectoryWalker} does provide for cancellation is:
+ * </p>
+ * <ul>
+ * <li>{@link CancelException} which can be thrown in any of the <i>lifecycle</i> methods to stop processing.</li>
+ * <li>The {@code walk()} method traps thrown {@link CancelException} and calls the {@code handleCancelled()}
+ * method, providing a place for custom cancel processing.</li>
+ * </ul>
+ * <p>
+ * Implementations need to provide:
+ * </p>
+ * <ul>
+ * <li>The decision logic on whether to cancel processing or not.</li>
+ * <li>Constructing and throwing a {@link CancelException}.</li>
+ * <li>Custom cancel processing in the {@code handleCancelled()} method.
+ * </ul>
+ * <p>
+ * Two possible scenarios are envisaged for cancellation:
+ * </p>
+ * <ul>
+ * <li><a href="#external">3.1 External / Multi-threaded</a> - cancellation being decided/initiated by an external
+ * process.</li>
+ * <li><a href="#internal">3.2 Internal</a> - cancellation being decided/initiated from within a DirectoryWalker
+ * implementation.</li>
+ * </ul>
+ * <p>
+ * The following sections provide example implementations for these two different scenarios.
+ * </p>
+ *
+ * <h3 id="external">3.1 External / Multi-threaded</h3>
+ *
+ * <p>
+ * This example provides a public {@code cancel()} method that can be called by another thread to stop the
+ * processing. A typical example use-case would be a cancel button on a GUI. Calling this method sets a
+ * <a href="http://java.sun.com/docs/books/jls/second_edition/html/classes.doc.html#36930"> volatile</a> flag to ensure
+ * it will work properly in a multi-threaded environment. The flag is returned by the {@code handleIsCancelled()}
+ * method, which will cause the walk to stop immediately. The {@code handleCancelled()} method will be the next,
+ * and last, callback method received once cancellation has occurred.
+ * </p>
+ *
+ * <pre>
+ * public class FooDirectoryWalker extends DirectoryWalker {
+ *
+ *     private volatile boolean cancelled = false;
+ *
+ *     public void cancel() {
+ *         cancelled = true;
+ *     }
+ *
+ *     protected boolean handleIsCancelled(File file, int depth, Collection results) {
+ *         return cancelled;
+ *     }
+ *
+ *     protected void handleCancelled(File startDirectory, Collection results, CancelException cancel) {
+ *         // implement processing required when a cancellation occurs
+ *     }
+ * }
+ * </pre>
+ *
+ * <h3 id="internal">3.2 Internal</h3>
+ *
+ * <p>
+ * This shows an example of how internal cancellation processing could be implemented. <b>Note</b> the decision logic
+ * and throwing a {@link CancelException} could be implemented in any of the <i>lifecycle</i> methods.
+ * </p>
+ *
+ * <pre>
+ * public class BarDirectoryWalker extends DirectoryWalker {
+ *
+ *     protected boolean handleDirectory(File directory, int depth, Collection results) throws IOException {
+ *         // cancel if hidden directory
+ *         if (directory.isHidden()) {
+ *             throw new CancelException(file, depth);
+ *         }
+ *         return true;
+ *     }
+ *
+ *     protected void handleFile(File file, int depth, Collection results) throws IOException {
+ *         // cancel if read-only file
+ *         if (!file.canWrite()) {
+ *             throw new CancelException(file, depth);
+ *         }
+ *         results.add(file);
+ *     }
+ *
+ *     protected void handleCancelled(File startDirectory, Collection results, CancelException cancel) {
+ *         // implement processing required when a cancellation occurs
+ *     }
+ * }
+ * </pre>
+ *
+ * @param <T> The result type, like {@link File}.
+ * @since 1.3
+ * @deprecated Apache Commons IO no longer uses this class. Instead, use
+ *             {@link PathUtils#walk(java.nio.file.Path, org.apache.commons.io.file.PathFilter, int, boolean, java.nio.file.FileVisitOption...)}
+ *             or {@link Files#walkFileTree(java.nio.file.Path, java.util.Set, int, java.nio.file.FileVisitor)}, and
+ *             friends.
+ */
+@Deprecated
+public abstract class DirectoryWalker<T> {
+
+    /**
+     * CancelException is thrown in DirectoryWalker to cancel the current
+     * processing.
+     */
+    public static class CancelException extends IOException {
+
+        /** Serialization id. */
+        private static final long serialVersionUID = 1347339620135041008L;
+
+        /** The file being processed when the exception was thrown. */
+        private final File file;
+        /** The file depth when the exception was thrown. */
+        private final int depth;
+
+        /**
+         * Constructs a {@link CancelException} with
+         * the file and depth when cancellation occurred.
+         *
+         * @param file  the file when the operation was cancelled, may be null
+         * @param depth  the depth when the operation was cancelled, may be null
+         */
+        public CancelException(final File file, final int depth) {
+            this("Operation Cancelled", file, depth);
+        }
+
+        /**
+         * Constructs a {@link CancelException} with
+         * an appropriate message and the file and depth when
+         * cancellation occurred.
+         *
+         * @param message  the detail message
+         * @param file  the file when the operation was cancelled
+         * @param depth  the depth when the operation was cancelled
+         */
+        public CancelException(final String message, final File file, final int depth) {
+            super(message);
+            this.file = file;
+            this.depth = depth;
+        }
+
+        /**
+         * Returns the depth when the operation was cancelled.
+         *
+         * @return the depth when the operation was cancelled
+         */
+        public int getDepth() {
+            return depth;
+        }
+
+        /**
+         * Returns the file when the operation was cancelled.
+         *
+         * @return the file when the operation was cancelled
+         */
+        public File getFile() {
+            return file;
+        }
+    }
+    /**
+     * The file filter to use to filter files and directories.
+     */
+    private final FileFilter filter;
+
+    /**
+     * The limit on the directory depth to walk.
+     */
+    private final int depthLimit;
+
+    /**
+     * Constructs an instance with no filtering and unlimited <i>depth</i>.
+     */
+    protected DirectoryWalker() {
+        this(null, -1);
+    }
+
+    /**
+     * Constructs an instance with a filter and limit the <i>depth</i> navigated to.
+     * <p>
+     * The filter controls which files and directories will be navigated to as
+     * part of the walk. The {@link FileFilterUtils} class is useful for combining
+     * various filters together. A {@code null} filter means that no
+     * filtering should occur and all files and directories will be visited.
+     * </p>
+     *
+     * @param filter  the filter to apply, null means visit all files
+     * @param depthLimit  controls how <i>deep</i> the hierarchy is
+     *  navigated to (less than 0 means unlimited)
+     */
+    protected DirectoryWalker(final FileFilter filter, final int depthLimit) {
+        this.filter = filter;
+        this.depthLimit = depthLimit;
+    }
+
+    /**
+     * Constructs an instance with a directory and a file filter and an optional
+     * limit on the <i>depth</i> navigated to.
+     * <p>
+     * The filters control which files and directories will be navigated to as part
+     * of the walk. This constructor uses {@link FileFilterUtils#makeDirectoryOnly(IOFileFilter)}
+     * and {@link FileFilterUtils#makeFileOnly(IOFileFilter)} internally to combine the filters.
+     * A {@code null} filter means that no filtering should occur.
+     * </p>
+     *
+     * @param directoryFilter  the filter to apply to directories, null means visit all directories
+     * @param fileFilter  the filter to apply to files, null means visit all files
+     * @param depthLimit  controls how <i>deep</i> the hierarchy is
+     *  navigated to (less than 0 means unlimited)
+     */
+    protected DirectoryWalker(IOFileFilter directoryFilter, IOFileFilter fileFilter, final int depthLimit) {
+        if (directoryFilter == null && fileFilter == null) {
+            this.filter = null;
+        } else {
+            directoryFilter = directoryFilter != null ? directoryFilter : TrueFileFilter.TRUE;
+            fileFilter = fileFilter != null ? fileFilter : TrueFileFilter.TRUE;
+            directoryFilter = FileFilterUtils.makeDirectoryOnly(directoryFilter);
+            fileFilter = FileFilterUtils.makeFileOnly(fileFilter);
+            this.filter = directoryFilter.or(fileFilter);
+        }
+        this.depthLimit = depthLimit;
+    }
+
+    /**
+     * Checks whether the walk has been cancelled by calling {@link #handleIsCancelled},
+     * throwing a {@link CancelException} if it has.
+     * <p>
+     * Writers of subclasses should not normally call this method as it is called
+     * automatically by the walk of the tree. However, sometimes a single method,
+     * typically {@link #handleFile}, may take a long time to run. In that case,
+     * you may wish to check for cancellation by calling this method.
+     * </p>
+     *
+     * @param file  the current file being processed
+     * @param depth  the current file level (starting directory = 0)
+     * @param results  the collection of result objects, may be updated
+     * @throws IOException if an I/O Error occurs
+     */
+    protected final void checkIfCancelled(final File file, final int depth, final Collection<T> results) throws
+            IOException {
+        if (handleIsCancelled(file, depth, results)) {
+            throw new CancelException(file, depth);
+        }
+    }
+
+    /**
+     * Overridable callback method invoked with the contents of each directory.
+     * <p>
+     * This implementation returns the files unchanged
+     * </p>
+     *
+     * @param directory  the current directory being processed
+     * @param depth  the current directory level (starting directory = 0)
+     * @param files the files (possibly filtered) in the directory, may be {@code null}
+     * @return the filtered list of files
+     * @throws IOException if an I/O Error occurs
+     * @since 2.0
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected File[] filterDirectoryContents(final File directory, final int depth, final File... files) throws
+            IOException {
+        return files;
+    }
+
+    /**
+     * Overridable callback method invoked when the operation is cancelled.
+     * The file being processed when the cancellation occurred can be
+     * obtained from the exception.
+     * <p>
+     * This implementation just re-throws the {@link CancelException}.
+     * </p>
+     *
+     * @param startDirectory  the directory that the walk started from
+     * @param results  the collection of result objects, may be updated
+     * @param cancel  the exception throw to cancel further processing
+     * containing details at the point of cancellation.
+     * @throws IOException if an I/O Error occurs
+     */
+    protected void handleCancelled(final File startDirectory, final Collection<T> results,
+                       final CancelException cancel) throws IOException {
+        // re-throw exception - overridable by subclass
+        throw cancel;
+    }
+
+    /**
+     * Overridable callback method invoked to determine if a directory should be processed.
+     * <p>
+     * This method returns a boolean to indicate if the directory should be examined or not.
+     * If you return false, the entire directory and any subdirectories will be skipped.
+     * Note that this functionality is in addition to the filtering by file filter.
+     * </p>
+     * <p>
+     * This implementation does nothing and returns true.
+     * </p>
+     *
+     * @param directory  the current directory being processed
+     * @param depth  the current directory level (starting directory = 0)
+     * @param results  the collection of result objects, may be updated
+     * @return true to process this directory, false to skip this directory
+     * @throws IOException if an I/O Error occurs
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected boolean handleDirectory(final File directory, final int depth, final Collection<T> results) throws
+            IOException {
+        // do nothing - overridable by subclass
+        return true;  // process directory
+    }
+
+    /**
+     * Overridable callback method invoked at the end of processing each directory.
+     * <p>
+     * This implementation does nothing.
+     * </p>
+     *
+     * @param directory  the directory being processed
+     * @param depth  the current directory level (starting directory = 0)
+     * @param results  the collection of result objects, may be updated
+     * @throws IOException if an I/O Error occurs
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected void handleDirectoryEnd(final File directory, final int depth, final Collection<T> results) throws
+            IOException {
+        // do nothing - overridable by subclass
+    }
+
+    /**
+     * Overridable callback method invoked at the start of processing each directory.
+     * <p>
+     * This implementation does nothing.
+     * </p>
+     *
+     * @param directory  the current directory being processed
+     * @param depth  the current directory level (starting directory = 0)
+     * @param results  the collection of result objects, may be updated
+     * @throws IOException if an I/O Error occurs
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected void handleDirectoryStart(final File directory, final int depth, final Collection<T> results) throws
+            IOException {
+        // do nothing - overridable by subclass
+    }
+
+    /**
+     * Overridable callback method invoked at the end of processing.
+     * <p>
+     * This implementation does nothing.
+     * </p>
+     *
+     * @param results  the collection of result objects, may be updated
+     * @throws IOException if an I/O Error occurs
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected void handleEnd(final Collection<T> results) throws IOException {
+        // do nothing - overridable by subclass
+    }
+
+    /**
+     * Overridable callback method invoked for each (non-directory) file.
+     * <p>
+     * This implementation does nothing.
+     * </p>
+     *
+     * @param file  the current file being processed
+     * @param depth  the current directory level (starting directory = 0)
+     * @param results  the collection of result objects, may be updated
+     * @throws IOException if an I/O Error occurs
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected void handleFile(final File file, final int depth, final Collection<T> results) throws IOException {
+        // do nothing - overridable by subclass
+    }
+
+    /**
+     * Overridable callback method invoked to determine if the entire walk
+     * operation should be immediately cancelled.
+     * <p>
+     * This method should be implemented by those subclasses that want to
+     * provide a public {@code cancel()} method available from another
+     * thread. The design pattern for the subclass should be as follows:
+     * </p>
+     * <pre>
+     *  public class FooDirectoryWalker extends DirectoryWalker {
+     *    private volatile boolean cancelled = false;
+     *
+     *    public void cancel() {
+     *        cancelled = true;
+     *    }
+     *    private void handleIsCancelled(File file, int depth, Collection results) {
+     *        return cancelled;
+     *    }
+     *    protected void handleCancelled(File startDirectory,
+     *              Collection results, CancelException cancel) {
+     *        // implement processing required when a cancellation occurs
+     *    }
+     *  }
+     * </pre>
+     * <p>
+     * If this method returns true, then the directory walk is immediately
+     * cancelled. The next callback method will be {@link #handleCancelled}.
+     * </p>
+     * <p>
+     * This implementation returns false.
+     * </p>
+     *
+     * @param file  the file or directory being processed
+     * @param depth  the current directory level (starting directory = 0)
+     * @param results  the collection of result objects, may be updated
+     * @return true if the walk has been cancelled
+     * @throws IOException if an I/O Error occurs
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected boolean handleIsCancelled(
+            final File file, final int depth, final Collection<T> results) throws IOException {
+        // do nothing - overridable by subclass
+        return false;  // not cancelled
+    }
+
+    /**
+     * Overridable callback method invoked for each restricted directory.
+     * <p>
+     * This implementation does nothing.
+     * </p>
+     *
+     * @param directory  the restricted directory
+     * @param depth  the current directory level (starting directory = 0)
+     * @param results  the collection of result objects, may be updated
+     * @throws IOException if an I/O Error occurs
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected void handleRestricted(final File directory, final int depth, final Collection<T> results) throws
+            IOException {
+        // do nothing - overridable by subclass
+    }
+
+    /**
+     * Overridable callback method invoked at the start of processing.
+     * <p>
+     * This implementation does nothing.
+     * </p>
+     *
+     * @param startDirectory  the directory to start from
+     * @param results  the collection of result objects, may be updated
+     * @throws IOException if an I/O Error occurs
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected void handleStart(final File startDirectory, final Collection<T> results) throws IOException {
+        // do nothing - overridable by subclass
+    }
+
+    /**
+     * Internal method that walks the directory hierarchy in a depth-first manner.
+     * <p>
+     * Users of this class do not need to call this method. This method will
+     * be called automatically by another (public) method on the specific subclass.
+     * </p>
+     * <p>
+     * Writers of subclasses should call this method to start the directory walk.
+     * Once called, this method will emit events as it walks the hierarchy.
+     * The event methods have the prefix {@code handle}.
+     * </p>
+     *
+     * @param startDirectory  the directory to start from, not null
+     * @param results  the collection of result objects, may be updated
+     * @throws NullPointerException if the start directory is null
+     * @throws IOException if an I/O Error occurs
+     */
+    protected final void walk(final File startDirectory, final Collection<T> results) throws IOException {
+        Objects.requireNonNull(startDirectory, "startDirectory");
+        try {
+            handleStart(startDirectory, results);
+            walk(startDirectory, 0, results);
+            handleEnd(results);
+        } catch(final CancelException cancel) {
+            handleCancelled(startDirectory, results, cancel);
+        }
+    }
+
+    /**
+     * Main recursive method to examine the directory hierarchy.
+     *
+     * @param directory  the directory to examine, not null
+     * @param depth  the directory level (starting directory = 0)
+     * @param results  the collection of result objects, may be updated
+     * @throws IOException if an I/O Error occurs
+     */
+    private void walk(final File directory, final int depth, final Collection<T> results) throws IOException {
+        checkIfCancelled(directory, depth, results);
+        if (handleDirectory(directory, depth, results)) {
+            handleDirectoryStart(directory, depth, results);
+            final int childDepth = depth + 1;
+            if (depthLimit < 0 || childDepth <= depthLimit) {
+                checkIfCancelled(directory, depth, results);
+                File[] childFiles = filter == null ? directory.listFiles() : directory.listFiles(filter);
+                childFiles = filterDirectoryContents(directory, depth, childFiles);
+                if (childFiles == null) {
+                    handleRestricted(directory, childDepth, results);
+                } else {
+                    for (final File childFile : childFiles) {
+                        if (childFile.isDirectory()) {
+                            walk(childFile, childDepth, results);
+                        } else {
+                            checkIfCancelled(childFile, childDepth, results);
+                            handleFile(childFile, childDepth, results);
+                            checkIfCancelled(childFile, childDepth, results);
+                        }
+                    }
+                }
+            }
+            handleDirectoryEnd(directory, depth, results);
+        }
+        checkIfCancelled(directory, depth, results);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/EndianUtils.java b/src/main/java/org/apache/commons/io/EndianUtils.java
new file mode 100644
index 0000000..acc9531
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/EndianUtils.java
@@ -0,0 +1,437 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Helps with different endian systems.
+ * <p>
+ * Different computer architectures adopt different conventions for
+ * byte ordering. In so-called "Little Endian" architectures (eg Intel),
+ * the low-order byte is stored in memory at the lowest address, and
+ * subsequent bytes at higher addresses. For "Big Endian" architectures
+ * (eg Motorola), the situation is reversed.
+ * This class helps you solve this incompatibility.
+ * </p>
+ * <p>
+ * Origin of code: Excalibur
+ * </p>
+ *
+ * @see org.apache.commons.io.input.SwappedDataInputStream
+ */
+public class EndianUtils {
+
+    /**
+     * Reads the next byte from the input stream.
+     * @param input  the stream
+     * @return the byte
+     * @throws IOException if the end of file is reached
+     */
+    private static int read(final InputStream input) throws IOException {
+        final int value = input.read();
+        if (EOF == value) {
+            throw new EOFException("Unexpected EOF reached");
+        }
+        return value;
+    }
+
+    /**
+     * Reads a "double" value from a byte array at a given offset. The value is
+     * converted to the opposed endian system while reading.
+     * @param data source byte array
+     * @param offset starting offset in the byte array
+     * @return the value read
+     */
+    public static double readSwappedDouble(final byte[] data, final int offset) {
+        return Double.longBitsToDouble(readSwappedLong(data, offset));
+    }
+
+    /**
+     * Reads a "double" value from an InputStream. The value is
+     * converted to the opposed endian system while reading.
+     * @param input source InputStream
+     * @return the value just read
+     * @throws IOException in case of an I/O problem
+     */
+    public static double readSwappedDouble(final InputStream input) throws IOException {
+        return Double.longBitsToDouble(readSwappedLong(input));
+    }
+
+    /**
+     * Reads a "float" value from a byte array at a given offset. The value is
+     * converted to the opposed endian system while reading.
+     * @param data source byte array
+     * @param offset starting offset in the byte array
+     * @return the value read
+     */
+    public static float readSwappedFloat(final byte[] data, final int offset) {
+        return Float.intBitsToFloat(readSwappedInteger(data, offset));
+    }
+
+    /**
+     * Reads a "float" value from an InputStream. The value is
+     * converted to the opposed endian system while reading.
+     * @param input source InputStream
+     * @return the value just read
+     * @throws IOException in case of an I/O problem
+     */
+    public static float readSwappedFloat(final InputStream input) throws IOException {
+        return Float.intBitsToFloat(readSwappedInteger(input));
+    }
+
+    /**
+     * Reads an "int" value from a byte array at a given offset. The value is
+     * converted to the opposed endian system while reading.
+     * @param data source byte array
+     * @param offset starting offset in the byte array
+     * @return the value read
+     */
+    public static int readSwappedInteger(final byte[] data, final int offset) {
+        return ((data[offset + 0] & 0xff) << 0) +
+            ((data[offset + 1] & 0xff) << 8) +
+            ((data[offset + 2] & 0xff) << 16) +
+            ((data[offset + 3] & 0xff) << 24);
+    }
+
+    /**
+     * Reads an "int" value from an InputStream. The value is
+     * converted to the opposed endian system while reading.
+     * @param input source InputStream
+     * @return the value just read
+     * @throws IOException in case of an I/O problem
+     */
+    public static int readSwappedInteger(final InputStream input) throws IOException {
+        final int value1 = read(input);
+        final int value2 = read(input);
+        final int value3 = read(input);
+        final int value4 = read(input);
+        return ((value1 & 0xff) << 0) + ((value2 & 0xff) << 8) + ((value3 & 0xff) << 16) + ((value4 & 0xff) << 24);
+    }
+
+    /**
+     * Reads a "long" value from a byte array at a given offset. The value is
+     * converted to the opposed endian system while reading.
+     * @param data source byte array
+     * @param offset starting offset in the byte array
+     * @return the value read
+     */
+    public static long readSwappedLong(final byte[] data, final int offset) {
+        final long low = readSwappedInteger(data, offset);
+        final long high = readSwappedInteger(data, offset + 4);
+        return (high << 32) + (0xffffffffL & low);
+    }
+
+    /**
+     * Reads a "long" value from an InputStream. The value is
+     * converted to the opposed endian system while reading.
+     * @param input source InputStream
+     * @return the value just read
+     * @throws IOException in case of an I/O problem
+     */
+    public static long readSwappedLong(final InputStream input) throws IOException {
+        final byte[] bytes = new byte[8];
+        for (int i = 0; i < 8; i++) {
+            bytes[i] = (byte) read(input);
+        }
+        return readSwappedLong(bytes, 0);
+    }
+
+    /**
+     * Reads a "short" value from a byte array at a given offset. The value is
+     * converted to the opposed endian system while reading.
+     * @param data source byte array
+     * @param offset starting offset in the byte array
+     * @return the value read
+     */
+    public static short readSwappedShort(final byte[] data, final int offset) {
+        return (short)(((data[offset + 0] & 0xff) << 0) +
+            ((data[offset + 1] & 0xff) << 8));
+    }
+
+    /**
+     * Reads a "short" value from an InputStream. The value is
+     * converted to the opposed endian system while reading.
+     * @param input source InputStream
+     * @return the value just read
+     * @throws IOException in case of an I/O problem
+     */
+    public static short readSwappedShort(final InputStream input) throws IOException {
+        return (short) (((read(input) & 0xff) << 0) + ((read(input) & 0xff) << 8));
+    }
+
+    /**
+     * Reads an unsigned integer (32-bit) value from a byte array at a given
+     * offset. The value is converted to the opposed endian system while
+     * reading.
+     * @param data source byte array
+     * @param offset starting offset in the byte array
+     * @return the value read
+     */
+    public static long readSwappedUnsignedInteger(final byte[] data, final int offset) {
+        final long low = ((data[offset + 0] & 0xff) << 0) +
+                     ((data[offset + 1] & 0xff) << 8) +
+                     ((data[offset + 2] & 0xff) << 16);
+        final long high = data[offset + 3] & 0xff;
+        return (high << 24) + (0xffffffffL & low);
+    }
+
+    /**
+     * Reads an unsigned integer (32-bit) from an InputStream. The value is
+     * converted to the opposed endian system while reading.
+     * @param input source InputStream
+     * @return the value just read
+     * @throws IOException in case of an I/O problem
+     */
+    public static long readSwappedUnsignedInteger(final InputStream input) throws IOException {
+        final int value1 = read(input);
+        final int value2 = read(input);
+        final int value3 = read(input);
+        final int value4 = read(input);
+        final long low = ((value1 & 0xff) << 0) + ((value2 & 0xff) << 8) + ((value3 & 0xff) << 16);
+        final long high = value4 & 0xff;
+        return (high << 24) + (0xffffffffL & low);
+    }
+
+    /**
+     * Reads an unsigned short (16-bit) value from a byte array at a given
+     * offset. The value is converted to the opposed endian system while
+     * reading.
+     * @param data source byte array
+     * @param offset starting offset in the byte array
+     * @return the value read
+     */
+    public static int readSwappedUnsignedShort(final byte[] data, final int offset) {
+        return ((data[offset + 0] & 0xff) << 0) +
+            ((data[offset + 1] & 0xff) << 8);
+    }
+
+    /**
+     * Reads an unsigned short (16-bit) from an InputStream. The value is
+     * converted to the opposed endian system while reading.
+     * @param input source InputStream
+     * @return the value just read
+     * @throws IOException in case of an I/O problem
+     */
+    public static int readSwappedUnsignedShort(final InputStream input) throws IOException {
+        final int value1 = read(input);
+        final int value2 = read(input);
+
+        return ((value1 & 0xff) << 0) + ((value2 & 0xff) << 8);
+    }
+
+    /**
+     * Converts a "double" value between endian systems.
+     * @param value value to convert
+     * @return the converted value
+     */
+    public static double swapDouble(final double value) {
+        return Double.longBitsToDouble(swapLong(Double.doubleToLongBits(value)));
+    }
+
+    /**
+     * Converts a "float" value between endian systems.
+     * @param value value to convert
+     * @return the converted value
+     */
+    public static float swapFloat(final float value) {
+        return Float.intBitsToFloat(swapInteger(Float.floatToIntBits(value)));
+    }
+
+    /**
+     * Converts an "int" value between endian systems.
+     * @param value value to convert
+     * @return the converted value
+     */
+    public static int swapInteger(final int value) {
+        return
+            ((value >> 0 & 0xff) << 24) +
+            ((value >> 8 & 0xff) << 16) +
+            ((value >> 16 & 0xff) << 8) +
+            ((value >> 24 & 0xff) << 0);
+    }
+
+    /**
+     * Converts a "long" value between endian systems.
+     * @param value value to convert
+     * @return the converted value
+     */
+    public static long swapLong(final long value) {
+        return
+            ((value >> 0 & 0xff) << 56) +
+            ((value >> 8 & 0xff) << 48) +
+            ((value >> 16 & 0xff) << 40) +
+            ((value >> 24 & 0xff) << 32) +
+            ((value >> 32 & 0xff) << 24) +
+            ((value >> 40 & 0xff) << 16) +
+            ((value >> 48 & 0xff) << 8) +
+            ((value >> 56 & 0xff) << 0);
+    }
+
+    /**
+     * Converts a "short" value between endian systems.
+     * @param value value to convert
+     * @return the converted value
+     */
+    public static short swapShort(final short value) {
+        return (short) (((value >> 0 & 0xff) << 8) +
+            ((value >> 8 & 0xff) << 0));
+    }
+
+    /**
+     * Writes a "double" value to a byte array at a given offset. The value is
+     * converted to the opposed endian system while writing.
+     * @param data target byte array
+     * @param offset starting offset in the byte array
+     * @param value value to write
+     */
+    public static void writeSwappedDouble(final byte[] data, final int offset, final double value) {
+        writeSwappedLong(data, offset, Double.doubleToLongBits(value));
+    }
+
+    /**
+     * Writes a "double" value to an OutputStream. The value is
+     * converted to the opposed endian system while writing.
+     * @param output target OutputStream
+     * @param value value to write
+     * @throws IOException in case of an I/O problem
+     */
+    public static void writeSwappedDouble(final OutputStream output, final double value) throws IOException {
+        writeSwappedLong(output, Double.doubleToLongBits(value));
+    }
+
+    /**
+     * Writes a "float" value to a byte array at a given offset. The value is
+     * converted to the opposed endian system while writing.
+     * @param data target byte array
+     * @param offset starting offset in the byte array
+     * @param value value to write
+     */
+    public static void writeSwappedFloat(final byte[] data, final int offset, final float value) {
+        writeSwappedInteger(data, offset, Float.floatToIntBits(value));
+    }
+
+    /**
+     * Writes a "float" value to an OutputStream. The value is
+     * converted to the opposed endian system while writing.
+     * @param output target OutputStream
+     * @param value value to write
+     * @throws IOException in case of an I/O problem
+     */
+    public static void writeSwappedFloat(final OutputStream output, final float value) throws IOException {
+        writeSwappedInteger(output, Float.floatToIntBits(value));
+    }
+
+    /**
+     * Writes an "int" value to a byte array at a given offset. The value is
+     * converted to the opposed endian system while writing.
+     * @param data target byte array
+     * @param offset starting offset in the byte array
+     * @param value value to write
+     */
+    public static void writeSwappedInteger(final byte[] data, final int offset, final int value) {
+        data[offset + 0] = (byte) (value >> 0 & 0xff);
+        data[offset + 1] = (byte) (value >> 8 & 0xff);
+        data[offset + 2] = (byte) (value >> 16 & 0xff);
+        data[offset + 3] = (byte) (value >> 24 & 0xff);
+    }
+
+    /**
+     * Writes an "int" value to an OutputStream. The value is converted to the opposed endian system while writing.
+     *
+     * @param output target OutputStream
+     * @param value value to write
+     * @throws IOException in case of an I/O problem
+     */
+    public static void writeSwappedInteger(final OutputStream output, final int value) throws IOException {
+        output.write((byte) (value >> 0 & 0xff));
+        output.write((byte) (value >> 8 & 0xff));
+        output.write((byte) (value >> 16 & 0xff));
+        output.write((byte) (value >> 24 & 0xff));
+    }
+
+    /**
+     * Writes a "long" value to a byte array at a given offset. The value is
+     * converted to the opposed endian system while writing.
+     * @param data target byte array
+     * @param offset starting offset in the byte array
+     * @param value value to write
+     */
+    public static void writeSwappedLong(final byte[] data, final int offset, final long value) {
+        data[offset + 0] = (byte) (value >> 0 & 0xff);
+        data[offset + 1] = (byte) (value >> 8 & 0xff);
+        data[offset + 2] = (byte) (value >> 16 & 0xff);
+        data[offset + 3] = (byte) (value >> 24 & 0xff);
+        data[offset + 4] = (byte) (value >> 32 & 0xff);
+        data[offset + 5] = (byte) (value >> 40 & 0xff);
+        data[offset + 6] = (byte) (value >> 48 & 0xff);
+        data[offset + 7] = (byte) (value >> 56 & 0xff);
+    }
+
+    /**
+     * Writes a "long" value to an OutputStream. The value is
+     * converted to the opposed endian system while writing.
+     * @param output target OutputStream
+     * @param value value to write
+     * @throws IOException in case of an I/O problem
+     */
+    public static void writeSwappedLong(final OutputStream output, final long value) throws IOException {
+        output.write((byte) (value >> 0 & 0xff));
+        output.write((byte) (value >> 8 & 0xff));
+        output.write((byte) (value >> 16 & 0xff));
+        output.write((byte) (value >> 24 & 0xff));
+        output.write((byte) (value >> 32 & 0xff));
+        output.write((byte) (value >> 40 & 0xff));
+        output.write((byte) (value >> 48 & 0xff));
+        output.write((byte) (value >> 56 & 0xff));
+    }
+
+    /**
+     * Writes a "short" value to a byte array at a given offset. The value is
+     * converted to the opposed endian system while writing.
+     * @param data target byte array
+     * @param offset starting offset in the byte array
+     * @param value value to write
+     */
+    public static void writeSwappedShort(final byte[] data, final int offset, final short value) {
+        data[offset + 0] = (byte)(value >> 0 & 0xff);
+        data[offset + 1] = (byte)(value >> 8 & 0xff);
+    }
+
+    /**
+     * Writes a "short" value to an OutputStream. The value is
+     * converted to the opposed endian system while writing.
+     * @param output target OutputStream
+     * @param value value to write
+     * @throws IOException in case of an I/O problem
+     */
+    public static void writeSwappedShort(final OutputStream output, final short value) throws IOException {
+        output.write((byte) (value >> 0 & 0xff));
+        output.write((byte) (value >> 8 & 0xff));
+    }
+
+    /**
+     * Instances should NOT be constructed in standard programming.
+     */
+    public EndianUtils() {
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/FileCleaner.java b/src/main/java/org/apache/commons/io/FileCleaner.java
new file mode 100644
index 0000000..609d34b
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/FileCleaner.java
@@ -0,0 +1,158 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.File;
+
+/**
+ * Keeps track of files awaiting deletion, and deletes them when an associated
+ * marker object is reclaimed by the garbage collector.
+ * <p>
+ * This utility creates a background thread to handle file deletion.
+ * Each file to be deleted is registered with a handler object.
+ * When the handler object is garbage collected, the file is deleted.
+ * <p>
+ * In an environment with multiple class loaders (a servlet container, for
+ * example), you should consider stopping the background thread if it is no
+ * longer needed. This is done by invoking the method
+ * {@link #exitWhenFinished}, typically in
+ * {@code javax.servlet.ServletContextListener.contextDestroyed(javax.servlet.ServletContextEvent)} or similar.
+ *
+ * @deprecated Use {@link FileCleaningTracker}
+ */
+@Deprecated
+public class FileCleaner {
+
+    /**
+     * The instance to use for the deprecated, static methods.
+     */
+    private static final FileCleaningTracker INSTANCE = new FileCleaningTracker();
+
+    /**
+     * Call this method to cause the file cleaner thread to terminate when
+     * there are no more objects being tracked for deletion.
+     * <p>
+     * In a simple environment, you don't need this method as the file cleaner
+     * thread will simply exit when the JVM exits. In a more complex environment,
+     * with multiple class loaders (such as an application server), you should be
+     * aware that the file cleaner thread will continue running even if the class
+     * loader it was started from terminates. This can constitute a memory leak.
+     * <p>
+     * For example, suppose that you have developed a web application, which
+     * contains the commons-io jar file in your WEB-INF/lib directory. In other
+     * words, the FileCleaner class is loaded through the class loader of your
+     * web application. If the web application is terminated, but the servlet
+     * container is still running, then the file cleaner thread will still exist,
+     * posing a memory leak.
+     * <p>
+     * This method allows the thread to be terminated. Simply call this method
+     * in the resource cleanup code, such as
+     * {@code javax.servlet.ServletContextListener.contextDestroyed(javax.servlet.ServletContextEvent)}.
+     * One called, no new objects can be tracked by the file cleaner.
+     * @deprecated Use {@link FileCleaningTracker#exitWhenFinished()}.
+     */
+    @Deprecated
+    public static synchronized void exitWhenFinished() {
+        INSTANCE.exitWhenFinished();
+    }
+
+    /**
+     * Returns the singleton instance, which is used by the deprecated, static methods.
+     * This is mainly useful for code, which wants to support the new
+     * {@link FileCleaningTracker} class while maintain compatibility with the
+     * deprecated {@link FileCleaner}.
+     *
+     * @return the singleton instance
+     */
+    public static FileCleaningTracker getInstance() {
+        return INSTANCE;
+    }
+
+    /**
+     * Retrieve the number of files currently being tracked, and therefore
+     * awaiting deletion.
+     *
+     * @return the number of files being tracked
+     * @deprecated Use {@link FileCleaningTracker#getTrackCount()}.
+     */
+    @Deprecated
+    public static int getTrackCount() {
+        return INSTANCE.getTrackCount();
+    }
+
+    /**
+     * Track the specified file, using the provided marker, deleting the file
+     * when the marker instance is garbage collected.
+     * The {@link FileDeleteStrategy#NORMAL normal} deletion strategy will be used.
+     *
+     * @param file  the file to be tracked, not null
+     * @param marker  the marker object used to track the file, not null
+     * @throws NullPointerException if the file is null
+     * @deprecated Use {@link FileCleaningTracker#track(File, Object)}.
+     */
+    @Deprecated
+    public static void track(final File file, final Object marker) {
+        INSTANCE.track(file, marker);
+    }
+
+    /**
+     * Track the specified file, using the provided marker, deleting the file
+     * when the marker instance is garbage collected.
+     * The specified deletion strategy is used.
+     *
+     * @param file  the file to be tracked, not null
+     * @param marker  the marker object used to track the file, not null
+     * @param deleteStrategy  the strategy to delete the file, null means normal
+     * @throws NullPointerException if the file is null
+     * @deprecated Use {@link FileCleaningTracker#track(File, Object, FileDeleteStrategy)}.
+     */
+    @Deprecated
+    public static void track(final File file, final Object marker, final FileDeleteStrategy deleteStrategy) {
+        INSTANCE.track(file, marker, deleteStrategy);
+    }
+
+    /**
+     * Track the specified file, using the provided marker, deleting the file
+     * when the marker instance is garbage collected.
+     * The {@link FileDeleteStrategy#NORMAL normal} deletion strategy will be used.
+     *
+     * @param path  the full path to the file to be tracked, not null
+     * @param marker  the marker object used to track the file, not null
+     * @throws NullPointerException if the path is null
+     * @deprecated Use {@link FileCleaningTracker#track(String, Object)}.
+     */
+    @Deprecated
+    public static void track(final String path, final Object marker) {
+        INSTANCE.track(path, marker);
+    }
+
+    /**
+     * Track the specified file, using the provided marker, deleting the file
+     * when the marker instance is garbage collected.
+     * The specified deletion strategy is used.
+     *
+     * @param path  the full path to the file to be tracked, not null
+     * @param marker  the marker object used to track the file, not null
+     * @param deleteStrategy  the strategy to delete the file, null means normal
+     * @throws NullPointerException if the path is null
+     * @deprecated Use {@link FileCleaningTracker#track(String, Object, FileDeleteStrategy)}.
+     */
+    @Deprecated
+    public static void track(final String path, final Object marker, final FileDeleteStrategy deleteStrategy) {
+        INSTANCE.track(path, marker, deleteStrategy);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/FileCleaningTracker.java b/src/main/java/org/apache/commons/io/FileCleaningTracker.java
new file mode 100644
index 0000000..e4787c7
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/FileCleaningTracker.java
@@ -0,0 +1,280 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.File;
+import java.lang.ref.PhantomReference;
+import java.lang.ref.ReferenceQueue;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Keeps track of files awaiting deletion, and deletes them when an associated
+ * marker object is reclaimed by the garbage collector.
+ * <p>
+ * This utility creates a background thread to handle file deletion.
+ * Each file to be deleted is registered with a handler object.
+ * When the handler object is garbage collected, the file is deleted.
+ * <p>
+ * In an environment with multiple class loaders (a servlet container, for
+ * example), you should consider stopping the background thread if it is no
+ * longer needed. This is done by invoking the method
+ * {@link #exitWhenFinished}, typically in
+ * {@code javax.servlet.ServletContextListener.contextDestroyed(javax.servlet.ServletContextEvent)} or similar.
+ *
+ */
+public class FileCleaningTracker {
+
+    // Note: fields are package protected to allow use by test cases
+
+    /**
+     * The reaper thread.
+     */
+    private final class Reaper extends Thread {
+        /** Constructs a new Reaper */
+        Reaper() {
+            super("File Reaper");
+            setPriority(Thread.MAX_PRIORITY);
+            setDaemon(true);
+        }
+
+        /**
+         * Run the reaper thread that will delete files as their associated
+         * marker objects are reclaimed by the garbage collector.
+         */
+        @Override
+        public void run() {
+            // thread exits when exitWhenFinished is true and there are no more tracked objects
+            while (!exitWhenFinished || !trackers.isEmpty()) {
+                try {
+                    // Wait for a tracker to remove.
+                    final Tracker tracker = (Tracker) q.remove(); // cannot return null
+                    trackers.remove(tracker);
+                    if (!tracker.delete()) {
+                        deleteFailures.add(tracker.getPath());
+                    }
+                    tracker.clear();
+                } catch (final InterruptedException e) {
+                    continue;
+                }
+            }
+        }
+    }
+    /**
+     * Inner class which acts as the reference for a file pending deletion.
+     */
+    private static final class Tracker extends PhantomReference<Object> {
+
+        /**
+         * The full path to the file being tracked.
+         */
+        private final String path;
+        /**
+         * The strategy for deleting files.
+         */
+        private final FileDeleteStrategy deleteStrategy;
+
+        /**
+         * Constructs an instance of this class from the supplied parameters.
+         *
+         * @param path  the full path to the file to be tracked, not null
+         * @param deleteStrategy  the strategy to delete the file, null means normal
+         * @param marker  the marker object used to track the file, not null
+         * @param queue  the queue on to which the tracker will be pushed, not null
+         */
+        Tracker(final String path, final FileDeleteStrategy deleteStrategy, final Object marker,
+                final ReferenceQueue<? super Object> queue) {
+            super(marker, queue);
+            this.path = path;
+            this.deleteStrategy = deleteStrategy == null ? FileDeleteStrategy.NORMAL : deleteStrategy;
+        }
+
+        /**
+         * Deletes the file associated with this tracker instance.
+         *
+         * @return {@code true} if the file was deleted successfully;
+         *         {@code false} otherwise.
+         */
+        public boolean delete() {
+            return deleteStrategy.deleteQuietly(new File(path));
+        }
+
+        /**
+         * Return the path.
+         *
+         * @return the path
+         */
+        public String getPath() {
+            return path;
+        }
+    }
+    /**
+     * Queue of {@link Tracker} instances being watched.
+     */
+    ReferenceQueue<Object> q = new ReferenceQueue<>();
+    /**
+     * Collection of {@link Tracker} instances in existence.
+     */
+    final Collection<Tracker> trackers = Collections.synchronizedSet(new HashSet<>()); // synchronized
+    /**
+     * Collection of File paths that failed to delete.
+     */
+    final List<String> deleteFailures = Collections.synchronizedList(new ArrayList<>());
+
+    /**
+     * Whether to terminate the thread when the tracking is complete.
+     */
+    volatile boolean exitWhenFinished;
+
+    /**
+     * The thread that will clean up registered files.
+     */
+    Thread reaper;
+
+    /**
+     * Adds a tracker to the list of trackers.
+     *
+     * @param path  the full path to the file to be tracked, not null
+     * @param marker  the marker object used to track the file, not null
+     * @param deleteStrategy  the strategy to delete the file, null means normal
+     */
+    private synchronized void addTracker(final String path, final Object marker, final FileDeleteStrategy
+            deleteStrategy) {
+        // synchronized block protects reaper
+        if (exitWhenFinished) {
+            throw new IllegalStateException("No new trackers can be added once exitWhenFinished() is called");
+        }
+        if (reaper == null) {
+            reaper = new Reaper();
+            reaper.start();
+        }
+        trackers.add(new Tracker(path, deleteStrategy, marker, q));
+    }
+
+    /**
+     * Call this method to cause the file cleaner thread to terminate when
+     * there are no more objects being tracked for deletion.
+     * <p>
+     * In a simple environment, you don't need this method as the file cleaner
+     * thread will simply exit when the JVM exits. In a more complex environment,
+     * with multiple class loaders (such as an application server), you should be
+     * aware that the file cleaner thread will continue running even if the class
+     * loader it was started from terminates. This can constitute a memory leak.
+     * <p>
+     * For example, suppose that you have developed a web application, which
+     * contains the commons-io jar file in your WEB-INF/lib directory. In other
+     * words, the FileCleaner class is loaded through the class loader of your
+     * web application. If the web application is terminated, but the servlet
+     * container is still running, then the file cleaner thread will still exist,
+     * posing a memory leak.
+     * <p>
+     * This method allows the thread to be terminated. Simply call this method
+     * in the resource cleanup code, such as
+     * {@code javax.servlet.ServletContextListener.contextDestroyed(javax.servlet.ServletContextEvent)}.
+     * Once called, no new objects can be tracked by the file cleaner.
+     */
+    public synchronized void exitWhenFinished() {
+        // synchronized block protects reaper
+        exitWhenFinished = true;
+        if (reaper != null) {
+            synchronized (reaper) {
+                reaper.interrupt();
+            }
+        }
+    }
+
+    /**
+     * Return the file paths that failed to delete.
+     *
+     * @return the file paths that failed to delete
+     * @since 2.0
+     */
+    public List<String> getDeleteFailures() {
+        return deleteFailures;
+    }
+
+    /**
+     * Retrieve the number of files currently being tracked, and therefore
+     * awaiting deletion.
+     *
+     * @return the number of files being tracked
+     */
+    public int getTrackCount() {
+        return trackers.size();
+    }
+
+    /**
+     * Track the specified file, using the provided marker, deleting the file
+     * when the marker instance is garbage collected.
+     * The {@link FileDeleteStrategy#NORMAL normal} deletion strategy will be used.
+     *
+     * @param file  the file to be tracked, not null
+     * @param marker  the marker object used to track the file, not null
+     * @throws NullPointerException if the file is null
+     */
+    public void track(final File file, final Object marker) {
+        track(file, marker, null);
+    }
+
+    /**
+     * Track the specified file, using the provided marker, deleting the file
+     * when the marker instance is garbage collected.
+     * The specified deletion strategy is used.
+     *
+     * @param file  the file to be tracked, not null
+     * @param marker  the marker object used to track the file, not null
+     * @param deleteStrategy  the strategy to delete the file, null means normal
+     * @throws NullPointerException if the file is null
+     */
+    public void track(final File file, final Object marker, final FileDeleteStrategy deleteStrategy) {
+        Objects.requireNonNull(file, "file");
+        addTracker(file.getPath(), marker, deleteStrategy);
+    }
+
+    /**
+     * Track the specified file, using the provided marker, deleting the file
+     * when the marker instance is garbage collected.
+     * The {@link FileDeleteStrategy#NORMAL normal} deletion strategy will be used.
+     *
+     * @param path  the full path to the file to be tracked, not null
+     * @param marker  the marker object used to track the file, not null
+     * @throws NullPointerException if the path is null
+     */
+    public void track(final String path, final Object marker) {
+        track(path, marker, null);
+    }
+
+    /**
+     * Track the specified file, using the provided marker, deleting the file
+     * when the marker instance is garbage collected.
+     * The specified deletion strategy is used.
+     *
+     * @param path  the full path to the file to be tracked, not null
+     * @param marker  the marker object used to track the file, not null
+     * @param deleteStrategy  the strategy to delete the file, null means normal
+     * @throws NullPointerException if the path is null
+     */
+    public void track(final String path, final Object marker, final FileDeleteStrategy deleteStrategy) {
+        Objects.requireNonNull(path, "path");
+        addTracker(path, marker, deleteStrategy);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/FileDeleteStrategy.java b/src/main/java/org/apache/commons/io/FileDeleteStrategy.java
new file mode 100644
index 0000000..6bc5107
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/FileDeleteStrategy.java
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * Strategy for deleting files.
+ * <p>
+ * There is more than one way to delete a file.
+ * You may want to limit access to certain directories, to only delete
+ * directories if they are empty, or maybe to force deletion.
+ * </p>
+ * <p>
+ * This class captures the strategy to use and is designed for user subclassing.
+ * </p>
+ *
+ * @since 1.3
+ */
+public class FileDeleteStrategy {
+
+    /**
+     * Force file deletion strategy.
+     */
+    static class ForceFileDeleteStrategy extends FileDeleteStrategy {
+        /** Default Constructor */
+        ForceFileDeleteStrategy() {
+            super("Force");
+        }
+
+        /**
+         * Deletes the file object.
+         * <p>
+         * This implementation uses {@code FileUtils.forceDelete()}
+         * if the file exists.
+         * </p>
+         *
+         * @param fileToDelete  the file to delete, not null
+         * @return Always returns {@code true}
+         * @throws NullPointerException if the file is null
+         * @throws IOException if an error occurs during file deletion
+         */
+        @Override
+        protected boolean doDelete(final File fileToDelete) throws IOException {
+            FileUtils.forceDelete(fileToDelete);
+            return true;
+        }
+    }
+
+    /**
+     * The singleton instance for normal file deletion, which does not permit
+     * the deletion of directories that are not empty.
+     */
+    public static final FileDeleteStrategy NORMAL = new FileDeleteStrategy("Normal");
+
+    /**
+     * The singleton instance for forced file deletion, which always deletes,
+     * even if the file represents a non-empty directory.
+     */
+    public static final FileDeleteStrategy FORCE = new ForceFileDeleteStrategy();
+
+    /** The name of the strategy. */
+    private final String name;
+
+    /**
+     * Restricted constructor.
+     *
+     * @param name  the name by which the strategy is known
+     */
+    protected FileDeleteStrategy(final String name) {
+        this.name = name;
+    }
+
+    /**
+     * Deletes the file object, which may be a file or a directory.
+     * If the file does not exist, the method just returns.
+     * <p>
+     * Subclass writers should override {@link #doDelete(File)}, not this method.
+     * </p>
+     *
+     * @param fileToDelete  the file to delete, not null
+     * @throws NullPointerException if the file is null
+     * @throws IOException if an error occurs during file deletion
+     */
+    public void delete(final File fileToDelete) throws IOException {
+        if (fileToDelete.exists() && !doDelete(fileToDelete)) {
+            throw new IOException("Deletion failed: " + fileToDelete);
+        }
+    }
+
+    /**
+     * Deletes the file object, which may be a file or a directory.
+     * All {@link IOException}s are caught and false returned instead.
+     * If the file does not exist or is null, true is returned.
+     * <p>
+     * Subclass writers should override {@link #doDelete(File)}, not this method.
+     * </p>
+     *
+     * @param fileToDelete  the file to delete, null returns true
+     * @return true if the file was deleted, or there was no such file
+     */
+    public boolean deleteQuietly(final File fileToDelete) {
+        if (fileToDelete == null || !fileToDelete.exists()) {
+            return true;
+        }
+        try {
+            return doDelete(fileToDelete);
+        } catch (final IOException ex) {
+            return false;
+        }
+    }
+
+    /**
+     * Actually deletes the file object, which may be a file or a directory.
+     * <p>
+     * This method is designed for subclasses to override.
+     * The implementation may return either false or an {@link IOException}
+     * when deletion fails. The {@link #delete(File)} and {@link #deleteQuietly(File)}
+     * methods will handle either response appropriately.
+     * A check has been made to ensure that the file will exist.
+     * </p>
+     * <p>
+     * This implementation uses {@link FileUtils#delete(File)}.
+     * </p>
+     *
+     * @param file  the file to delete, exists, not null
+     * @return true if the file was deleted
+     * @throws NullPointerException if the file is null
+     * @throws IOException if an error occurs during file deletion
+     */
+    protected boolean doDelete(final File file) throws IOException {
+        FileUtils.delete(file);
+        return true;
+    }
+
+    /**
+     * Gets a string describing the delete strategy.
+     *
+     * @return a string describing the delete strategy
+     */
+    @Override
+    public String toString() {
+        return "FileDeleteStrategy[" + name + "]";
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/FileExistsException.java b/src/main/java/org/apache/commons/io/FileExistsException.java
new file mode 100644
index 0000000..2264256
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/FileExistsException.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * Indicates that a file already exists.
+ *
+ * @since 2.0
+ */
+public class FileExistsException extends IOException {
+
+    /**
+     * Defines the serial version UID.
+     */
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * Default Constructor.
+     */
+    public FileExistsException() {
+    }
+
+    /**
+     * Constructs an instance with the specified file.
+     *
+     * @param file The file that exists
+     */
+    public FileExistsException(final File file) {
+        super("File " + file + " exists");
+    }
+
+    /**
+     * Constructs an instance with the specified message.
+     *
+     * @param message The error message
+     */
+    public FileExistsException(final String message) {
+        super(message);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/FileSystem.java b/src/main/java/org/apache/commons/io/FileSystem.java
new file mode 100644
index 0000000..42f5167
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/FileSystem.java
@@ -0,0 +1,514 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io;
+
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.Objects;
+
+/**
+ * Abstracts an OS' file system details, currently supporting the single use case of converting a file name String to a
+ * legal file name with {@link #toLegalFileName(String, char)}.
+ * <p>
+ * The starting point of any operation is {@link #getCurrent()} which gets you the enum for the file system that matches
+ * the OS hosting the running JVM.
+ * </p>
+ *
+ * @since 2.7
+ */
+public enum FileSystem {
+
+    /**
+     * Generic file system.
+     */
+    GENERIC(false, false, Integer.MAX_VALUE, Integer.MAX_VALUE, new int[] { 0 }, new String[] {}, false, false, '/'),
+
+    /**
+     * Linux file system.
+     */
+    LINUX(true, true, 255, 4096, new int[] {
+            // KEEP THIS ARRAY SORTED!
+            // @formatter:off
+            // ASCII NUL
+            0,
+             '/'
+            // @formatter:on
+    }, new String[] {}, false, false, '/'),
+
+    /**
+     * MacOS file system.
+     */
+    MAC_OSX(true, true, 255, 1024, new int[] {
+            // KEEP THIS ARRAY SORTED!
+            // @formatter:off
+            // ASCII NUL
+            0,
+            '/',
+             ':'
+            // @formatter:on
+    }, new String[] {}, false, false, '/'),
+
+    /**
+     * Windows file system.
+     * <p>
+     * The reserved characters are defined in the
+     * <a href="https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file">Naming Conventions
+     * (microsoft.com)</a>.
+     * </p>
+     *
+     * @see <a href="https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file">Naming Conventions
+     *      (microsoft.com)</a>
+     * @see <a href="https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea#consoles">
+     *      CreateFileA function - Consoles (microsoft.com)</a>
+     */
+    WINDOWS(false, true, 255,
+            32000, new int[] {
+                    // KEEP THIS ARRAY SORTED!
+                    // @formatter:off
+                    // ASCII NUL
+                    0,
+                    // 1-31 may be allowed in file streams
+                    1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28,
+                    29, 30, 31,
+                    '"', '*', '/', ':', '<', '>', '?', '\\', '|'
+                    // @formatter:on
+            }, // KEEP THIS ARRAY SORTED!
+            new String[] { "AUX", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "CON", "CONIN$", "CONOUT$",
+                    "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "NUL", "PRN" }, true, true, '\\');
+
+    /**
+     * <p>
+     * Is {@code true} if this is Linux.
+     * </p>
+     * <p>
+     * The field will return {@code false} if {@code OS_NAME} is {@code null}.
+     * </p>
+     */
+    private static final boolean IS_OS_LINUX = getOsMatchesName("Linux");
+
+    /**
+     * <p>
+     * Is {@code true} if this is Mac.
+     * </p>
+     * <p>
+     * The field will return {@code false} if {@code OS_NAME} is {@code null}.
+     * </p>
+     */
+    private static final boolean IS_OS_MAC = getOsMatchesName("Mac");
+
+    /**
+     * The prefix String for all Windows OS.
+     */
+    private static final String OS_NAME_WINDOWS_PREFIX = "Windows";
+
+    /**
+     * <p>
+     * Is {@code true} if this is Windows.
+     * </p>
+     * <p>
+     * The field will return {@code false} if {@code OS_NAME} is {@code null}.
+     * </p>
+     */
+    private static final boolean IS_OS_WINDOWS = getOsMatchesName(OS_NAME_WINDOWS_PREFIX);
+
+    /**
+     * The current FileSystem.
+     */
+    private static final FileSystem CURRENT = current();
+
+    /**
+     * Gets the current file system.
+     *
+     * @return the current file system
+     */
+    private static FileSystem current() {
+        if (IS_OS_LINUX) {
+            return LINUX;
+        }
+        if (IS_OS_MAC) {
+            return MAC_OSX;
+        }
+        if (IS_OS_WINDOWS) {
+            return WINDOWS;
+        }
+        return GENERIC;
+    }
+
+    /**
+     * Gets the current file system.
+     *
+     * @return the current file system
+     */
+    public static FileSystem getCurrent() {
+        return CURRENT;
+    }
+
+    /**
+     * Decides if the operating system matches.
+     *
+     * @param osNamePrefix
+     *            the prefix for the os name
+     * @return true if matches, or false if not or can't determine
+     */
+    private static boolean getOsMatchesName(final String osNamePrefix) {
+        return isOsNameMatch(getSystemProperty("os.name"), osNamePrefix);
+    }
+
+    /**
+     * <p>
+     * Gets a System property, defaulting to {@code null} if the property cannot be read.
+     * </p>
+     * <p>
+     * If a {@link SecurityException} is caught, the return value is {@code null} and a message is written to
+     * {@code System.err}.
+     * </p>
+     *
+     * @param property
+     *            the system property name
+     * @return the system property value or {@code null} if a security problem occurs
+     */
+    private static String getSystemProperty(final String property) {
+        try {
+            return System.getProperty(property);
+        } catch (final SecurityException ex) {
+            // we are not allowed to look at this property
+            System.err.println("Caught a SecurityException reading the system property '" + property
+                    + "'; the SystemUtils property value will default to null.");
+            return null;
+        }
+    }
+
+    /**
+     * Copied from Apache Commons Lang CharSequenceUtils.
+     *
+     * Returns the index within {@code cs} of the first occurrence of the
+     * specified character, starting the search at the specified index.
+     * <p>
+     * If a character with value {@code searchChar} occurs in the
+     * character sequence represented by the {@code cs}
+     * object at an index no smaller than {@code start}, then
+     * the index of the first such occurrence is returned. For values
+     * of {@code searchChar} in the range from 0 to 0xFFFF (inclusive),
+     * this is the smallest value <i>k</i> such that:
+     * </p>
+     * <blockquote><pre>
+     * (this.charAt(<i>k</i>) == searchChar) &amp;&amp; (<i>k</i> &gt;= start)
+     * </pre></blockquote>
+     * is true. For other values of {@code searchChar}, it is the
+     * smallest value <i>k</i> such that:
+     * <blockquote><pre>
+     * (this.codePointAt(<i>k</i>) == searchChar) &amp;&amp; (<i>k</i> &gt;= start)
+     * </pre></blockquote>
+     * <p>
+     * is true. In either case, if no such character occurs inm {@code cs}
+     * at or after position {@code start}, then
+     * {@code -1} is returned.
+     * </p>
+     * <p>
+     * There is no restriction on the value of {@code start}. If it
+     * is negative, it has the same effect as if it were zero: the entire
+     * {@link CharSequence} may be searched. If it is greater than
+     * the length of {@code cs}, it has the same effect as if it were
+     * equal to the length of {@code cs}: {@code -1} is returned.
+     * </p>
+     * <p>All indices are specified in {@code char} values
+     * (Unicode code units).
+     * </p>
+     *
+     * @param cs  the {@link CharSequence} to be processed, not null
+     * @param searchChar  the char to be searched for
+     * @param start  the start index, negative starts at the string start
+     * @return the index where the search char was found, -1 if not found
+     * @since 3.6 updated to behave more like {@link String}
+     */
+    private static int indexOf(final CharSequence cs, final int searchChar, int start) {
+        if (cs instanceof String) {
+            return ((String) cs).indexOf(searchChar, start);
+        }
+        final int sz = cs.length();
+        if (start < 0) {
+            start = 0;
+        }
+        if (searchChar < Character.MIN_SUPPLEMENTARY_CODE_POINT) {
+            for (int i = start; i < sz; i++) {
+                if (cs.charAt(i) == searchChar) {
+                    return i;
+                }
+            }
+            return -1;
+        }
+        //supplementary characters (LANG1300)
+        if (searchChar <= Character.MAX_CODE_POINT) {
+            final char[] chars = Character.toChars(searchChar);
+            for (int i = start; i < sz - 1; i++) {
+                final char high = cs.charAt(i);
+                final char low = cs.charAt(i + 1);
+                if (high == chars[0] && low == chars[1]) {
+                    return i;
+                }
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * Decides if the operating system matches.
+     * <p>
+     * This method is package private instead of private to support unit test invocation.
+     * </p>
+     *
+     * @param osName
+     *            the actual OS name
+     * @param osNamePrefix
+     *            the prefix for the expected OS name
+     * @return true if matches, or false if not or can't determine
+     */
+    private static boolean isOsNameMatch(final String osName, final String osNamePrefix) {
+        if (osName == null) {
+            return false;
+        }
+        return osName.toUpperCase(Locale.ROOT).startsWith(osNamePrefix.toUpperCase(Locale.ROOT));
+    }
+
+    /**
+     * Null-safe replace.
+     *
+     * @param path the path to be changed, null ignored.
+     * @param oldChar the old character.
+     * @param newChar the new character.
+     * @return the new path.
+     */
+    private static String replace(final String path, final char oldChar, final char newChar) {
+        return path == null ? null : path.replace(oldChar, newChar);
+    }
+    private final boolean casePreserving;
+    private final boolean caseSensitive;
+    private final int[] illegalFileNameChars;
+    private final int maxFileNameLength;
+    private final int maxPathLength;
+    private final String[] reservedFileNames;
+    private final boolean reservedFileNamesExtensions;
+    private final boolean supportsDriveLetter;
+    private final char nameSeparator;
+
+    private final char nameSeparatorOther;
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param caseSensitive Whether this file system is case-sensitive.
+     * @param casePreserving Whether this file system is case-preserving.
+     * @param maxFileLength The maximum length for file names. The file name does not include folders.
+     * @param maxPathLength The maximum length of the path to a file. This can include folders.
+     * @param illegalFileNameChars Illegal characters for this file system.
+     * @param reservedFileNames The reserved file names.
+     * @param reservedFileNamesExtensions TODO
+     * @param supportsDriveLetter Whether this file system support driver letters.
+     * @param nameSeparator The name separator, '\\' on Windows, '/' on Linux.
+     */
+    FileSystem(final boolean caseSensitive, final boolean casePreserving, final int maxFileLength,
+        final int maxPathLength, final int[] illegalFileNameChars, final String[] reservedFileNames,
+        final boolean reservedFileNamesExtensions, final boolean supportsDriveLetter, final char nameSeparator) {
+        this.maxFileNameLength = maxFileLength;
+        this.maxPathLength = maxPathLength;
+        this.illegalFileNameChars = Objects.requireNonNull(illegalFileNameChars, "illegalFileNameChars");
+        this.reservedFileNames = Objects.requireNonNull(reservedFileNames, "reservedFileNames");
+        this.reservedFileNamesExtensions = reservedFileNamesExtensions;
+        this.caseSensitive = caseSensitive;
+        this.casePreserving = casePreserving;
+        this.supportsDriveLetter = supportsDriveLetter;
+        this.nameSeparator = nameSeparator;
+        this.nameSeparatorOther = FilenameUtils.flipSeparator(nameSeparator);
+    }
+
+    /**
+     * Gets a cloned copy of the illegal characters for this file system.
+     *
+     * @return the illegal characters for this file system.
+     */
+    public char[] getIllegalFileNameChars() {
+        final char[] chars = new char[illegalFileNameChars.length];
+        for (int i = 0; i < illegalFileNameChars.length; i++) {
+            chars[i] = (char) illegalFileNameChars[i];
+        }
+        return chars;
+    }
+
+    /**
+     * Gets a cloned copy of the illegal code points for this file system.
+     *
+     * @return the illegal code points for this file system.
+     * @since 2.12.0
+     */
+    public int[] getIllegalFileNameCodePoints() {
+        return this.illegalFileNameChars.clone();
+    }
+
+    /**
+     * Gets the maximum length for file names. The file name does not include folders.
+     *
+     * @return the maximum length for file names.
+     */
+    public int getMaxFileNameLength() {
+        return maxFileNameLength;
+    }
+
+    /**
+     * Gets the maximum length of the path to a file. This can include folders.
+     *
+     * @return the maximum length of the path to a file.
+     */
+    public int getMaxPathLength() {
+        return maxPathLength;
+    }
+
+    /**
+     * Gets the name separator, '\\' on Windows, '/' on Linux.
+     *
+     * @return '\\' on Windows, '/' on Linux.
+     *
+     * @since 2.12.0
+     */
+    public char getNameSeparator() {
+        return nameSeparator;
+    }
+
+    /**
+     * Gets a cloned copy of the reserved file names.
+     *
+     * @return the reserved file names.
+     */
+    public String[] getReservedFileNames() {
+        return reservedFileNames.clone();
+    }
+
+    /**
+     * Tests whether this file system preserves case.
+     *
+     * @return Whether this file system preserves case.
+     */
+    public boolean isCasePreserving() {
+        return casePreserving;
+    }
+
+    /**
+     * Tests whether this file system is case-sensitive.
+     *
+     * @return Whether this file system is case-sensitive.
+     */
+    public boolean isCaseSensitive() {
+        return caseSensitive;
+    }
+
+    /**
+     * Tests if the given character is illegal in a file name, {@code false} otherwise.
+     *
+     * @param c
+     *            the character to test
+     * @return {@code true} if the given character is illegal in a file name, {@code false} otherwise.
+     */
+    private boolean isIllegalFileNameChar(final int c) {
+        return Arrays.binarySearch(illegalFileNameChars, c) >= 0;
+    }
+
+    /**
+     * Tests if a candidate file name (without a path) such as {@code "filename.ext"} or {@code "filename"} is a
+     * potentially legal file name. If the file name length exceeds {@link #getMaxFileNameLength()}, or if it contains
+     * an illegal character then the check fails.
+     *
+     * @param candidate
+     *            a candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"}
+     * @return {@code true} if the candidate name is legal
+     */
+    public boolean isLegalFileName(final CharSequence candidate) {
+        if (candidate == null || candidate.length() == 0 || candidate.length() > maxFileNameLength) {
+            return false;
+        }
+        if (isReservedFileName(candidate)) {
+            return false;
+        }
+        return candidate.chars().noneMatch(this::isIllegalFileNameChar);
+    }
+
+    /**
+     * Tests whether the given string is a reserved file name.
+     *
+     * @param candidate
+     *            the string to test
+     * @return {@code true} if the given string is a reserved file name.
+     */
+    public boolean isReservedFileName(final CharSequence candidate) {
+        final CharSequence test = reservedFileNamesExtensions ? trimExtension(candidate) : candidate;
+        return Arrays.binarySearch(reservedFileNames, test) >= 0;
+    }
+
+    /**
+     * Converts all separators to the Windows separator of backslash.
+     *
+     * @param path the path to be changed, null ignored
+     * @return the updated path
+     * @since 2.12.0
+     */
+    public String normalizeSeparators(final String path) {
+        return replace(path, nameSeparatorOther, nameSeparator);
+    }
+
+    /**
+     * Tests whether this file system support driver letters.
+     * <p>
+     * Windows supports driver letters as do other operating systems. Whether these other OS's still support Java like
+     * OS/2, is a different matter.
+     * </p>
+     *
+     * @return whether this file system support driver letters.
+     * @since 2.9.0
+     * @see <a href="https://en.wikipedia.org/wiki/Drive_letter_assignment">Operating systems that use drive letter
+     *      assignment</a>
+     */
+    public boolean supportsDriveLetter() {
+        return supportsDriveLetter;
+    }
+
+    /**
+     * Converts a candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"} to a legal file
+     * name. Illegal characters in the candidate name are replaced by the {@code replacement} character. If the file
+     * name length exceeds {@link #getMaxFileNameLength()}, then the name is truncated to
+     * {@link #getMaxFileNameLength()}.
+     *
+     * @param candidate
+     *            a candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"}
+     * @param replacement
+     *            Illegal characters in the candidate name are replaced by this character
+     * @return a String without illegal characters
+     */
+    public String toLegalFileName(final String candidate, final char replacement) {
+        if (isIllegalFileNameChar(replacement)) {
+            // %s does not work properly with NUL
+            throw new IllegalArgumentException(String.format("The replacement character '%s' cannot be one of the %s illegal characters: %s",
+                replacement == '\0' ? "\\0" : replacement, name(), Arrays.toString(illegalFileNameChars)));
+        }
+        final String truncated = candidate.length() > maxFileNameLength ? candidate.substring(0, maxFileNameLength) : candidate;
+        final int[] array = truncated.chars().map(i -> isIllegalFileNameChar(i) ? replacement : i).toArray();
+        return new String(array, 0, array.length);
+    }
+
+    CharSequence trimExtension(final CharSequence cs) {
+        final int index = indexOf(cs, '.', 0);
+        return index < 0 ? cs : cs.subSequence(0, index);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/FileSystemUtils.java b/src/main/java/org/apache/commons/io/FileSystemUtils.java
new file mode 100644
index 0000000..884368d
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/FileSystemUtils.java
@@ -0,0 +1,555 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.charset.Charset;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.StringTokenizer;
+import java.util.stream.Collectors;
+
+/**
+ * General File System utilities.
+ * <p>
+ * This class provides static utility methods for general file system
+ * functions not provided via the JDK {@link java.io.File File} class.
+ * <p>
+ * The current functions provided are:
+ * <ul>
+ * <li>Get the free space on a drive
+ * </ul>
+ *
+ * @since 1.1
+ * @deprecated As of 2.6 deprecated without replacement. Use equivalent
+ *  methods in {@link java.nio.file.FileStore} instead, e.g.
+ *  {@code Files.getFileStore(Paths.get("/home")).getUsableSpace()}
+ *  or iterate over {@code FileSystems.getDefault().getFileStores()}
+ */
+@Deprecated
+public class FileSystemUtils {
+
+    /** Singleton instance, used mainly for testing. */
+    private static final FileSystemUtils INSTANCE = new FileSystemUtils();
+
+    /** Operating system state flag for error. */
+    private static final int INIT_PROBLEM = -1;
+    /** Operating system state flag for neither Unix nor Windows. */
+    private static final int OTHER = 0;
+    /** Operating system state flag for Windows. */
+    private static final int WINDOWS = 1;
+    /** Operating system state flag for Unix. */
+    private static final int UNIX = 2;
+    /** Operating system state flag for Posix flavour Unix. */
+    private static final int POSIX_UNIX = 3;
+
+    /** The operating system flag. */
+    private static final int OS;
+
+    /** The path to df */
+    private static final String DF;
+
+    static {
+        int os = OTHER;
+        String dfPath = "df";
+        try {
+            String osName = System.getProperty("os.name");
+            if (osName == null) {
+                throw new IOException("os.name not found");
+            }
+            osName = osName.toLowerCase(Locale.ENGLISH);
+            // match
+            if (osName.contains("windows")) {
+                os = WINDOWS;
+            } else if (osName.contains("linux") ||
+                    osName.contains("mpe/ix") ||
+                    osName.contains("freebsd") ||
+                    osName.contains("openbsd") ||
+                    osName.contains("irix") ||
+                    osName.contains("digital unix") ||
+                    osName.contains("unix") ||
+                    osName.contains("mac os x")) {
+                os = UNIX;
+            } else if (osName.contains("sun os") ||
+                    osName.contains("sunos") ||
+                    osName.contains("solaris")) {
+                os = POSIX_UNIX;
+                dfPath = "/usr/xpg4/bin/df";
+            } else if (osName.contains("hp-ux") ||
+                    osName.contains("aix")) {
+                os = POSIX_UNIX;
+            }
+
+        } catch (final Exception ex) {
+            os = INIT_PROBLEM;
+        }
+        OS = os;
+        DF = dfPath;
+    }
+
+    /**
+     * Returns the free space on a drive or volume by invoking
+     * the command line.
+     * This method does not normalize the result, and typically returns
+     * bytes on Windows, 512 byte units on OS X and kilobytes on Unix.
+     * As this is not very useful, this method is deprecated in favour
+     * of {@link #freeSpaceKb(String)} which returns a result in kilobytes.
+     * <p>
+     * Note that some OS's are NOT currently supported, including OS/390,
+     * OpenVMS.
+     * <pre>
+     * FileSystemUtils.freeSpace("C:");       // Windows
+     * FileSystemUtils.freeSpace("/volume");  // *nix
+     * </pre>
+     * The free space is calculated via the command line.
+     * It uses 'dir /-c' on Windows and 'df' on *nix.
+     *
+     * @param path  the path to get free space for, not null, not empty on Unix
+     * @return the amount of free drive space on the drive or volume
+     * @throws IllegalArgumentException if the path is invalid
+     * @throws IllegalStateException if an error occurred in initialisation
+     * @throws IOException if an error occurs when finding the free space
+     * @since 1.1, enhanced OS support in 1.2 and 1.3
+     * @deprecated Use freeSpaceKb(String)
+     *  Deprecated from 1.3, may be removed in 2.0
+     */
+    @Deprecated
+    public static long freeSpace(final String path) throws IOException {
+        return INSTANCE.freeSpaceOS(path, OS, false, Duration.ofMillis(-1));
+    }
+
+    /**
+     * Returns the free space for the working directory
+     * in kibibytes (1024 bytes) by invoking the command line.
+     * <p>
+     * Identical to:
+     * <pre>
+     * freeSpaceKb(FileUtils.current().getAbsolutePath())
+     * </pre>
+     * @return the amount of free drive space on the drive or volume in kilobytes
+     * @throws IllegalStateException if an error occurred in initialisation
+     * @throws IOException if an error occurs when finding the free space
+     * @since 2.0
+     * @deprecated As of 2.6 deprecated without replacement. Please use {@link java.nio.file.FileStore#getUsableSpace()}.
+     */
+    @Deprecated
+    public static long freeSpaceKb() throws IOException {
+        return freeSpaceKb(-1);
+    }
+
+    /**
+     * Returns the free space for the working directory
+     * in kibibytes (1024 bytes) by invoking the command line.
+     * <p>
+     * Identical to:
+     * <pre>
+     * freeSpaceKb(FileUtils.current().getAbsolutePath())
+     * </pre>
+     * @param timeout The timeout amount in milliseconds or no timeout if the value
+     *  is zero or less
+     * @return the amount of free drive space on the drive or volume in kilobytes
+     * @throws IllegalStateException if an error occurred in initialisation
+     * @throws IOException if an error occurs when finding the free space
+     * @since 2.0
+     * @deprecated As of 2.6 deprecated without replacement. Please use {@link java.nio.file.FileStore#getUsableSpace()}.
+     */
+    @Deprecated
+    public static long freeSpaceKb(final long timeout) throws IOException {
+        return freeSpaceKb(FileUtils.current().getAbsolutePath(), timeout);
+    }
+    /**
+     * Returns the free space on a drive or volume in kibibytes (1024 bytes)
+     * by invoking the command line.
+     * <pre>
+     * FileSystemUtils.freeSpaceKb("C:");       // Windows
+     * FileSystemUtils.freeSpaceKb("/volume");  // *nix
+     * </pre>
+     * The free space is calculated via the command line.
+     * It uses 'dir /-c' on Windows, 'df -kP' on AIX/HP-UX and 'df -k' on other Unix.
+     * <p>
+     * In order to work, you must be running Windows, or have an implementation of
+     * Unix df that supports GNU format when passed -k (or -kP). If you are going
+     * to rely on this code, please check that it works on your OS by running
+     * some simple tests to compare the command line with the output from this class.
+     * If your operating system isn't supported, please raise a JIRA call detailing
+     * the exact result from df -k and as much other detail as possible, thanks.
+     *
+     * @param path  the path to get free space for, not null, not empty on Unix
+     * @return the amount of free drive space on the drive or volume in kilobytes
+     * @throws IllegalArgumentException if the path is invalid
+     * @throws IllegalStateException if an error occurred in initialisation
+     * @throws IOException if an error occurs when finding the free space
+     * @since 1.2, enhanced OS support in 1.3
+     * @deprecated As of 2.6 deprecated without replacement. Please use {@link java.nio.file.FileStore#getUsableSpace()}.
+     */
+    @Deprecated
+    public static long freeSpaceKb(final String path) throws IOException {
+        return freeSpaceKb(path, -1);
+    }
+
+    /**
+     * Returns the free space on a drive or volume in kibibytes (1024 bytes)
+     * by invoking the command line.
+     * <pre>
+     * FileSystemUtils.freeSpaceKb("C:");       // Windows
+     * FileSystemUtils.freeSpaceKb("/volume");  // *nix
+     * </pre>
+     * The free space is calculated via the command line.
+     * It uses 'dir /-c' on Windows, 'df -kP' on AIX/HP-UX and 'df -k' on other Unix.
+     * <p>
+     * In order to work, you must be running Windows, or have an implementation of
+     * Unix df that supports GNU format when passed -k (or -kP). If you are going
+     * to rely on this code, please check that it works on your OS by running
+     * some simple tests to compare the command line with the output from this class.
+     * If your operating system isn't supported, please raise a JIRA call detailing
+     * the exact result from df -k and as much other detail as possible, thanks.
+     *
+     * @param path  the path to get free space for, not null, not empty on Unix
+     * @param timeout The timeout amount in milliseconds or no timeout if the value
+     *  is zero or less
+     * @return the amount of free drive space on the drive or volume in kilobytes
+     * @throws IllegalArgumentException if the path is invalid
+     * @throws IllegalStateException if an error occurred in initialisation
+     * @throws IOException if an error occurs when finding the free space
+     * @since 2.0
+     * @deprecated As of 2.6 deprecated without replacement. Please use {@link java.nio.file.FileStore#getUsableSpace()}.
+     */
+    @Deprecated
+    public static long freeSpaceKb(final String path, final long timeout) throws IOException {
+        return INSTANCE.freeSpaceOS(path, OS, true, Duration.ofMillis(timeout));
+    }
+
+    /**
+     * Instances should NOT be constructed in standard programming.
+     */
+    public FileSystemUtils() {
+    }
+
+    /**
+     * Returns the free space on a drive or volume in a cross-platform manner.
+     * Note that some OS's are NOT currently supported, including OS/390.
+     * <pre>
+     * FileSystemUtils.freeSpace("C:");  // Windows
+     * FileSystemUtils.freeSpace("/volume");  // *nix
+     * </pre>
+     * The free space is calculated via the command line.
+     * It uses 'dir /-c' on Windows and 'df' on *nix.
+     *
+     * @param path  the path to get free space for, not null, not empty on Unix
+     * @param os  the operating system code
+     * @param kb  whether to normalize to kilobytes
+     * @param timeout The timeout amount in milliseconds or no timeout if the value
+     *  is zero or less
+     * @return the amount of free drive space on the drive or volume
+     * @throws IllegalArgumentException if the path is invalid
+     * @throws IllegalStateException if an error occurred in initialization
+     * @throws IOException if an error occurs when finding the free space
+     */
+    long freeSpaceOS(final String path, final int os, final boolean kb, final Duration timeout) throws IOException {
+        Objects.requireNonNull(path, "path");
+        switch (os) {
+        case WINDOWS:
+            return kb ? freeSpaceWindows(path, timeout) / FileUtils.ONE_KB : freeSpaceWindows(path, timeout);
+        case UNIX:
+            return freeSpaceUnix(path, kb, false, timeout);
+        case POSIX_UNIX:
+            return freeSpaceUnix(path, kb, true, timeout);
+        case OTHER:
+            throw new IllegalStateException("Unsupported operating system");
+        default:
+            throw new IllegalStateException("Exception caught when determining operating system");
+        }
+    }
+
+    /**
+     * Find free space on the *nix platform using the 'df' command.
+     *
+     * @param path  the path to get free space for
+     * @param kb  whether to normalize to kilobytes
+     * @param posix  whether to use the POSIX standard format flag
+     * @param timeout The timeout amount in milliseconds or no timeout if the value
+     *  is zero or less
+     * @return the amount of free drive space on the volume
+     * @throws IOException if an error occurs
+     */
+    long freeSpaceUnix(final String path, final boolean kb, final boolean posix, final Duration timeout)
+            throws IOException {
+        if (path.isEmpty()) {
+            throw new IllegalArgumentException("Path must not be empty");
+        }
+
+        // build and run the 'dir' command
+        String flags = "-";
+        if (kb) {
+            flags += "k";
+        }
+        if (posix) {
+            flags += "P";
+        }
+        final String[] cmdAttribs =
+            flags.length() > 1 ? new String[] {DF, flags, path} : new String[] {DF, path};
+
+        // perform the command, asking for up to 3 lines (header, interesting, overflow)
+        final List<String> lines = performCommand(cmdAttribs, 3, timeout);
+        if (lines.size() < 2) {
+            // unknown problem, throw exception
+            throw new IOException(
+                    "Command line '" + DF + "' did not return info as expected " +
+                    "for path '" + path + "'- response was " + lines);
+        }
+        final String line2 = lines.get(1); // the line we're interested in
+
+        // Now, we tokenize the string. The fourth element is what we want.
+        StringTokenizer tok = new StringTokenizer(line2, " ");
+        if (tok.countTokens() < 4) {
+            // could be long Filesystem, thus data on third line
+            if (tok.countTokens() != 1 || lines.size() < 3) {
+                throw new IOException(
+                        "Command line '" + DF + "' did not return data as expected " +
+                        "for path '" + path + "'- check path is valid");
+            }
+            final String line3 = lines.get(2); // the line may be interested in
+            tok = new StringTokenizer(line3, " ");
+        } else {
+            tok.nextToken(); // Ignore Filesystem
+        }
+        tok.nextToken(); // Ignore 1K-blocks
+        tok.nextToken(); // Ignore Used
+        final String freeSpace = tok.nextToken();
+        return parseBytes(freeSpace, path);
+    }
+
+    /**
+     * Find free space on the Windows platform using the 'dir' command.
+     *
+     * @param path  the path to get free space for, including the colon
+     * @param timeout The timeout amount in milliseconds or no timeout if the value
+     *  is zero or less
+     * @return the amount of free drive space on the drive
+     * @throws IOException if an error occurs
+     */
+    long freeSpaceWindows(final String path, final Duration timeout) throws IOException {
+        String normPath = FilenameUtils.normalize(path, false);
+        if (normPath == null) {
+            throw new IllegalArgumentException(path);
+        }
+        if (!normPath.isEmpty() && normPath.charAt(0) != '"') {
+            normPath = "\"" + normPath + "\"";
+        }
+
+        // build and run the 'dir' command
+        final String[] cmdAttribs = {"cmd.exe", "/C", "dir /a /-c " + normPath};
+
+        // read in the output of the command to an ArrayList
+        final List<String> lines = performCommand(cmdAttribs, Integer.MAX_VALUE, timeout);
+
+        // now iterate over the lines we just read and find the LAST
+        // non-empty line (the free space bytes should be in the last element
+        // of the ArrayList anyway, but this will ensure it works even if it's
+        // not, still assuming it is on the last non-blank line)
+        for (int i = lines.size() - 1; i >= 0; i--) {
+            final String line = lines.get(i);
+            if (!line.isEmpty()) {
+                return parseDir(line, normPath);
+            }
+        }
+        // all lines are blank
+        throw new IOException(
+                "Command line 'dir /-c' did not return any info " +
+                "for path '" + normPath + "'");
+    }
+
+    /**
+     * Opens the process to the operating system.
+     *
+     * @param cmdAttribs  the command line parameters
+     * @return the process
+     * @throws IOException if an error occurs
+     */
+    Process openProcess(final String[] cmdAttribs) throws IOException {
+        return Runtime.getRuntime().exec(cmdAttribs);
+    }
+
+    /**
+     * Parses the bytes from a string.
+     *
+     * @param freeSpace  the free space string
+     * @param path  the path
+     * @return the number of bytes
+     * @throws IOException if an error occurs
+     */
+    long parseBytes(final String freeSpace, final String path) throws IOException {
+        try {
+            final long bytes = Long.parseLong(freeSpace);
+            if (bytes < 0) {
+                throw new IOException(
+                        "Command line '" + DF + "' did not find free space in response " +
+                        "for path '" + path + "'- check path is valid");
+            }
+            return bytes;
+
+        } catch (final NumberFormatException ex) {
+            throw new IOException(
+                    "Command line '" + DF + "' did not return numeric data as expected " +
+                    "for path '" + path + "'- check path is valid", ex);
+        }
+    }
+
+    /**
+     * Parses the Windows dir response last line
+     *
+     * @param line  the line to parse
+     * @param path  the path that was sent
+     * @return the number of bytes
+     * @throws IOException if an error occurs
+     */
+    long parseDir(final String line, final String path) throws IOException {
+        // read from the end of the line to find the last numeric
+        // character on the line, then continue until we find the first
+        // non-numeric character, and everything between that and the last
+        // numeric character inclusive is our free space bytes count
+        int bytesStart = 0;
+        int bytesEnd = 0;
+        int j = line.length() - 1;
+        innerLoop1: while (j >= 0) {
+            final char c = line.charAt(j);
+            if (Character.isDigit(c)) {
+              // found the last numeric character, this is the end of
+              // the free space bytes count
+              bytesEnd = j + 1;
+              break innerLoop1;
+            }
+            j--;
+        }
+        innerLoop2: while (j >= 0) {
+            final char c = line.charAt(j);
+            if (!Character.isDigit(c) && c != ',' && c != '.') {
+              // found the next non-numeric character, this is the
+              // beginning of the free space bytes count
+              bytesStart = j + 1;
+              break innerLoop2;
+            }
+            j--;
+        }
+        if (j < 0) {
+            throw new IOException(
+                    "Command line 'dir /-c' did not return valid info " +
+                    "for path '" + path + "'");
+        }
+
+        // remove commas and dots in the bytes count
+        final StringBuilder buf = new StringBuilder(line.substring(bytesStart, bytesEnd));
+        for (int k = 0; k < buf.length(); k++) {
+            if (buf.charAt(k) == ',' || buf.charAt(k) == '.') {
+                buf.deleteCharAt(k--);
+            }
+        }
+        return parseBytes(buf.toString(), path);
+    }
+
+    /**
+     * Performs an OS command.
+     *
+     * @param cmdAttribs  the command line parameters
+     * @param max The maximum limit for the lines returned
+     * @param timeout The timeout amount in milliseconds or no timeout if the value
+     *  is zero or less
+     * @return the lines returned by the command, converted to lower-case
+     * @throws IOException if an error occurs
+     */
+    List<String> performCommand(final String[] cmdAttribs, final int max, final Duration timeout) throws IOException {
+        // this method does what it can to avoid the 'Too many open files' error
+        // based on trial and error and these links:
+        // http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4784692
+        // http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4801027
+        // http://forum.java.sun.com/thread.jspa?threadID=533029&messageID=2572018
+        // however, it's still not perfect as the JDK support is so poor
+        // (see commons-exec or Ant for a better multithreaded multi-os solution)
+
+        final List<String> lines;
+        Process proc = null;
+        InputStream in = null;
+        OutputStream out = null;
+        InputStream err = null;
+        BufferedReader inr = null;
+        try {
+
+            final Thread monitor = ThreadMonitor.start(timeout);
+
+            proc = openProcess(cmdAttribs);
+            in = proc.getInputStream();
+            out = proc.getOutputStream();
+            err = proc.getErrorStream();
+            // default charset is most likely appropriate here
+            inr = new BufferedReader(new InputStreamReader(in, Charset.defaultCharset()));
+
+            lines = inr.lines().limit(max).map(line -> line.toLowerCase(Locale.ENGLISH).trim()).collect(Collectors.toList());
+
+            proc.waitFor();
+
+            ThreadMonitor.stop(monitor);
+
+            if (proc.exitValue() != 0) {
+                // OS command problem, throw exception
+                throw new IOException("Command line returned OS error code '" + proc.exitValue() + "' for command " + Arrays.asList(cmdAttribs));
+            }
+            if (lines.isEmpty()) {
+                // unknown problem, throw exception
+                throw new IOException("Command line did not return any info " + "for command " + Arrays.asList(cmdAttribs));
+            }
+
+            inr.close();
+            inr = null;
+
+            in.close();
+            in = null;
+
+            if (out != null) {
+                out.close();
+                out = null;
+            }
+
+            if (err != null) {
+                err.close();
+                err = null;
+            }
+
+            return lines;
+
+        } catch (final InterruptedException ex) {
+            throw new IOException(
+                    "Command line threw an InterruptedException " +
+                    "for command " + Arrays.asList(cmdAttribs) + " timeout=" + timeout, ex);
+        } finally {
+            IOUtils.closeQuietly(in);
+            IOUtils.closeQuietly(out);
+            IOUtils.closeQuietly(err);
+            IOUtils.closeQuietly(inr);
+            if (proc != null) {
+                proc.destroy();
+            }
+        }
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/FileUtils.java b/src/main/java/org/apache/commons/io/FileUtils.java
new file mode 100644
index 0000000..156e563
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/FileUtils.java
@@ -0,0 +1,3547 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileFilter;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.Reader;
+import java.io.UncheckedIOException;
+import java.math.BigInteger;
+import java.net.URL;
+import java.nio.ByteBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.charset.UnsupportedCharsetException;
+import java.nio.file.CopyOption;
+import java.nio.file.FileVisitOption;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.NotDirectoryException;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.FileTime;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.ZoneId;
+import java.time.chrono.ChronoLocalDate;
+import java.time.chrono.ChronoLocalDateTime;
+import java.time.chrono.ChronoZonedDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import java.util.zip.CRC32;
+import java.util.zip.CheckedInputStream;
+import java.util.zip.Checksum;
+
+import org.apache.commons.io.file.AccumulatorPathVisitor;
+import org.apache.commons.io.file.Counters;
+import org.apache.commons.io.file.PathFilter;
+import org.apache.commons.io.file.PathUtils;
+import org.apache.commons.io.file.StandardDeleteOption;
+import org.apache.commons.io.filefilter.FileEqualsFileFilter;
+import org.apache.commons.io.filefilter.FileFileFilter;
+import org.apache.commons.io.filefilter.IOFileFilter;
+import org.apache.commons.io.filefilter.SuffixFileFilter;
+import org.apache.commons.io.filefilter.TrueFileFilter;
+import org.apache.commons.io.function.IOConsumer;
+import org.apache.commons.io.function.Uncheck;
+
+/**
+ * General file manipulation utilities.
+ * <p>
+ * Facilities are provided in the following areas:
+ * </p>
+ * <ul>
+ * <li>writing to a file
+ * <li>reading from a file
+ * <li>make a directory including parent directories
+ * <li>copying files and directories
+ * <li>deleting files and directories
+ * <li>converting to and from a URL
+ * <li>listing files and directories by filter and extension
+ * <li>comparing file content
+ * <li>file last changed date
+ * <li>calculating a checksum
+ * </ul>
+ * <p>
+ * Note that a specific charset should be specified whenever possible. Relying on the platform default means that the
+ * code is Locale-dependent. Only use the default if the files are known to always use the platform default.
+ * </p>
+ * <p>
+ * {@link SecurityException} are not documented in the Javadoc.
+ * </p>
+ * <p>
+ * Origin of code: Excalibur, Alexandria, Commons-Utils
+ * </p>
+ */
+public class FileUtils {
+
+    /**
+     * The number of bytes in a kilobyte.
+     */
+    public static final long ONE_KB = 1024;
+
+    /**
+     * The number of bytes in a kilobyte.
+     *
+     * @since 2.4
+     */
+    public static final BigInteger ONE_KB_BI = BigInteger.valueOf(ONE_KB);
+
+    /**
+     * The number of bytes in a megabyte.
+     */
+    public static final long ONE_MB = ONE_KB * ONE_KB;
+
+    /**
+     * The number of bytes in a megabyte.
+     *
+     * @since 2.4
+     */
+    public static final BigInteger ONE_MB_BI = ONE_KB_BI.multiply(ONE_KB_BI);
+
+    /**
+     * The number of bytes in a gigabyte.
+     */
+    public static final long ONE_GB = ONE_KB * ONE_MB;
+
+    /**
+     * The number of bytes in a gigabyte.
+     *
+     * @since 2.4
+     */
+    public static final BigInteger ONE_GB_BI = ONE_KB_BI.multiply(ONE_MB_BI);
+
+    /**
+     * The number of bytes in a terabyte.
+     */
+    public static final long ONE_TB = ONE_KB * ONE_GB;
+
+    /**
+     * The number of bytes in a terabyte.
+     *
+     * @since 2.4
+     */
+    public static final BigInteger ONE_TB_BI = ONE_KB_BI.multiply(ONE_GB_BI);
+
+    /**
+     * The number of bytes in a petabyte.
+     */
+    public static final long ONE_PB = ONE_KB * ONE_TB;
+
+    /**
+     * The number of bytes in a petabyte.
+     *
+     * @since 2.4
+     */
+    public static final BigInteger ONE_PB_BI = ONE_KB_BI.multiply(ONE_TB_BI);
+
+    /**
+     * The number of bytes in an exabyte.
+     */
+    public static final long ONE_EB = ONE_KB * ONE_PB;
+
+    /**
+     * The number of bytes in an exabyte.
+     *
+     * @since 2.4
+     */
+    public static final BigInteger ONE_EB_BI = ONE_KB_BI.multiply(ONE_PB_BI);
+
+    /**
+     * The number of bytes in a zettabyte.
+     */
+    public static final BigInteger ONE_ZB = BigInteger.valueOf(ONE_KB).multiply(BigInteger.valueOf(ONE_EB));
+
+    /**
+     * The number of bytes in a yottabyte.
+     */
+    public static final BigInteger ONE_YB = ONE_KB_BI.multiply(ONE_ZB);
+
+    /**
+     * An empty array of type {@link File}.
+     */
+    public static final File[] EMPTY_FILE_ARRAY = {};
+
+    /**
+     * Copies the given array and adds StandardCopyOption.COPY_ATTRIBUTES.
+     *
+     * @param copyOptions sorted copy options.
+     * @return a new array.
+     */
+    private static CopyOption[] addCopyAttributes(final CopyOption... copyOptions) {
+        // Make a copy first since we don't want to sort the call site's version.
+        final CopyOption[] actual = Arrays.copyOf(copyOptions, copyOptions.length + 1);
+        Arrays.sort(actual, 0, copyOptions.length);
+        if (Arrays.binarySearch(copyOptions, 0, copyOptions.length, StandardCopyOption.COPY_ATTRIBUTES) >= 0) {
+            return copyOptions;
+        }
+        actual[actual.length - 1] = StandardCopyOption.COPY_ATTRIBUTES;
+        return actual;
+    }
+
+    /**
+     * Returns a human-readable version of the file size, where the input represents a specific number of bytes.
+     * <p>
+     * If the size is over 1GB, the size is returned as the number of whole GB, i.e. the size is rounded down to the
+     * nearest GB boundary.
+     * </p>
+     * <p>
+     * Similarly for the 1MB and 1KB boundaries.
+     * </p>
+     *
+     * @param size the number of bytes
+     * @return a human-readable display value (includes units - EB, PB, TB, GB, MB, KB or bytes)
+     * @throws NullPointerException if the given {@link BigInteger} is {@code null}.
+     * @see <a href="https://issues.apache.org/jira/browse/IO-226">IO-226 - should the rounding be changed?</a>
+     * @since 2.4
+     */
+    // See https://issues.apache.org/jira/browse/IO-226 - should the rounding be changed?
+    public static String byteCountToDisplaySize(final BigInteger size) {
+        Objects.requireNonNull(size, "size");
+        final String displaySize;
+
+        if (size.divide(ONE_EB_BI).compareTo(BigInteger.ZERO) > 0) {
+            displaySize = size.divide(ONE_EB_BI) + " EB";
+        } else if (size.divide(ONE_PB_BI).compareTo(BigInteger.ZERO) > 0) {
+            displaySize = size.divide(ONE_PB_BI) + " PB";
+        } else if (size.divide(ONE_TB_BI).compareTo(BigInteger.ZERO) > 0) {
+            displaySize = size.divide(ONE_TB_BI) + " TB";
+        } else if (size.divide(ONE_GB_BI).compareTo(BigInteger.ZERO) > 0) {
+            displaySize = size.divide(ONE_GB_BI) + " GB";
+        } else if (size.divide(ONE_MB_BI).compareTo(BigInteger.ZERO) > 0) {
+            displaySize = size.divide(ONE_MB_BI) + " MB";
+        } else if (size.divide(ONE_KB_BI).compareTo(BigInteger.ZERO) > 0) {
+            displaySize = size.divide(ONE_KB_BI) + " KB";
+        } else {
+            displaySize = size + " bytes";
+        }
+        return displaySize;
+    }
+
+    /**
+     * Returns a human-readable version of the file size, where the input represents a specific number of bytes.
+     * <p>
+     * If the size is over 1GB, the size is returned as the number of whole GB, i.e. the size is rounded down to the
+     * nearest GB boundary.
+     * </p>
+     * <p>
+     * Similarly for the 1MB and 1KB boundaries.
+     * </p>
+     *
+     * @param size the number of bytes
+     * @return a human-readable display value (includes units - EB, PB, TB, GB, MB, KB or bytes)
+     * @see <a href="https://issues.apache.org/jira/browse/IO-226">IO-226 - should the rounding be changed?</a>
+     */
+    // See https://issues.apache.org/jira/browse/IO-226 - should the rounding be changed?
+    public static String byteCountToDisplaySize(final long size) {
+        return byteCountToDisplaySize(BigInteger.valueOf(size));
+    }
+
+    /**
+     * Returns a human-readable version of the file size, where the input represents a specific number of bytes.
+     * <p>
+     * If the size is over 1GB, the size is returned as the number of whole GB, i.e. the size is rounded down to the
+     * nearest GB boundary.
+     * </p>
+     * <p>
+     * Similarly for the 1MB and 1KB boundaries.
+     * </p>
+     *
+     * @param size the number of bytes
+     * @return a human-readable display value (includes units - EB, PB, TB, GB, MB, KB or bytes)
+     * @see <a href="https://issues.apache.org/jira/browse/IO-226">IO-226 - should the rounding be changed?</a>
+     * @since 2.12.0
+     */
+    // See https://issues.apache.org/jira/browse/IO-226 - should the rounding be changed?
+    public static String byteCountToDisplaySize(final Number size) {
+        return byteCountToDisplaySize(size.longValue());
+    }
+
+    /**
+     * Computes the checksum of a file using the specified checksum object. Multiple files may be checked using one
+     * {@link Checksum} instance if desired simply by reusing the same checksum object. For example:
+     *
+     * <pre>
+     * long checksum = FileUtils.checksum(file, new CRC32()).getValue();
+     * </pre>
+     *
+     * @param file the file to checksum, must not be {@code null}
+     * @param checksum the checksum object to be used, must not be {@code null}
+     * @return the checksum specified, updated with the content of the file
+     * @throws NullPointerException if the given {@link File} is {@code null}.
+     * @throws NullPointerException if the given {@link Checksum} is {@code null}.
+     * @throws IllegalArgumentException if the given {@link File} does not exist or is not a file.
+     * @throws IOException if an IO error occurs reading the file.
+     * @since 1.3
+     */
+    public static Checksum checksum(final File file, final Checksum checksum) throws IOException {
+        requireExistsChecked(file, "file");
+        requireFile(file, "file");
+        Objects.requireNonNull(checksum, "checksum");
+        try (InputStream inputStream = new CheckedInputStream(Files.newInputStream(file.toPath()), checksum)) {
+            IOUtils.consume(inputStream);
+        }
+        return checksum;
+    }
+
+    /**
+     * Computes the checksum of a file using the CRC32 checksum routine.
+     * The value of the checksum is returned.
+     *
+     * @param file the file to checksum, must not be {@code null}
+     * @return the checksum value
+     * @throws NullPointerException if the given {@link File} is {@code null}.
+     * @throws IllegalArgumentException if the given {@link File} does not exist or is not a file.
+     * @throws IOException              if an IO error occurs reading the file.
+     * @since 1.3
+     */
+    public static long checksumCRC32(final File file) throws IOException {
+        return checksum(file, new CRC32()).getValue();
+    }
+
+    /**
+     * Cleans a directory without deleting it.
+     *
+     * @param directory directory to clean
+     * @throws NullPointerException if the given {@link File} is {@code null}.
+     * @throws IllegalArgumentException if directory does not exist or is not a directory.
+     * @throws IOException if an I/O error occurs.
+     * @see #forceDelete(File)
+     */
+    public static void cleanDirectory(final File directory) throws IOException {
+        IOConsumer.forAll(FileUtils::forceDelete, listFiles(directory, null));
+    }
+
+    /**
+     * Cleans a directory without deleting it.
+     *
+     * @param directory directory to clean, must not be {@code null}
+     * @throws NullPointerException if the given {@link File} is {@code null}.
+     * @throws IllegalArgumentException if directory does not exist or is not a directory.
+     * @throws IOException if an I/O error occurs.
+     * @see #forceDeleteOnExit(File)
+     */
+    private static void cleanDirectoryOnExit(final File directory) throws IOException {
+        IOConsumer.forAll(FileUtils::forceDeleteOnExit, listFiles(directory, null));
+    }
+
+    /**
+     * Tests whether the contents of two files are equal.
+     * <p>
+     * This method checks to see if the two files are different lengths or if they point to the same file, before
+     * resorting to byte-by-byte comparison of the contents.
+     * </p>
+     * <p>
+     * Code origin: Avalon
+     * </p>
+     *
+     * @param file1 the first file
+     * @param file2 the second file
+     * @return true if the content of the files are equal or they both don't exist, false otherwise
+     * @throws IllegalArgumentException when an input is not a file.
+     * @throws IOException If an I/O error occurs.
+     * @see org.apache.commons.io.file.PathUtils#fileContentEquals(Path,Path,java.nio.file.LinkOption[],java.nio.file.OpenOption...)
+     */
+    public static boolean contentEquals(final File file1, final File file2) throws IOException {
+        if (file1 == null && file2 == null) {
+            return true;
+        }
+        if (file1 == null || file2 == null) {
+            return false;
+        }
+        final boolean file1Exists = file1.exists();
+        if (file1Exists != file2.exists()) {
+            return false;
+        }
+
+        if (!file1Exists) {
+            // two not existing files are equal
+            return true;
+        }
+
+        requireFile(file1, "file1");
+        requireFile(file2, "file2");
+
+        if (file1.length() != file2.length()) {
+            // lengths differ, cannot be equal
+            return false;
+        }
+
+        if (file1.getCanonicalFile().equals(file2.getCanonicalFile())) {
+            // same file
+            return true;
+        }
+
+        try (InputStream input1 = Files.newInputStream(file1.toPath()); InputStream input2 = Files.newInputStream(file2.toPath())) {
+            return IOUtils.contentEquals(input1, input2);
+        }
+    }
+
+    /**
+     * Compares the contents of two files to determine if they are equal or not.
+     * <p>
+     * This method checks to see if the two files point to the same file,
+     * before resorting to line-by-line comparison of the contents.
+     * </p>
+     *
+     * @param file1       the first file
+     * @param file2       the second file
+     * @param charsetName the name of the requested charset.
+     *                    May be null, in which case the platform default is used
+     * @return true if the content of the files are equal or neither exists,
+     * false otherwise
+     * @throws IllegalArgumentException when an input is not a file.
+     * @throws IOException in case of an I/O error.
+     * @throws UnsupportedCharsetException If the named charset is unavailable (unchecked exception).
+     * @see IOUtils#contentEqualsIgnoreEOL(Reader, Reader)
+     * @since 2.2
+     */
+    public static boolean contentEqualsIgnoreEOL(final File file1, final File file2, final String charsetName)
+            throws IOException {
+        if (file1 == null && file2 == null) {
+            return true;
+        }
+        if (file1 == null || file2 == null) {
+            return false;
+        }
+        final boolean file1Exists = file1.exists();
+        if (file1Exists != file2.exists()) {
+            return false;
+        }
+
+        if (!file1Exists) {
+            // two not existing files are equal
+            return true;
+        }
+
+        requireFile(file1, "file1");
+        requireFile(file2, "file2");
+
+        if (file1.getCanonicalFile().equals(file2.getCanonicalFile())) {
+            // same file
+            return true;
+        }
+
+        final Charset charset = Charsets.toCharset(charsetName);
+        try (Reader input1 = new InputStreamReader(Files.newInputStream(file1.toPath()), charset);
+             Reader input2 = new InputStreamReader(Files.newInputStream(file2.toPath()), charset)) {
+            return IOUtils.contentEqualsIgnoreEOL(input1, input2);
+        }
+    }
+
+    /**
+     * Converts a Collection containing java.io.File instances into array
+     * representation. This is to account for the difference between
+     * File.listFiles() and FileUtils.listFiles().
+     *
+     * @param files a Collection containing java.io.File instances
+     * @return an array of java.io.File
+     */
+    public static File[] convertFileCollectionToFileArray(final Collection<File> files) {
+        return files.toArray(EMPTY_FILE_ARRAY);
+    }
+
+    /**
+     * Copies a whole directory to a new location preserving the file dates.
+     * <p>
+     * This method copies the specified directory and all its child directories and files to the specified destination.
+     * The destination is the new location and name of the directory.
+     * </p>
+     * <p>
+     * The destination directory is created if it does not exist. If the destination directory did exist, then this
+     * method merges the source with the destination, with the source taking precedence.
+     * </p>
+     * <p>
+     * <strong>Note:</strong> This method tries to preserve the files' last modified date/times using
+     * {@link File#setLastModified(long)}, however it is not guaranteed that those operations will succeed. If the
+     * modification operation fails, the methods throws IOException.
+     * </p>
+     *
+     * @param srcDir an existing directory to copy, must not be {@code null}.
+     * @param destDir the new directory, must not be {@code null}.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws IllegalArgumentException if the source or destination is invalid.
+     * @throws FileNotFoundException if the source does not exist.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @since 1.1
+     */
+    public static void copyDirectory(final File srcDir, final File destDir) throws IOException {
+        copyDirectory(srcDir, destDir, true);
+    }
+
+    /**
+     * Copies a whole directory to a new location.
+     * <p>
+     * This method copies the contents of the specified source directory to within the specified destination directory.
+     * </p>
+     * <p>
+     * The destination directory is created if it does not exist. If the destination directory did exist, then this
+     * method merges the source with the destination, with the source taking precedence.
+     * </p>
+     * <p>
+     * <strong>Note:</strong> Setting {@code preserveFileDate} to {@code true} tries to preserve the files' last
+     * modified date/times using {@link File#setLastModified(long)}, however it is not guaranteed that those operations
+     * will succeed. If the modification operation fails, the methods throws IOException.
+     * </p>
+     *
+     * @param srcDir an existing directory to copy, must not be {@code null}.
+     * @param destDir the new directory, must not be {@code null}.
+     * @param preserveFileDate true if the file date of the copy should be the same as the original.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws IllegalArgumentException if the source or destination is invalid.
+     * @throws FileNotFoundException if the source does not exist.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @since 1.1
+     */
+    public static void copyDirectory(final File srcDir, final File destDir, final boolean preserveFileDate)
+        throws IOException {
+        copyDirectory(srcDir, destDir, null, preserveFileDate);
+    }
+
+    /**
+     * Copies a filtered directory to a new location preserving the file dates.
+     * <p>
+     * This method copies the contents of the specified source directory to within the specified destination directory.
+     * </p>
+     * <p>
+     * The destination directory is created if it does not exist. If the destination directory did exist, then this
+     * method merges the source with the destination, with the source taking precedence.
+     * </p>
+     * <p>
+     * <strong>Note:</strong> This method tries to preserve the files' last modified date/times using
+     * {@link File#setLastModified(long)}, however it is not guaranteed that those operations will succeed. If the
+     * modification operation fails, the methods throws IOException.
+     * </p>
+     * <b>Example: Copy directories only</b>
+     *
+     * <pre>
+     * // only copy the directory structure
+     * FileUtils.copyDirectory(srcDir, destDir, DirectoryFileFilter.DIRECTORY);
+     * </pre>
+     *
+     * <b>Example: Copy directories and txt files</b>
+     *
+     * <pre>
+     * // Create a filter for ".txt" files
+     * IOFileFilter txtSuffixFilter = FileFilterUtils.suffixFileFilter(".txt");
+     * IOFileFilter txtFiles = FileFilterUtils.andFileFilter(FileFileFilter.FILE, txtSuffixFilter);
+     *
+     * // Create a filter for either directories or ".txt" files
+     * FileFilter filter = FileFilterUtils.orFileFilter(DirectoryFileFilter.DIRECTORY, txtFiles);
+     *
+     * // Copy using the filter
+     * FileUtils.copyDirectory(srcDir, destDir, filter);
+     * </pre>
+     *
+     * @param srcDir an existing directory to copy, must not be {@code null}.
+     * @param destDir the new directory, must not be {@code null}.
+     * @param filter the filter to apply, null means copy all directories and files should be the same as the original.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws IllegalArgumentException if the source or destination is invalid.
+     * @throws FileNotFoundException if the source does not exist.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @since 1.4
+     */
+    public static void copyDirectory(final File srcDir, final File destDir, final FileFilter filter)
+        throws IOException {
+        copyDirectory(srcDir, destDir, filter, true);
+    }
+
+    /**
+     * Copies a filtered directory to a new location.
+     * <p>
+     * This method copies the contents of the specified source directory to within the specified destination directory.
+     * </p>
+     * <p>
+     * The destination directory is created if it does not exist. If the destination directory did exist, then this
+     * method merges the source with the destination, with the source taking precedence.
+     * </p>
+     * <p>
+     * <strong>Note:</strong> Setting {@code preserveFileDate} to {@code true} tries to preserve the files' last
+     * modified date/times using {@link File#setLastModified(long)}, however it is not guaranteed that those operations
+     * will succeed. If the modification operation fails, the methods throws IOException.
+     * </p>
+     * <b>Example: Copy directories only</b>
+     *
+     * <pre>
+     * // only copy the directory structure
+     * FileUtils.copyDirectory(srcDir, destDir, DirectoryFileFilter.DIRECTORY, false);
+     * </pre>
+     *
+     * <b>Example: Copy directories and txt files</b>
+     *
+     * <pre>
+     * // Create a filter for ".txt" files
+     * IOFileFilter txtSuffixFilter = FileFilterUtils.suffixFileFilter(".txt");
+     * IOFileFilter txtFiles = FileFilterUtils.andFileFilter(FileFileFilter.FILE, txtSuffixFilter);
+     *
+     * // Create a filter for either directories or ".txt" files
+     * FileFilter filter = FileFilterUtils.orFileFilter(DirectoryFileFilter.DIRECTORY, txtFiles);
+     *
+     * // Copy using the filter
+     * FileUtils.copyDirectory(srcDir, destDir, filter, false);
+     * </pre>
+     *
+     * @param srcDir an existing directory to copy, must not be {@code null}.
+     * @param destDir the new directory, must not be {@code null}.
+     * @param filter the filter to apply, null means copy all directories and files.
+     * @param preserveFileDate true if the file date of the copy should be the same as the original.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws IllegalArgumentException if the source or destination is invalid.
+     * @throws FileNotFoundException if the source does not exist.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @since 1.4
+     */
+    public static void copyDirectory(final File srcDir, final File destDir, final FileFilter filter, final boolean preserveFileDate) throws IOException {
+        copyDirectory(srcDir, destDir, filter, preserveFileDate, StandardCopyOption.REPLACE_EXISTING);
+    }
+
+    /**
+     * Copies a filtered directory to a new location.
+     * <p>
+     * This method copies the contents of the specified source directory to within the specified destination directory.
+     * </p>
+     * <p>
+     * The destination directory is created if it does not exist. If the destination directory did exist, then this
+     * method merges the source with the destination, with the source taking precedence.
+     * </p>
+     * <p>
+     * <strong>Note:</strong> Setting {@code preserveFileDate} to {@code true} tries to preserve the files' last
+     * modified date/times using {@link File#setLastModified(long)}, however it is not guaranteed that those operations
+     * will succeed. If the modification operation fails, the methods throws IOException.
+     * </p>
+     * <b>Example: Copy directories only</b>
+     *
+     * <pre>
+     * // only copy the directory structure
+     * FileUtils.copyDirectory(srcDir, destDir, DirectoryFileFilter.DIRECTORY, false);
+     * </pre>
+     *
+     * <b>Example: Copy directories and txt files</b>
+     *
+     * <pre>
+     * // Create a filter for ".txt" files
+     * IOFileFilter txtSuffixFilter = FileFilterUtils.suffixFileFilter(".txt");
+     * IOFileFilter txtFiles = FileFilterUtils.andFileFilter(FileFileFilter.FILE, txtSuffixFilter);
+     *
+     * // Create a filter for either directories or ".txt" files
+     * FileFilter filter = FileFilterUtils.orFileFilter(DirectoryFileFilter.DIRECTORY, txtFiles);
+     *
+     * // Copy using the filter
+     * FileUtils.copyDirectory(srcDir, destDir, filter, false);
+     * </pre>
+     *
+     * @param srcDir an existing directory to copy, must not be {@code null}
+     * @param destDir the new directory, must not be {@code null}
+     * @param fileFilter the filter to apply, null means copy all directories and files
+     * @param preserveFileDate true if the file date of the copy should be the same as the original
+     * @param copyOptions options specifying how the copy should be done, for example {@link StandardCopyOption}.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws IllegalArgumentException if the source or destination is invalid.
+     * @throws FileNotFoundException if the source does not exist.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @since 2.8.0
+     */
+    public static void copyDirectory(final File srcDir, final File destDir, final FileFilter fileFilter, final boolean preserveFileDate,
+        final CopyOption... copyOptions) throws IOException {
+        requireFileCopy(srcDir, destDir);
+        requireDirectory(srcDir, "srcDir");
+        requireCanonicalPathsNotEquals(srcDir, destDir);
+
+        // Cater for destination being directory within the source directory (see IO-141)
+        List<String> exclusionList = null;
+        final String srcDirCanonicalPath = srcDir.getCanonicalPath();
+        final String destDirCanonicalPath = destDir.getCanonicalPath();
+        if (destDirCanonicalPath.startsWith(srcDirCanonicalPath)) {
+            final File[] srcFiles = listFiles(srcDir, fileFilter);
+            if (srcFiles.length > 0) {
+                exclusionList = new ArrayList<>(srcFiles.length);
+                for (final File srcFile : srcFiles) {
+                    exclusionList.add(new File(destDir, srcFile.getName()).getCanonicalPath());
+                }
+            }
+        }
+        doCopyDirectory(srcDir, destDir, fileFilter, exclusionList, preserveFileDate, preserveFileDate ? addCopyAttributes(copyOptions) : copyOptions);
+    }
+
+    /**
+     * Copies a directory to within another directory preserving the file dates.
+     * <p>
+     * This method copies the source directory and all its contents to a directory of the same name in the specified
+     * destination directory.
+     * </p>
+     * <p>
+     * The destination directory is created if it does not exist. If the destination directory did exist, then this
+     * method merges the source with the destination, with the source taking precedence.
+     * </p>
+     * <p>
+     * <strong>Note:</strong> This method tries to preserve the files' last modified date/times using
+     * {@link File#setLastModified(long)}, however it is not guaranteed that those operations will succeed. If the
+     * modification operation fails, the methods throws IOException.
+     * </p>
+     *
+     * @param sourceDir an existing directory to copy, must not be {@code null}.
+     * @param destinationDir the directory to place the copy in, must not be {@code null}.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws IllegalArgumentException if the source or destination is invalid.
+     * @throws FileNotFoundException if the source does not exist.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @since 1.2
+     */
+    public static void copyDirectoryToDirectory(final File sourceDir, final File destinationDir) throws IOException {
+        requireDirectoryIfExists(sourceDir, "sourceDir");
+        requireDirectoryIfExists(destinationDir, "destinationDir");
+        copyDirectory(sourceDir, new File(destinationDir, sourceDir.getName()), true);
+    }
+
+    /**
+     * Copies a file to a new location preserving the file date.
+     * <p>
+     * This method copies the contents of the specified source file to the specified destination file. The directory
+     * holding the destination file is created if it does not exist. If the destination file exists, then this method
+     * will overwrite it.
+     * </p>
+     * <p>
+     * <strong>Note:</strong> This method tries to preserve the file's last modified date/times using
+     * {@link StandardCopyOption#COPY_ATTRIBUTES}, however it is not guaranteed that the operation will succeed. If the
+     * modification operation fails, the methods throws IOException.
+     * </p>
+     *
+     * @param srcFile an existing file to copy, must not be {@code null}.
+     * @param destFile the new file, must not be {@code null}.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws IOException if source or destination is invalid.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @throws IOException if the output file length is not the same as the input file length after the copy completes.
+     * @see #copyFileToDirectory(File, File)
+     * @see #copyFile(File, File, boolean)
+     */
+    public static void copyFile(final File srcFile, final File destFile) throws IOException {
+        copyFile(srcFile, destFile, StandardCopyOption.COPY_ATTRIBUTES, StandardCopyOption.REPLACE_EXISTING);
+    }
+
+    /**
+     * Copies an existing file to a new file location.
+     * <p>
+     * This method copies the contents of the specified source file to the specified destination file. The directory
+     * holding the destination file is created if it does not exist. If the destination file exists, then this method
+     * will overwrite it.
+     * </p>
+     * <p>
+     * <strong>Note:</strong> Setting {@code preserveFileDate} to {@code true} tries to preserve the file's last
+     * modified date/times using {@link StandardCopyOption#COPY_ATTRIBUTES}, however it is not guaranteed that the operation
+     * will succeed. If the modification operation fails, the methods throws IOException.
+     * </p>
+     *
+     * @param srcFile an existing file to copy, must not be {@code null}.
+     * @param destFile the new file, must not be {@code null}.
+     * @param preserveFileDate true if the file date of the copy should be the same as the original.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws IOException if source or destination is invalid.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @throws IOException if the output file length is not the same as the input file length after the copy completes
+     * @see #copyFile(File, File, boolean, CopyOption...)
+     */
+    public static void copyFile(final File srcFile, final File destFile, final boolean preserveFileDate) throws IOException {
+        // @formatter:off
+        copyFile(srcFile, destFile, preserveFileDate
+                ? new CopyOption[] {StandardCopyOption.COPY_ATTRIBUTES, StandardCopyOption.REPLACE_EXISTING}
+                : new CopyOption[] {StandardCopyOption.REPLACE_EXISTING});
+        // @formatter:on
+    }
+
+    /**
+     * Copies a file to a new location.
+     * <p>
+     * This method copies the contents of the specified source file to the specified destination file. The directory
+     * holding the destination file is created if it does not exist. If the destination file exists, you can overwrite
+     * it with {@link StandardCopyOption#REPLACE_EXISTING}.
+     * </p>
+     * <p>
+     * <strong>Note:</strong> Setting {@code preserveFileDate} to {@code true} tries to preserve the file's last
+     * modified date/times using {@link StandardCopyOption#COPY_ATTRIBUTES}, however it is not guaranteed that the operation
+     * will succeed. If the modification operation fails, the methods throws IOException.
+     * </p>
+     *
+     * @param srcFile an existing file to copy, must not be {@code null}.
+     * @param destFile the new file, must not be {@code null}.
+     * @param preserveFileDate true if the file date of the copy should be the same as the original.
+     * @param copyOptions options specifying how the copy should be done, for example {@link StandardCopyOption}..
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws FileNotFoundException if the source does not exist.
+     * @throws IllegalArgumentException if source is not a file.
+     * @throws IOException if the output file length is not the same as the input file length after the copy completes.
+     * @throws IOException if an I/O error occurs, or setting the last-modified time didn't succeed.
+     * @see #copyFileToDirectory(File, File, boolean)
+     * @since 2.8.0
+     */
+    public static void copyFile(final File srcFile, final File destFile, final boolean preserveFileDate, final CopyOption... copyOptions) throws IOException {
+        copyFile(srcFile, destFile, preserveFileDate ? addCopyAttributes(copyOptions) : copyOptions);
+    }
+
+    /**
+     * Copies a file to a new location.
+     * <p>
+     * This method copies the contents of the specified source file to the specified destination file. The directory
+     * holding the destination file is created if it does not exist. If the destination file exists, you can overwrite
+     * it if you use {@link StandardCopyOption#REPLACE_EXISTING}.
+     * </p>
+     *
+     * @param srcFile an existing file to copy, must not be {@code null}.
+     * @param destFile the new file, must not be {@code null}.
+     * @param copyOptions options specifying how the copy should be done, for example {@link StandardCopyOption}..
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws FileNotFoundException if the source does not exist.
+     * @throws IllegalArgumentException if source is not a file.
+     * @throws IOException if an I/O error occurs.
+     * @see StandardCopyOption
+     * @since 2.9.0
+     */
+    public static void copyFile(final File srcFile, final File destFile, final CopyOption... copyOptions) throws IOException {
+        requireFileCopy(srcFile, destFile);
+        requireFile(srcFile, "srcFile");
+        requireCanonicalPathsNotEquals(srcFile, destFile);
+        createParentDirectories(destFile);
+        requireFileIfExists(destFile, "destFile");
+        if (destFile.exists()) {
+            requireCanWrite(destFile, "destFile");
+        }
+        // On Windows, the last modified time is copied by default.
+        Files.copy(srcFile.toPath(), destFile.toPath(), copyOptions);
+    }
+
+    /**
+     * Copies bytes from a {@link File} to an {@link OutputStream}.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a {@link BufferedInputStream}.
+     * </p>
+     *
+     * @param input  the {@link File} to read.
+     * @param output the {@link OutputStream} to write.
+     * @return the number of bytes copied
+     * @throws NullPointerException if the File is {@code null}.
+     * @throws NullPointerException if the OutputStream is {@code null}.
+     * @throws IOException          if an I/O error occurs.
+     * @since 2.1
+     */
+    public static long copyFile(final File input, final OutputStream output) throws IOException {
+        try (InputStream fis = Files.newInputStream(input.toPath())) {
+            return IOUtils.copyLarge(fis, output);
+        }
+    }
+
+    /**
+     * Copies a file to a directory preserving the file date.
+     * <p>
+     * This method copies the contents of the specified source file to a file of the same name in the specified
+     * destination directory. The destination directory is created if it does not exist. If the destination file exists,
+     * then this method will overwrite it.
+     * </p>
+     * <p>
+     * <strong>Note:</strong> This method tries to preserve the file's last modified date/times using
+     * {@link StandardCopyOption#COPY_ATTRIBUTES}, however it is not guaranteed that the operation will succeed. If the
+     * modification operation fails, the methods throws IOException.
+     * </p>
+     *
+     * @param srcFile an existing file to copy, must not be {@code null}.
+     * @param destDir the directory to place the copy in, must not be {@code null}.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws IllegalArgumentException if source or destination is invalid.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @see #copyFile(File, File, boolean)
+     */
+    public static void copyFileToDirectory(final File srcFile, final File destDir) throws IOException {
+        copyFileToDirectory(srcFile, destDir, true);
+    }
+
+    /**
+     * Copies a file to a directory optionally preserving the file date.
+     * <p>
+     * This method copies the contents of the specified source file to a file of the same name in the specified
+     * destination directory. The destination directory is created if it does not exist. If the destination file exists,
+     * then this method will overwrite it.
+     * </p>
+     * <p>
+     * <strong>Note:</strong> Setting {@code preserveFileDate} to {@code true} tries to preserve the file's last
+     * modified date/times using {@link StandardCopyOption#COPY_ATTRIBUTES}, however it is not guaranteed that the operation
+     * will succeed. If the modification operation fails, the methods throws IOException.
+     * </p>
+     *
+     * @param sourceFile an existing file to copy, must not be {@code null}.
+     * @param destinationDir the directory to place the copy in, must not be {@code null}.
+     * @param preserveFileDate true if the file date of the copy should be the same as the original.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @throws IOException if the output file length is not the same as the input file length after the copy completes.
+     * @see #copyFile(File, File, CopyOption...)
+     * @since 1.3
+     */
+    public static void copyFileToDirectory(final File sourceFile, final File destinationDir, final boolean preserveFileDate) throws IOException {
+        Objects.requireNonNull(sourceFile, "sourceFile");
+        requireDirectoryIfExists(destinationDir, "destinationDir");
+        copyFile(sourceFile, new File(destinationDir, sourceFile.getName()), preserveFileDate);
+    }
+
+    /**
+     * Copies bytes from an {@link InputStream} {@code source} to a file
+     * {@code destination}. The directories up to {@code destination}
+     * will be created if they don't already exist. {@code destination}
+     * will be overwritten if it already exists.
+     * <p>
+     * <em>The {@code source} stream is closed.</em>
+     * </p>
+     * <p>
+     * See {@link #copyToFile(InputStream, File)} for a method that does not close the input stream.
+     * </p>
+     *
+     * @param source      the {@link InputStream} to copy bytes from, must not be {@code null}, will be closed
+     * @param destination the non-directory {@link File} to write bytes to
+     *                    (possibly overwriting), must not be {@code null}
+     * @throws IOException if {@code destination} is a directory
+     * @throws IOException if {@code destination} cannot be written
+     * @throws IOException if {@code destination} needs creating but can't be
+     * @throws IOException if an IO error occurs during copying
+     * @since 2.0
+     */
+    public static void copyInputStreamToFile(final InputStream source, final File destination) throws IOException {
+        try (InputStream inputStream = source) {
+            copyToFile(inputStream, destination);
+        }
+    }
+
+    /**
+     * Copies a file or directory to within another directory preserving the file dates.
+     * <p>
+     * This method copies the source file or directory, along all its contents, to a directory of the same name in the
+     * specified destination directory.
+     * </p>
+     * <p>
+     * The destination directory is created if it does not exist. If the destination directory did exist, then this method
+     * merges the source with the destination, with the source taking precedence.
+     * </p>
+     * <p>
+     * <strong>Note:</strong> This method tries to preserve the files' last modified date/times using
+     * {@link StandardCopyOption#COPY_ATTRIBUTES} or {@link File#setLastModified(long)} depending on the input, however it
+     * is not guaranteed that those operations will succeed. If the modification operation fails, the methods throws
+     * IOException.
+     * </p>
+     *
+     * @param sourceFile an existing file or directory to copy, must not be {@code null}.
+     * @param destinationDir the directory to place the copy in, must not be {@code null}.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws IllegalArgumentException if the source or destination is invalid.
+     * @throws FileNotFoundException if the source does not exist.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @see #copyDirectoryToDirectory(File, File)
+     * @see #copyFileToDirectory(File, File)
+     * @since 2.6
+     */
+    public static void copyToDirectory(final File sourceFile, final File destinationDir) throws IOException {
+        Objects.requireNonNull(sourceFile, "sourceFile");
+        if (sourceFile.isFile()) {
+            copyFileToDirectory(sourceFile, destinationDir);
+        } else if (sourceFile.isDirectory()) {
+            copyDirectoryToDirectory(sourceFile, destinationDir);
+        } else {
+            throw new FileNotFoundException("The source " + sourceFile + " does not exist");
+        }
+    }
+
+    /**
+     * Copies a files to a directory preserving each file's date.
+     * <p>
+     * This method copies the contents of the specified source files
+     * to a file of the same name in the specified destination directory.
+     * The destination directory is created if it does not exist.
+     * If the destination file exists, then this method will overwrite it.
+     * </p>
+     * <p>
+     * <strong>Note:</strong> This method tries to preserve the file's last
+     * modified date/times using {@link File#setLastModified(long)}, however
+     * it is not guaranteed that the operation will succeed.
+     * If the modification operation fails, the methods throws IOException.
+     * </p>
+     *
+     * @param sourceIterable  existing files to copy, must not be {@code null}.
+     * @param destinationDir  the directory to place the copies in, must not be {@code null}.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws IOException if source or destination is invalid.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @see #copyFileToDirectory(File, File)
+     * @since 2.6
+     */
+    public static void copyToDirectory(final Iterable<File> sourceIterable, final File destinationDir) throws IOException {
+        Objects.requireNonNull(sourceIterable, "sourceIterable");
+        for (final File src : sourceIterable) {
+            copyFileToDirectory(src, destinationDir);
+        }
+    }
+
+    /**
+     * Copies bytes from an {@link InputStream} source to a {@link File} destination. The directories
+     * up to {@code destination} will be created if they don't already exist. {@code destination} will be
+     * overwritten if it already exists. The {@code source} stream is left open, e.g. for use with
+     * {@link java.util.zip.ZipInputStream ZipInputStream}. See {@link #copyInputStreamToFile(InputStream, File)} for a
+     * method that closes the input stream.
+     *
+     * @param inputStream the {@link InputStream} to copy bytes from, must not be {@code null}
+     * @param file the non-directory {@link File} to write bytes to (possibly overwriting), must not be
+     *        {@code null}
+     * @throws NullPointerException if the InputStream is {@code null}.
+     * @throws NullPointerException if the File is {@code null}.
+     * @throws IllegalArgumentException if the file object is a directory.
+     * @throws IllegalArgumentException if the file is not writable.
+     * @throws IOException if the directories could not be created.
+     * @throws IOException if an IO error occurs during copying.
+     * @since 2.5
+     */
+    public static void copyToFile(final InputStream inputStream, final File file) throws IOException {
+        try (OutputStream out = newOutputStream(file, false)) {
+            IOUtils.copy(inputStream, out);
+        }
+    }
+
+    /**
+     * Copies bytes from the URL {@code source} to a file
+     * {@code destination}. The directories up to {@code destination}
+     * will be created if they don't already exist. {@code destination}
+     * will be overwritten if it already exists.
+     * <p>
+     * Warning: this method does not set a connection or read timeout and thus
+     * might block forever. Use {@link #copyURLToFile(URL, File, int, int)}
+     * with reasonable timeouts to prevent this.
+     * </p>
+     *
+     * @param source      the {@link URL} to copy bytes from, must not be {@code null}
+     * @param destination the non-directory {@link File} to write bytes to
+     *                    (possibly overwriting), must not be {@code null}
+     * @throws IOException if {@code source} URL cannot be opened
+     * @throws IOException if {@code destination} is a directory
+     * @throws IOException if {@code destination} cannot be written
+     * @throws IOException if {@code destination} needs creating but can't be
+     * @throws IOException if an IO error occurs during copying
+     */
+    public static void copyURLToFile(final URL source, final File destination) throws IOException {
+        try (InputStream stream = source.openStream()) {
+            final Path path = destination.toPath();
+            PathUtils.createParentDirectories(path);
+            Files.copy(stream, path, StandardCopyOption.REPLACE_EXISTING);
+        }
+    }
+
+    /**
+     * Copies bytes from the URL {@code source} to a file {@code destination}. The directories up to
+     * {@code destination} will be created if they don't already exist. {@code destination} will be
+     * overwritten if it already exists.
+     *
+     * @param source the {@link URL} to copy bytes from, must not be {@code null}
+     * @param destination the non-directory {@link File} to write bytes to (possibly overwriting), must not be
+     *        {@code null}
+     * @param connectionTimeoutMillis the number of milliseconds until this method will time out if no connection could
+     *        be established to the {@code source}
+     * @param readTimeoutMillis the number of milliseconds until this method will time out if no data could be read from
+     *        the {@code source}
+     * @throws IOException if {@code source} URL cannot be opened
+     * @throws IOException if {@code destination} is a directory
+     * @throws IOException if {@code destination} cannot be written
+     * @throws IOException if {@code destination} needs creating but can't be
+     * @throws IOException if an IO error occurs during copying
+     * @since 2.0
+     */
+    public static void copyURLToFile(final URL source, final File destination, final int connectionTimeoutMillis, final int readTimeoutMillis)
+        throws IOException {
+        try (CloseableURLConnection urlConnection = CloseableURLConnection.open(source)) {
+            urlConnection.setConnectTimeout(connectionTimeoutMillis);
+            urlConnection.setReadTimeout(readTimeoutMillis);
+            try (InputStream stream = urlConnection.getInputStream()) {
+                copyInputStreamToFile(stream, destination);
+            }
+        }
+    }
+
+    /**
+     * Creates all parent directories for a File object.
+     *
+     * @param file the File that may need parents, may be null.
+     * @return The parent directory, or {@code null} if the given file does not name a parent
+     * @throws IOException if the directory was not created along with all its parent directories.
+     * @throws IOException if the given file object is not null and not a directory.
+     * @since 2.9.0
+     */
+    public static File createParentDirectories(final File file) throws IOException {
+        return mkdirs(getParentFile(file));
+    }
+
+    /**
+     * Gets the current directory.
+     *
+     * @return the current directory.
+     * @since 2.12.0
+     */
+    public static File current() {
+        return PathUtils.current().toFile();
+    }
+
+    /**
+     * Decodes the specified URL as per RFC 3986, i.e. transforms
+     * percent-encoded octets to characters by decoding with the UTF-8 character
+     * set. This function is primarily intended for usage with
+     * {@link java.net.URL} which unfortunately does not enforce proper URLs. As
+     * such, this method will leniently accept invalid characters or malformed
+     * percent-encoded octets and simply pass them literally through to the
+     * result string. Except for rare edge cases, this will make unencoded URLs
+     * pass through unaltered.
+     *
+     * @param url The URL to decode, may be {@code null}.
+     * @return The decoded URL or {@code null} if the input was
+     * {@code null}.
+     */
+    static String decodeUrl(final String url) {
+        String decoded = url;
+        if (url != null && url.indexOf('%') >= 0) {
+            final int n = url.length();
+            final StringBuilder builder = new StringBuilder();
+            final ByteBuffer byteBuffer = ByteBuffer.allocate(n);
+            for (int i = 0; i < n; ) {
+                if (url.charAt(i) == '%') {
+                    try {
+                        do {
+                            final byte octet = (byte) Integer.parseInt(url.substring(i + 1, i + 3), 16);
+                            byteBuffer.put(octet);
+                            i += 3;
+                        } while (i < n && url.charAt(i) == '%');
+                        continue;
+                    } catch (final RuntimeException ignored) {
+                        // malformed percent-encoded octet, fall through and
+                        // append characters literally
+                    } finally {
+                        if (byteBuffer.position() > 0) {
+                            byteBuffer.flip();
+                            builder.append(StandardCharsets.UTF_8.decode(byteBuffer).toString());
+                            byteBuffer.clear();
+                        }
+                    }
+                }
+                builder.append(url.charAt(i++));
+            }
+            decoded = builder.toString();
+        }
+        return decoded;
+    }
+
+    /**
+     * Deletes the given File but throws an IOException if it cannot, unlike {@link File#delete()} which returns a
+     * boolean.
+     *
+     * @param file The file to delete.
+     * @return the given file.
+     * @throws NullPointerException     if the parameter is {@code null}
+     * @throws IOException              if the file cannot be deleted.
+     * @see File#delete()
+     * @since 2.9.0
+     */
+    public static File delete(final File file) throws IOException {
+        Objects.requireNonNull(file, "file");
+        Files.delete(file.toPath());
+        return file;
+    }
+
+    /**
+     * Deletes a directory recursively.
+     *
+     * @param directory directory to delete
+     * @throws IOException              in case deletion is unsuccessful
+     * @throws NullPointerException     if the parameter is {@code null}
+     * @throws IllegalArgumentException if {@code directory} is not a directory
+     */
+    public static void deleteDirectory(final File directory) throws IOException {
+        Objects.requireNonNull(directory, "directory");
+        if (!directory.exists()) {
+            return;
+        }
+        if (!isSymlink(directory)) {
+            cleanDirectory(directory);
+        }
+        delete(directory);
+    }
+
+    /**
+     * Schedules a directory recursively for deletion on JVM exit.
+     *
+     * @param directory directory to delete, must not be {@code null}
+     * @throws NullPointerException if the directory is {@code null}
+     * @throws IOException          in case deletion is unsuccessful
+     */
+    private static void deleteDirectoryOnExit(final File directory) throws IOException {
+        if (!directory.exists()) {
+            return;
+        }
+        directory.deleteOnExit();
+        if (!isSymlink(directory)) {
+            cleanDirectoryOnExit(directory);
+        }
+    }
+
+    /**
+     * Deletes a file, never throwing an exception. If file is a directory, delete it and all subdirectories.
+     * <p>
+     * The difference between File.delete() and this method are:
+     * </p>
+     * <ul>
+     * <li>A directory to be deleted does not have to be empty.</li>
+     * <li>No exceptions are thrown when a file or directory cannot be deleted.</li>
+     * </ul>
+     *
+     * @param file file or directory to delete, can be {@code null}
+     * @return {@code true} if the file or directory was deleted, otherwise
+     * {@code false}
+     * @since 1.4
+     */
+    public static boolean deleteQuietly(final File file) {
+        if (file == null) {
+            return false;
+        }
+        try {
+            if (file.isDirectory()) {
+                cleanDirectory(file);
+            }
+        } catch (final Exception ignored) {
+            // ignore
+        }
+
+        try {
+            return file.delete();
+        } catch (final Exception ignored) {
+            return false;
+        }
+    }
+
+    /**
+     * Determines whether the {@code parent} directory contains the {@code child} element (a file or directory).
+     * <p>
+     * Files are normalized before comparison.
+     * </p>
+     *
+     * Edge cases:
+     * <ul>
+     * <li>A {@code directory} must not be null: if null, throw IllegalArgumentException</li>
+     * <li>A {@code directory} must be a directory: if not a directory, throw IllegalArgumentException</li>
+     * <li>A directory does not contain itself: return false</li>
+     * <li>A null child file is not contained in any parent: return false</li>
+     * </ul>
+     *
+     * @param directory the file to consider as the parent.
+     * @param child     the file to consider as the child.
+     * @return true is the candidate leaf is under by the specified composite. False otherwise.
+     * @throws IOException              if an IO error occurs while checking the files.
+     * @throws NullPointerException if the given {@link File} is {@code null}.
+     * @throws IllegalArgumentException if the given {@link File} does not exist or is not a directory.
+     * @see FilenameUtils#directoryContains(String, String)
+     * @since 2.2
+     */
+    public static boolean directoryContains(final File directory, final File child) throws IOException {
+        requireDirectoryExists(directory, "directory");
+
+        if (child == null || !directory.exists() || !child.exists()) {
+            return false;
+        }
+
+        // Canonicalize paths (normalizes relative paths)
+        return FilenameUtils.directoryContains(directory.getCanonicalPath(), child.getCanonicalPath());
+    }
+
+    /**
+     * Internal copy directory method.
+     *
+     * @param srcDir the validated source directory, must not be {@code null}.
+     * @param destDir the validated destination directory, must not be {@code null}.
+     * @param fileFilter the filter to apply, null means copy all directories and files.
+     * @param exclusionList List of files and directories to exclude from the copy, may be null.
+     * @param preserveDirDate preserve the directories last modified dates.
+     * @param copyOptions options specifying how the copy should be done, see {@link StandardCopyOption}.
+     * @throws IOException if the directory was not created along with all its parent directories.
+     * @throws IOException if the given file object is not a directory.
+     */
+    private static void doCopyDirectory(final File srcDir, final File destDir, final FileFilter fileFilter, final List<String> exclusionList,
+        final boolean preserveDirDate, final CopyOption... copyOptions) throws IOException {
+        // recurse dirs, copy files.
+        final File[] srcFiles = listFiles(srcDir, fileFilter);
+        requireDirectoryIfExists(destDir, "destDir");
+        mkdirs(destDir);
+        requireCanWrite(destDir, "destDir");
+        for (final File srcFile : srcFiles) {
+            final File dstFile = new File(destDir, srcFile.getName());
+            if (exclusionList == null || !exclusionList.contains(srcFile.getCanonicalPath())) {
+                if (srcFile.isDirectory()) {
+                    doCopyDirectory(srcFile, dstFile, fileFilter, exclusionList, preserveDirDate, copyOptions);
+                } else {
+                    copyFile(srcFile, dstFile, copyOptions);
+                }
+            }
+        }
+        // Do this last, as the above has probably affected directory metadata
+        if (preserveDirDate) {
+            setLastModified(srcDir, destDir);
+        }
+    }
+
+    /**
+     * Deletes a file or directory. For a directory, delete it and all subdirectories.
+     * <p>
+     * The difference between File.delete() and this method are:
+     * </p>
+     * <ul>
+     * <li>The directory does not have to be empty.</li>
+     * <li>You get an exception when a file or directory cannot be deleted.</li>
+     * </ul>
+     *
+     * @param file file or directory to delete, must not be {@code null}.
+     * @throws NullPointerException  if the file is {@code null}.
+     * @throws FileNotFoundException if the file was not found.
+     * @throws IOException           in case deletion is unsuccessful.
+     */
+    public static void forceDelete(final File file) throws IOException {
+        Objects.requireNonNull(file, "file");
+        final Counters.PathCounters deleteCounters;
+        try {
+            deleteCounters = PathUtils.delete(file.toPath(), PathUtils.EMPTY_LINK_OPTION_ARRAY,
+                StandardDeleteOption.OVERRIDE_READ_ONLY);
+        } catch (final IOException e) {
+            throw new IOException("Cannot delete file: " + file, e);
+        }
+
+        if (deleteCounters.getFileCounter().get() < 1 && deleteCounters.getDirectoryCounter().get() < 1) {
+            // didn't find a file to delete.
+            throw new FileNotFoundException("File does not exist: " + file);
+        }
+    }
+
+    /**
+     * Schedules a file to be deleted when JVM exits.
+     * If file is directory delete it and all subdirectories.
+     *
+     * @param file file or directory to delete, must not be {@code null}.
+     * @throws NullPointerException if the file is {@code null}.
+     * @throws IOException          in case deletion is unsuccessful.
+     */
+    public static void forceDeleteOnExit(final File file) throws IOException {
+        Objects.requireNonNull(file, "file");
+        if (file.isDirectory()) {
+            deleteDirectoryOnExit(file);
+        } else {
+            file.deleteOnExit();
+        }
+    }
+
+    /**
+     * Makes a directory, including any necessary but nonexistent parent
+     * directories. If a file already exists with specified name but it is
+     * not a directory then an IOException is thrown.
+     * If the directory cannot be created (or the file already exists but is not a directory)
+     * then an IOException is thrown.
+     *
+     * @param directory directory to create, may be {@code null}.
+     * @throws IOException if the directory was not created along with all its parent directories.
+     * @throws IOException if the given file object is not a directory.
+     * @throws SecurityException See {@link File#mkdirs()}.
+     */
+    public static void forceMkdir(final File directory) throws IOException {
+        mkdirs(directory);
+    }
+
+    /**
+     * Makes any necessary but nonexistent parent directories for a given File. If the parent directory cannot be
+     * created then an IOException is thrown.
+     *
+     * @param file file with parent to create, must not be {@code null}.
+     * @throws NullPointerException if the file is {@code null}.
+     * @throws IOException          if the parent directory cannot be created.
+     * @since 2.5
+     */
+    public static void forceMkdirParent(final File file) throws IOException {
+        Objects.requireNonNull(file, "file");
+        forceMkdir(getParentFile(file));
+    }
+
+    /**
+     * Constructs a file from the set of name elements.
+     *
+     * @param directory the parent directory.
+     * @param names the name elements.
+     * @return the new file.
+     * @since 2.1
+     */
+    public static File getFile(final File directory, final String... names) {
+        Objects.requireNonNull(directory, "directory");
+        Objects.requireNonNull(names, "names");
+        File file = directory;
+        for (final String name : names) {
+            file = new File(file, name);
+        }
+        return file;
+    }
+
+    /**
+     * Constructs a file from the set of name elements.
+     *
+     * @param names the name elements.
+     * @return the file.
+     * @since 2.1
+     */
+    public static File getFile(final String... names) {
+        Objects.requireNonNull(names, "names");
+        File file = null;
+        for (final String name : names) {
+            if (file == null) {
+                file = new File(name);
+            } else {
+                file = new File(file, name);
+            }
+        }
+        return file;
+    }
+
+    /**
+     * Gets the parent of the given file. The given file may be bull and a file's parent may as well be null.
+     *
+     * @param file The file to query.
+     * @return The parent file or {@code null}.
+     */
+    private static File getParentFile(final File file) {
+        return file == null ? null : file.getParentFile();
+    }
+
+    /**
+     * Returns a {@link File} representing the system temporary directory.
+     *
+     * @return the system temporary directory.
+     * @since 2.0
+     */
+    public static File getTempDirectory() {
+        return new File(getTempDirectoryPath());
+    }
+
+    /**
+     * Returns the path to the system temporary directory.
+     *
+     * @return the path to the system temporary directory.
+     * @since 2.0
+     */
+    public static String getTempDirectoryPath() {
+        return System.getProperty("java.io.tmpdir");
+    }
+
+    /**
+     * Returns a {@link File} representing the user's home directory.
+     *
+     * @return the user's home directory.
+     * @since 2.0
+     */
+    public static File getUserDirectory() {
+        return new File(getUserDirectoryPath());
+    }
+
+    /**
+     * Returns the path to the user's home directory.
+     *
+     * @return the path to the user's home directory.
+     * @since 2.0
+     */
+    public static String getUserDirectoryPath() {
+        return System.getProperty("user.home");
+    }
+
+    /**
+     * Tests whether the specified {@link File} is a directory or not. Implemented as a
+     * null-safe delegate to {@link Files#isDirectory(Path path, LinkOption... options)}.
+     *
+     * @param   file the path to the file.
+     * @param   options options indicating how symbolic links are handled
+     * @return  {@code true} if the file is a directory; {@code false} if
+     *          the path is null, the file does not exist, is not a directory, or it cannot
+     *          be determined if the file is a directory or not.
+     * @throws SecurityException     In the case of the default provider, and a security manager is installed, the
+     *                               {@link SecurityManager#checkRead(String) checkRead} method is invoked to check read
+     *                               access to the directory.
+     * @since 2.9.0
+     */
+    public static boolean isDirectory(final File file, final LinkOption... options) {
+        return file != null && Files.isDirectory(file.toPath(), options);
+    }
+
+    /**
+     * Tests whether the directory is empty.
+     *
+     * @param directory the directory to query.
+     * @return whether the directory is empty.
+     * @throws IOException if an I/O error occurs.
+     * @throws NotDirectoryException if the file could not otherwise be opened because it is not a directory
+     *                               <i>(optional specific exception)</i>.
+     * @since 2.9.0
+     */
+    public static boolean isEmptyDirectory(final File directory) throws IOException {
+        return PathUtils.isEmptyDirectory(directory.toPath());
+    }
+
+    /**
+     * Tests if the specified {@link File} is newer than the specified {@link ChronoLocalDate}
+     * at the end of day.
+     *
+     * <p>Note: The input date is assumed to be in the system default time-zone with the time
+     * part set to the current time. To use a non-default time-zone use the method
+     * {@link #isFileNewer(File, ChronoLocalDateTime, ZoneId)
+     * isFileNewer(file, chronoLocalDate.atTime(LocalTime.now(zoneId)), zoneId)} where
+     * {@code zoneId} is a valid {@link ZoneId}.
+     *
+     * @param file            the {@link File} of which the modification date must be compared.
+     * @param chronoLocalDate the date reference.
+     * @return true if the {@link File} exists and has been modified after the given
+     * {@link ChronoLocalDate} at the current time.
+     * @throws NullPointerException if the file or local date is {@code null}.
+     * @since 2.8.0
+     */
+    public static boolean isFileNewer(final File file, final ChronoLocalDate chronoLocalDate) {
+        return isFileNewer(file, chronoLocalDate, LocalTime.MAX);
+    }
+
+    /**
+     * Tests if the specified {@link File} is newer than the specified {@link ChronoLocalDate}
+     * at the specified time.
+     *
+     * <p>Note: The input date and time are assumed to be in the system default time-zone. To use a
+     * non-default time-zone use the method {@link #isFileNewer(File, ChronoLocalDateTime, ZoneId)
+     * isFileNewer(file, chronoLocalDate.atTime(localTime), zoneId)} where {@code zoneId} is a valid
+     * {@link ZoneId}.
+     *
+     * @param file            the {@link File} of which the modification date must be compared.
+     * @param chronoLocalDate the date reference.
+     * @param localTime       the time reference.
+     * @return true if the {@link File} exists and has been modified after the given
+     * {@link ChronoLocalDate} at the given time.
+     * @throws NullPointerException if the file, local date or zone ID is {@code null}.
+     * @since 2.8.0
+     */
+    public static boolean isFileNewer(final File file, final ChronoLocalDate chronoLocalDate, final LocalTime localTime) {
+        Objects.requireNonNull(chronoLocalDate, "chronoLocalDate");
+        Objects.requireNonNull(localTime, "localTime");
+        return isFileNewer(file, chronoLocalDate.atTime(localTime));
+    }
+
+    /**
+     * Tests if the specified {@link File} is newer than the specified {@link ChronoLocalDate} at the specified
+     * {@link OffsetTime}.
+     *
+     * @param file the {@link File} of which the modification date must be compared
+     * @param chronoLocalDate the date reference
+     * @param offsetTime the time reference
+     * @return true if the {@link File} exists and has been modified after the given {@link ChronoLocalDate} at the given
+     *         {@link OffsetTime}.
+     * @throws NullPointerException if the file, local date or zone ID is {@code null}
+     * @since 2.12.0
+     */
+    public static boolean isFileNewer(final File file, final ChronoLocalDate chronoLocalDate, final OffsetTime offsetTime) {
+        Objects.requireNonNull(chronoLocalDate, "chronoLocalDate");
+        Objects.requireNonNull(offsetTime, "offsetTime");
+        return isFileNewer(file, chronoLocalDate.atTime(offsetTime.toLocalTime()));
+    }
+
+    /**
+     * Tests if the specified {@link File} is newer than the specified {@link ChronoLocalDateTime}
+     * at the system-default time zone.
+     *
+     * <p>Note: The input date and time is assumed to be in the system default time-zone. To use a
+     * non-default time-zone use the method {@link #isFileNewer(File, ChronoLocalDateTime, ZoneId)
+     * isFileNewer(file, chronoLocalDateTime, zoneId)} where {@code zoneId} is a valid
+     * {@link ZoneId}.
+     *
+     * @param file                the {@link File} of which the modification date must be compared.
+     * @param chronoLocalDateTime the date reference.
+     * @return true if the {@link File} exists and has been modified after the given
+     * {@link ChronoLocalDateTime} at the system-default time zone.
+     * @throws NullPointerException if the file or local date time is {@code null}.
+     * @since 2.8.0
+     */
+    public static boolean isFileNewer(final File file, final ChronoLocalDateTime<?> chronoLocalDateTime) {
+        return isFileNewer(file, chronoLocalDateTime, ZoneId.systemDefault());
+    }
+
+    /**
+     * Tests if the specified {@link File} is newer than the specified {@link ChronoLocalDateTime}
+     * at the specified {@link ZoneId}.
+     *
+     * @param file                the {@link File} of which the modification date must be compared.
+     * @param chronoLocalDateTime the date reference.
+     * @param zoneId              the time zone.
+     * @return true if the {@link File} exists and has been modified after the given
+     * {@link ChronoLocalDateTime} at the given {@link ZoneId}.
+     * @throws NullPointerException if the file, local date time or zone ID is {@code null}.
+     * @since 2.8.0
+     */
+    public static boolean isFileNewer(final File file, final ChronoLocalDateTime<?> chronoLocalDateTime, final ZoneId zoneId) {
+        Objects.requireNonNull(chronoLocalDateTime, "chronoLocalDateTime");
+        Objects.requireNonNull(zoneId, "zoneId");
+        return isFileNewer(file, chronoLocalDateTime.atZone(zoneId));
+    }
+
+    /**
+     * Tests if the specified {@link File} is newer than the specified {@link ChronoZonedDateTime}.
+     *
+     * @param file                the {@link File} of which the modification date must be compared.
+     * @param chronoZonedDateTime the date reference.
+     * @return true if the {@link File} exists and has been modified after the given
+     * {@link ChronoZonedDateTime}.
+     * @throws NullPointerException if the file or zoned date time is {@code null}.
+     * @since 2.8.0
+     */
+    public static boolean isFileNewer(final File file, final ChronoZonedDateTime<?> chronoZonedDateTime) {
+        Objects.requireNonNull(file, "file");
+        Objects.requireNonNull(chronoZonedDateTime, "chronoZonedDateTime");
+        return Uncheck.get(() -> PathUtils.isNewer(file.toPath(), chronoZonedDateTime));
+    }
+
+    /**
+     * Tests if the specified {@link File} is newer than the specified {@link Date}.
+     *
+     * @param file the {@link File} of which the modification date must be compared.
+     * @param date the date reference.
+     * @return true if the {@link File} exists and has been modified
+     * after the given {@link Date}.
+     * @throws NullPointerException if the file or date is {@code null}.
+     */
+    public static boolean isFileNewer(final File file, final Date date) {
+        Objects.requireNonNull(date, "date");
+        return isFileNewer(file, date.getTime());
+    }
+
+    /**
+     * Tests if the specified {@link File} is newer than the reference {@link File}.
+     *
+     * @param file      the {@link File} of which the modification date must be compared.
+     * @param reference the {@link File} of which the modification date is used.
+     * @return true if the {@link File} exists and has been modified more
+     * recently than the reference {@link File}.
+     * @throws NullPointerException if the file or reference file is {@code null}.
+     * @throws IllegalArgumentException if the reference file doesn't exist.
+     */
+    public static boolean isFileNewer(final File file, final File reference) {
+        requireExists(reference, "reference");
+        return Uncheck.get(() -> PathUtils.isNewer(file.toPath(), reference.toPath()));
+    }
+
+    /**
+     * Tests if the specified {@link File} is newer than the specified {@link FileTime}.
+     *
+     * @param file the {@link File} of which the modification date must be compared.
+     * @param fileTime the file time reference.
+     * @return true if the {@link File} exists and has been modified after the given {@link FileTime}.
+     * @throws IOException if an I/O error occurs.
+     * @throws NullPointerException if the file or local date is {@code null}.
+     * @since 2.12.0
+     */
+    public static boolean isFileNewer(final File file, final FileTime fileTime) throws IOException {
+        Objects.requireNonNull(file, "file");
+        return PathUtils.isNewer(file.toPath(), fileTime);
+    }
+
+    /**
+     * Tests if the specified {@link File} is newer than the specified {@link Instant}.
+     *
+     * @param file the {@link File} of which the modification date must be compared.
+     * @param instant the date reference.
+     * @return true if the {@link File} exists and has been modified after the given {@link Instant}.
+     * @throws NullPointerException if the file or instant is {@code null}.
+     * @since 2.8.0
+     */
+    public static boolean isFileNewer(final File file, final Instant instant) {
+        Objects.requireNonNull(instant, "instant");
+        return Uncheck.get(() -> PathUtils.isNewer(file.toPath(), instant));
+    }
+
+    /**
+     * Tests if the specified {@link File} is newer than the specified time reference.
+     *
+     * @param file       the {@link File} of which the modification date must be compared.
+     * @param timeMillis the time reference measured in milliseconds since the
+     *                   epoch (00:00:00 GMT, January 1, 1970).
+     * @return true if the {@link File} exists and has been modified after the given time reference.
+     * @throws NullPointerException if the file is {@code null}.
+     */
+    public static boolean isFileNewer(final File file, final long timeMillis) {
+        Objects.requireNonNull(file, "file");
+        return Uncheck.get(() -> PathUtils.isNewer(file.toPath(), timeMillis));
+    }
+
+    /**
+     * Tests if the specified {@link File} is newer than the specified {@link OffsetDateTime}.
+     *
+     * @param file the {@link File} of which the modification date must be compared
+     * @param offsetDateTime the date reference
+     * @return true if the {@link File} exists and has been modified before the given {@link OffsetDateTime}.
+     * @throws NullPointerException if the file or zoned date time is {@code null}
+     * @since 2.12.0
+     */
+    public static boolean isFileNewer(final File file, final OffsetDateTime offsetDateTime) {
+        Objects.requireNonNull(offsetDateTime, "offsetDateTime");
+        return isFileNewer(file, offsetDateTime.toInstant());
+    }
+
+    /**
+     * Tests if the specified {@link File} is older than the specified {@link ChronoLocalDate}
+     * at the end of day.
+     *
+     * <p>Note: The input date is assumed to be in the system default time-zone with the time
+     * part set to the current time. To use a non-default time-zone use the method
+     * {@link #isFileOlder(File, ChronoLocalDateTime, ZoneId)
+     * isFileOlder(file, chronoLocalDate.atTime(LocalTime.now(zoneId)), zoneId)} where
+     * {@code zoneId} is a valid {@link ZoneId}.
+     *
+     * @param file            the {@link File} of which the modification date must be compared.
+     * @param chronoLocalDate the date reference.
+     * @return true if the {@link File} exists and has been modified before the given
+     * {@link ChronoLocalDate} at the current time.
+     * @throws NullPointerException if the file or local date is {@code null}.
+     * @see ZoneId#systemDefault()
+     * @see LocalTime#now()
+     * @since 2.8.0
+     */
+    public static boolean isFileOlder(final File file, final ChronoLocalDate chronoLocalDate) {
+        return isFileOlder(file, chronoLocalDate, LocalTime.MAX);
+    }
+
+    /**
+     * Tests if the specified {@link File} is older than the specified {@link ChronoLocalDate}
+     * at the specified {@link LocalTime}.
+     *
+     * <p>Note: The input date and time are assumed to be in the system default time-zone. To use a
+     * non-default time-zone use the method {@link #isFileOlder(File, ChronoLocalDateTime, ZoneId)
+     * isFileOlder(file, chronoLocalDate.atTime(localTime), zoneId)} where {@code zoneId} is a valid
+     * {@link ZoneId}.
+     *
+     * @param file            the {@link File} of which the modification date must be compared.
+     * @param chronoLocalDate the date reference.
+     * @param localTime       the time reference.
+     * @return true if the {@link File} exists and has been modified before the
+     * given {@link ChronoLocalDate} at the specified time.
+     * @throws NullPointerException if the file, local date or local time is {@code null}.
+     * @see ZoneId#systemDefault()
+     * @since 2.8.0
+     */
+    public static boolean isFileOlder(final File file, final ChronoLocalDate chronoLocalDate, final LocalTime localTime) {
+        Objects.requireNonNull(chronoLocalDate, "chronoLocalDate");
+        Objects.requireNonNull(localTime, "localTime");
+        return isFileOlder(file, chronoLocalDate.atTime(localTime));
+    }
+
+    /**
+     * Tests if the specified {@link File} is older than the specified {@link ChronoLocalDate} at the specified
+     * {@link OffsetTime}.
+     *
+     * @param file the {@link File} of which the modification date must be compared
+     * @param chronoLocalDate the date reference
+     * @param offsetTime the time reference
+     * @return true if the {@link File} exists and has been modified after the given {@link ChronoLocalDate} at the given
+     *         {@link OffsetTime}.
+     * @throws NullPointerException if the file, local date or zone ID is {@code null}
+     * @since 2.12.0
+     */
+    public static boolean isFileOlder(final File file, final ChronoLocalDate chronoLocalDate, final OffsetTime offsetTime) {
+        Objects.requireNonNull(chronoLocalDate, "chronoLocalDate");
+        Objects.requireNonNull(offsetTime, "offsetTime");
+        return isFileOlder(file, chronoLocalDate.atTime(offsetTime.toLocalTime()));
+    }
+
+    /**
+     * Tests if the specified {@link File} is older than the specified {@link ChronoLocalDateTime}
+     * at the system-default time zone.
+     *
+     * <p>Note: The input date and time is assumed to be in the system default time-zone. To use a
+     * non-default time-zone use the method {@link #isFileOlder(File, ChronoLocalDateTime, ZoneId)
+     * isFileOlder(file, chronoLocalDateTime, zoneId)} where {@code zoneId} is a valid
+     * {@link ZoneId}.
+     *
+     * @param file                the {@link File} of which the modification date must be compared.
+     * @param chronoLocalDateTime the date reference.
+     * @return true if the {@link File} exists and has been modified before the given
+     * {@link ChronoLocalDateTime} at the system-default time zone.
+     * @throws NullPointerException if the file or local date time is {@code null}.
+     * @see ZoneId#systemDefault()
+     * @since 2.8.0
+     */
+    public static boolean isFileOlder(final File file, final ChronoLocalDateTime<?> chronoLocalDateTime) {
+        return isFileOlder(file, chronoLocalDateTime, ZoneId.systemDefault());
+    }
+
+    /**
+     * Tests if the specified {@link File} is older than the specified {@link ChronoLocalDateTime}
+     * at the specified {@link ZoneId}.
+     *
+     * @param file          the {@link File} of which the modification date must be compared.
+     * @param chronoLocalDateTime the date reference.
+     * @param zoneId        the time zone.
+     * @return true if the {@link File} exists and has been modified before the given
+     * {@link ChronoLocalDateTime} at the given {@link ZoneId}.
+     * @throws NullPointerException if the file, local date time or zone ID is {@code null}.
+     * @since 2.8.0
+     */
+    public static boolean isFileOlder(final File file, final ChronoLocalDateTime<?> chronoLocalDateTime, final ZoneId zoneId) {
+        Objects.requireNonNull(chronoLocalDateTime, "chronoLocalDateTime");
+        Objects.requireNonNull(zoneId, "zoneId");
+        return isFileOlder(file, chronoLocalDateTime.atZone(zoneId));
+    }
+
+    /**
+     * Tests if the specified {@link File} is older than the specified {@link ChronoZonedDateTime}.
+     *
+     * @param file                the {@link File} of which the modification date must be compared.
+     * @param chronoZonedDateTime the date reference.
+     * @return true if the {@link File} exists and has been modified before the given
+     * {@link ChronoZonedDateTime}.
+     * @throws NullPointerException if the file or zoned date time is {@code null}.
+     * @since 2.8.0
+     */
+    public static boolean isFileOlder(final File file, final ChronoZonedDateTime<?> chronoZonedDateTime) {
+        Objects.requireNonNull(chronoZonedDateTime, "chronoZonedDateTime");
+        return isFileOlder(file, chronoZonedDateTime.toInstant());
+    }
+
+    /**
+     * Tests if the specified {@link File} is older than the specified {@link Date}.
+     *
+     * @param file the {@link File} of which the modification date must be compared.
+     * @param date the date reference.
+     * @return true if the {@link File} exists and has been modified before the given {@link Date}.
+     * @throws NullPointerException if the file or date is {@code null}.
+     */
+    public static boolean isFileOlder(final File file, final Date date) {
+        Objects.requireNonNull(date, "date");
+        return isFileOlder(file, date.getTime());
+    }
+
+    /**
+     * Tests if the specified {@link File} is older than the reference {@link File}.
+     *
+     * @param file      the {@link File} of which the modification date must be compared.
+     * @param reference the {@link File} of which the modification date is used.
+     * @return true if the {@link File} exists and has been modified before the reference {@link File}.
+     * @throws NullPointerException if the file or reference file is {@code null}.
+     * @throws IllegalArgumentException if the reference file doesn't exist.
+     */
+    public static boolean isFileOlder(final File file, final File reference) {
+        requireExists(reference, "reference");
+        return Uncheck.get(() -> PathUtils.isOlder(file.toPath(), reference.toPath()));
+    }
+
+    /**
+     * Tests if the specified {@link File} is older than the specified {@link FileTime}.
+     *
+     * @param file the {@link File} of which the modification date must be compared.
+     * @param fileTime the file time reference.
+     * @return true if the {@link File} exists and has been modified before the given {@link FileTime}.
+     * @throws IOException if an I/O error occurs.
+     * @throws NullPointerException if the file or local date is {@code null}.
+     * @since 2.12.0
+     */
+    public static boolean isFileOlder(final File file, final FileTime fileTime) throws IOException {
+        Objects.requireNonNull(file, "file");
+        return PathUtils.isOlder(file.toPath(), fileTime);
+    }
+
+    /**
+     * Tests if the specified {@link File} is older than the specified {@link Instant}.
+     *
+     * @param file    the {@link File} of which the modification date must be compared.
+     * @param instant the date reference.
+     * @return true if the {@link File} exists and has been modified before the given {@link Instant}.
+     * @throws NullPointerException if the file or instant is {@code null}.
+     * @since 2.8.0
+     */
+    public static boolean isFileOlder(final File file, final Instant instant) {
+        Objects.requireNonNull(instant, "instant");
+        return Uncheck.get(() -> PathUtils.isOlder(file.toPath(), instant));
+    }
+
+    /**
+     * Tests if the specified {@link File} is older than the specified time reference.
+     *
+     * @param file       the {@link File} of which the modification date must be compared.
+     * @param timeMillis the time reference measured in milliseconds since the
+     *                   epoch (00:00:00 GMT, January 1, 1970).
+     * @return true if the {@link File} exists and has been modified before the given time reference.
+     * @throws NullPointerException if the file is {@code null}.
+     */
+    public static boolean isFileOlder(final File file, final long timeMillis) {
+        Objects.requireNonNull(file, "file");
+        return Uncheck.get(() -> PathUtils.isOlder(file.toPath(), timeMillis));
+    }
+
+    /**
+     * Tests if the specified {@link File} is older than the specified {@link OffsetDateTime}.
+     *
+     * @param file the {@link File} of which the modification date must be compared
+     * @param offsetDateTime the date reference
+     * @return true if the {@link File} exists and has been modified before the given {@link OffsetDateTime}.
+     * @throws NullPointerException if the file or zoned date time is {@code null}
+     * @since 2.12.0
+     */
+    public static boolean isFileOlder(final File file, final OffsetDateTime offsetDateTime) {
+        Objects.requireNonNull(offsetDateTime, "offsetDateTime");
+        return isFileOlder(file, offsetDateTime.toInstant());
+    }
+
+    /**
+     * Tests whether the specified {@link File} is a regular file or not. Implemented as a
+     * null-safe delegate to {@link Files#isRegularFile(Path path, LinkOption... options)}.
+     *
+     * @param   file the path to the file.
+     * @param   options options indicating how symbolic links are handled
+     * @return  {@code true} if the file is a regular file; {@code false} if
+     *          the path is null, the file does not exist, is not a regular file, or it cannot
+     *          be determined if the file is a regular file or not.
+     * @throws SecurityException     In the case of the default provider, and a security manager is installed, the
+     *                               {@link SecurityManager#checkRead(String) checkRead} method is invoked to check read
+     *                               access to the directory.
+     * @since 2.9.0
+     */
+    public static boolean isRegularFile(final File file, final LinkOption... options) {
+        return file != null && Files.isRegularFile(file.toPath(), options);
+    }
+
+    /**
+     * Tests whether the specified file is a symbolic link rather than an actual file.
+     * <p>
+     * This method delegates to {@link Files#isSymbolicLink(Path path)}
+     * </p>
+     *
+     * @param file the file to test.
+     * @return true if the file is a symbolic link, see {@link Files#isSymbolicLink(Path path)}.
+     * @since 2.0
+     * @see Files#isSymbolicLink(Path)
+     */
+    public static boolean isSymlink(final File file) {
+        return file != null && Files.isSymbolicLink(file.toPath());
+    }
+
+    /**
+     * Iterates over the files in given directory (and optionally
+     * its subdirectories).
+     * <p>
+     * The resulting iterator MUST be consumed in its entirety in order to close its underlying stream.
+     * </p>
+     * <p>
+     * All files found are filtered by an IOFileFilter.
+     * </p>
+     *
+     * @param directory  the directory to search in
+     * @param fileFilter filter to apply when finding files.
+     * @param dirFilter  optional filter to apply when finding subdirectories.
+     *                   If this parameter is {@code null}, subdirectories will not be included in the
+     *                   search. Use TrueFileFilter.INSTANCE to match all directories.
+     * @return an iterator of java.io.File for the matching files
+     * @see org.apache.commons.io.filefilter.FileFilterUtils
+     * @see org.apache.commons.io.filefilter.NameFileFilter
+     * @since 1.2
+     */
+    public static Iterator<File> iterateFiles(final File directory, final IOFileFilter fileFilter, final IOFileFilter dirFilter) {
+        return listFiles(directory, fileFilter, dirFilter).iterator();
+    }
+
+    /**
+     * Iterates over the files in a given directory (and optionally
+     * its subdirectories) which match an array of extensions.
+     * <p>
+     * The resulting iterator MUST be consumed in its entirety in order to close its underlying stream.
+     * </p>
+     *
+     * @param directory  the directory to search in
+     * @param extensions an array of extensions, ex. {"java","xml"}. If this
+     *                   parameter is {@code null}, all files are returned.
+     * @param recursive  if true all subdirectories are searched as well
+     * @return an iterator of java.io.File with the matching files
+     * @since 1.2
+     */
+    public static Iterator<File> iterateFiles(final File directory, final String[] extensions, final boolean recursive) {
+        return Uncheck.apply(d -> streamFiles(d, recursive, extensions).iterator(), directory);
+    }
+
+    /**
+     * Iterates over the files in given directory (and optionally
+     * its subdirectories).
+     * <p>
+     * The resulting iterator MUST be consumed in its entirety in order to close its underlying stream.
+     * </p>
+     * <p>
+     * All files found are filtered by an IOFileFilter.
+     * </p>
+     * <p>
+     * The resulting iterator includes the subdirectories themselves.
+     * </p>
+     *
+     * @param directory  the directory to search in
+     * @param fileFilter filter to apply when finding files.
+     * @param dirFilter  optional filter to apply when finding subdirectories.
+     *                   If this parameter is {@code null}, subdirectories will not be included in the
+     *                   search. Use TrueFileFilter.INSTANCE to match all directories.
+     * @return an iterator of java.io.File for the matching files
+     * @see org.apache.commons.io.filefilter.FileFilterUtils
+     * @see org.apache.commons.io.filefilter.NameFileFilter
+     * @since 2.2
+     */
+    public static Iterator<File> iterateFilesAndDirs(final File directory, final IOFileFilter fileFilter, final IOFileFilter dirFilter) {
+        return listFilesAndDirs(directory, fileFilter, dirFilter).iterator();
+    }
+
+    /**
+     * Returns the last modification time in milliseconds via
+     * {@link java.nio.file.Files#getLastModifiedTime(Path, LinkOption...)}.
+     * <p>
+     * For the best precision, use {@link #lastModifiedFileTime(File)}.
+     * </p>
+     * <p>
+     * Use this method to avoid issues with {@link File#lastModified()} like
+     * <a href="https://bugs.openjdk.java.net/browse/JDK-8177809">JDK-8177809</a> where {@link File#lastModified()} is
+     * losing milliseconds (always ends in 000). This bug exists in OpenJDK 8 and 9, and is fixed in 10.
+     * </p>
+     *
+     * @param file The File to query.
+     * @return See {@link java.nio.file.attribute.FileTime#toMillis()}.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.9.0
+     */
+    public static long lastModified(final File file) throws IOException {
+        // https://bugs.openjdk.java.net/browse/JDK-8177809
+        // File.lastModified() is losing milliseconds (always ends in 000)
+        // This bug is in OpenJDK 8 and 9, and fixed in 10.
+        return lastModifiedFileTime(file).toMillis();
+    }
+
+    /**
+     * Returns the last modification {@link FileTime} via
+     * {@link java.nio.file.Files#getLastModifiedTime(Path, LinkOption...)}.
+     * <p>
+     * Use this method to avoid issues with {@link File#lastModified()} like
+     * <a href="https://bugs.openjdk.java.net/browse/JDK-8177809">JDK-8177809</a> where {@link File#lastModified()} is
+     * losing milliseconds (always ends in 000). This bug exists in OpenJDK 8 and 9, and is fixed in 10.
+     * </p>
+     *
+     * @param file The File to query.
+     * @return See {@link java.nio.file.Files#getLastModifiedTime(Path, LinkOption...)}.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.12.0
+     */
+    public static FileTime lastModifiedFileTime(final File file) throws IOException {
+        // https://bugs.openjdk.java.net/browse/JDK-8177809
+        // File.lastModified() is losing milliseconds (always ends in 000)
+        // This bug is in OpenJDK 8 and 9, and fixed in 10.
+        return Files.getLastModifiedTime(Objects.requireNonNull(file.toPath(), "file"));
+    }
+
+    /**
+     * Returns the last modification time in milliseconds via
+     * {@link java.nio.file.Files#getLastModifiedTime(Path, LinkOption...)}.
+     * <p>
+     * For the best precision, use {@link #lastModifiedFileTime(File)}.
+     * </p>
+     * <p>
+     * Use this method to avoid issues with {@link File#lastModified()} like
+     * <a href="https://bugs.openjdk.java.net/browse/JDK-8177809">JDK-8177809</a> where {@link File#lastModified()} is
+     * losing milliseconds (always ends in 000). This bug exists in OpenJDK 8 and 9, and is fixed in 10.
+     * </p>
+     *
+     * @param file The File to query.
+     * @return See {@link java.nio.file.attribute.FileTime#toMillis()}.
+     * @throws UncheckedIOException if an I/O error occurs.
+     * @since 2.9.0
+     */
+    public static long lastModifiedUnchecked(final File file) {
+        // https://bugs.openjdk.java.net/browse/JDK-8177809
+        // File.lastModified() is losing milliseconds (always ends in 000)
+        // This bug is in OpenJDK 8 and 9, and fixed in 10.
+        return Uncheck.apply(FileUtils::lastModified, file);
+    }
+
+    /**
+     * Returns an Iterator for the lines in a {@link File} using the default encoding for the VM.
+     *
+     * @param file the file to open for input, must not be {@code null}
+     * @return an Iterator of the lines in the file, never {@code null}
+     * @throws NullPointerException if file is {@code null}.
+     * @throws FileNotFoundException if the file does not exist, is a directory rather than a regular file, or for some
+     *         other reason cannot be opened for reading.
+     * @throws IOException if an I/O error occurs.
+     * @see #lineIterator(File, String)
+     * @since 1.3
+     */
+    public static LineIterator lineIterator(final File file) throws IOException {
+        return lineIterator(file, null);
+    }
+
+    /**
+     * Returns an Iterator for the lines in a {@link File}.
+     * <p>
+     * This method opens an {@link InputStream} for the file.
+     * When you have finished with the iterator you should close the stream
+     * to free internal resources. This can be done by using a try-with-resources block or calling the
+     * {@link LineIterator#close()} method.
+     * </p>
+     * <p>
+     * The recommended usage pattern is:
+     * </p>
+     * <pre>
+     * LineIterator it = FileUtils.lineIterator(file, StandardCharsets.UTF_8.name());
+     * try {
+     *   while (it.hasNext()) {
+     *     String line = it.nextLine();
+     *     /// do something with line
+     *   }
+     * } finally {
+     *   LineIterator.closeQuietly(iterator);
+     * }
+     * </pre>
+     * <p>
+     * If an exception occurs during the creation of the iterator, the
+     * underlying stream is closed.
+     * </p>
+     *
+     * @param file     the file to open for input, must not be {@code null}
+     * @param charsetName the name of the requested charset, {@code null} means platform default
+     * @return a LineIterator for lines in the file, never {@code null}; MUST be closed by the caller.
+     * @throws NullPointerException if file is {@code null}.
+     * @throws FileNotFoundException if the file does not exist, is a directory rather than a regular file, or for some
+     *         other reason cannot be opened for reading.
+     * @throws IOException if an I/O error occurs.
+     * @since 1.2
+     */
+    @SuppressWarnings("resource") // Caller closes the result LineIterator.
+    public static LineIterator lineIterator(final File file, final String charsetName) throws IOException {
+        InputStream inputStream = null;
+        try {
+            inputStream = Files.newInputStream(file.toPath());
+            return IOUtils.lineIterator(inputStream, charsetName);
+        } catch (final IOException | RuntimeException ex) {
+            IOUtils.closeQuietly(inputStream, ex::addSuppressed);
+            throw ex;
+        }
+    }
+
+    private static AccumulatorPathVisitor listAccumulate(final File directory, final IOFileFilter fileFilter, final IOFileFilter dirFilter,
+        final FileVisitOption... options) throws IOException {
+        final boolean isDirFilterSet = dirFilter != null;
+        final FileEqualsFileFilter rootDirFilter = new FileEqualsFileFilter(directory);
+        final PathFilter dirPathFilter = isDirFilterSet ? rootDirFilter.or(dirFilter) : rootDirFilter;
+        final AccumulatorPathVisitor visitor = new AccumulatorPathVisitor(Counters.noopPathCounters(), fileFilter, dirPathFilter,
+            (p, e) -> FileVisitResult.CONTINUE);
+        final Set<FileVisitOption> optionSet = new HashSet<>();
+        Collections.addAll(optionSet, options);
+        Files.walkFileTree(directory.toPath(), optionSet, toMaxDepth(isDirFilterSet), visitor);
+        return visitor;
+    }
+
+    /**
+     * Lists files in a directory, asserting that the supplied directory exists and is a directory.
+     *
+     * @param directory The directory to list
+     * @param fileFilter Optional file filter, may be null.
+     * @return The files in the directory, never {@code null}.
+     * @throws NullPointerException if directory is {@code null}.
+     * @throws IllegalArgumentException if directory does not exist or is not a directory.
+     * @throws IOException if an I/O error occurs.
+     */
+    private static File[] listFiles(final File directory, final FileFilter fileFilter) throws IOException {
+        requireDirectoryExists(directory, "directory");
+        final File[] files = fileFilter == null ? directory.listFiles() : directory.listFiles(fileFilter);
+        if (files == null) {
+            // null if the directory does not denote a directory, or if an I/O error occurs.
+            throw new IOException("Unknown I/O error listing contents of directory: " + directory);
+        }
+        return files;
+    }
+
+    /**
+     * Finds files within a given directory (and optionally its
+     * subdirectories). All files found are filtered by an IOFileFilter.
+     * <p>
+     * If your search should recurse into subdirectories you can pass in
+     * an IOFileFilter for directories. You don't need to bind a
+     * DirectoryFileFilter (via logical AND) to this filter. This method does
+     * that for you.
+     * </p>
+     * <p>
+     * An example: If you want to search through all directories called
+     * "temp" you pass in {@code FileFilterUtils.NameFileFilter("temp")}
+     * </p>
+     * <p>
+     * Another common usage of this method is find files in a directory
+     * tree but ignoring the directories generated CVS. You can simply pass
+     * in {@code FileFilterUtils.makeCVSAware(null)}.
+     * </p>
+     *
+     * @param directory  the directory to search in
+     * @param fileFilter filter to apply when finding files. Must not be {@code null},
+     *                   use {@link TrueFileFilter#INSTANCE} to match all files in selected directories.
+     * @param dirFilter  optional filter to apply when finding subdirectories.
+     *                   If this parameter is {@code null}, subdirectories will not be included in the
+     *                   search. Use {@link TrueFileFilter#INSTANCE} to match all directories.
+     * @return a collection of java.io.File with the matching files
+     * @see org.apache.commons.io.filefilter.FileFilterUtils
+     * @see org.apache.commons.io.filefilter.NameFileFilter
+     */
+    public static Collection<File> listFiles(final File directory, final IOFileFilter fileFilter, final IOFileFilter dirFilter) {
+        final AccumulatorPathVisitor visitor = Uncheck
+            .apply(d -> listAccumulate(d, FileFileFilter.INSTANCE.and(fileFilter), dirFilter, FileVisitOption.FOLLOW_LINKS), directory);
+        return visitor.getFileList().stream().map(Path::toFile).collect(Collectors.toList());
+    }
+
+    /**
+     * Finds files within a given directory (and optionally its subdirectories)
+     * which match an array of extensions.
+     *
+     * @param directory  the directory to search in
+     * @param extensions an array of extensions, ex. {"java","xml"}. If this
+     *                   parameter is {@code null}, all files are returned.
+     * @param recursive  if true all subdirectories are searched as well
+     * @return a collection of java.io.File with the matching files
+     */
+    public static Collection<File> listFiles(final File directory, final String[] extensions, final boolean recursive) {
+        return Uncheck.apply(d -> toList(streamFiles(d, recursive, extensions)), directory);
+    }
+
+    /**
+     * Finds files within a given directory (and optionally its
+     * subdirectories). All files found are filtered by an IOFileFilter.
+     * <p>
+     * The resulting collection includes the starting directory and
+     * any subdirectories that match the directory filter.
+     * </p>
+     *
+     * @param directory  the directory to search in
+     * @param fileFilter filter to apply when finding files.
+     * @param dirFilter  optional filter to apply when finding subdirectories.
+     *                   If this parameter is {@code null}, subdirectories will not be included in the
+     *                   search. Use TrueFileFilter.INSTANCE to match all directories.
+     * @return a collection of java.io.File with the matching files
+     * @see org.apache.commons.io.FileUtils#listFiles
+     * @see org.apache.commons.io.filefilter.FileFilterUtils
+     * @see org.apache.commons.io.filefilter.NameFileFilter
+     * @since 2.2
+     */
+    public static Collection<File> listFilesAndDirs(final File directory, final IOFileFilter fileFilter, final IOFileFilter dirFilter) {
+        final AccumulatorPathVisitor visitor = Uncheck.apply(d -> listAccumulate(d, fileFilter, dirFilter, FileVisitOption.FOLLOW_LINKS),
+            directory);
+        final List<Path> list = visitor.getFileList();
+        list.addAll(visitor.getDirList());
+        return list.stream().map(Path::toFile).collect(Collectors.toList());
+    }
+
+    /**
+     * Calls {@link File#mkdirs()} and throws an exception on failure.
+     *
+     * @param directory the receiver for {@code mkdirs()}, may be null.
+     * @return the given file, may be null.
+     * @throws IOException if the directory was not created along with all its parent directories.
+     * @throws IOException if the given file object is not a directory.
+     * @throws SecurityException See {@link File#mkdirs()}.
+     * @see File#mkdirs()
+     */
+    private static File mkdirs(final File directory) throws IOException {
+        if (directory != null && !directory.mkdirs() && !directory.isDirectory()) {
+            throw new IOException("Cannot create directory '" + directory + "'.");
+        }
+        return directory;
+    }
+
+    /**
+     * Moves a directory.
+     * <p>
+     * When the destination directory is on another file system, do a "copy and delete".
+     * </p>
+     *
+     * @param srcDir the directory to be moved.
+     * @param destDir the destination directory.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws IllegalArgumentException if the source or destination is invalid.
+     * @throws FileNotFoundException if the source does not exist.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @since 1.4
+     */
+    public static void moveDirectory(final File srcDir, final File destDir) throws IOException {
+        validateMoveParameters(srcDir, destDir);
+        requireDirectory(srcDir, "srcDir");
+        requireAbsent(destDir, "destDir");
+        if (!srcDir.renameTo(destDir)) {
+            if (destDir.getCanonicalPath().startsWith(srcDir.getCanonicalPath() + File.separator)) {
+                throw new IOException("Cannot move directory: " + srcDir + " to a subdirectory of itself: " + destDir);
+            }
+            copyDirectory(srcDir, destDir);
+            deleteDirectory(srcDir);
+            if (srcDir.exists()) {
+                throw new IOException("Failed to delete original directory '" + srcDir +
+                        "' after copy to '" + destDir + "'");
+            }
+        }
+    }
+
+    /**
+     * Moves a directory to another directory.
+     *
+     * @param source the file to be moved.
+     * @param destDir the destination file.
+     * @param createDestDir If {@code true} create the destination directory, otherwise if {@code false} throw an
+     *        IOException.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws IllegalArgumentException if the source or destination is invalid.
+     * @throws FileNotFoundException if the source does not exist.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @since 1.4
+     */
+    public static void moveDirectoryToDirectory(final File source, final File destDir, final boolean createDestDir) throws IOException {
+        validateMoveParameters(source, destDir);
+        if (!destDir.isDirectory()) {
+            if (destDir.exists()) {
+                throw new IOException("Destination '" + destDir + "' is not a directory");
+            }
+            if (!createDestDir) {
+                throw new FileNotFoundException("Destination directory '" + destDir + "' does not exist [createDestDir=" + false + "]");
+            }
+            mkdirs(destDir);
+        }
+        moveDirectory(source, new File(destDir, source.getName()));
+    }
+
+    /**
+     * Moves a file preserving attributes.
+     * <p>
+     * Shorthand for {@code moveFile(srcFile, destFile, StandardCopyOption.COPY_ATTRIBUTES)}.
+     * </p>
+     * <p>
+     * When the destination file is on another file system, do a "copy and delete".
+     * </p>
+     *
+     * @param srcFile the file to be moved.
+     * @param destFile the destination file.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws FileExistsException if the destination file exists.
+     * @throws FileNotFoundException if the source file does not exist.
+     * @throws IOException if source or destination is invalid.
+     * @throws IOException if an error occurs.
+     * @since 1.4
+     */
+    public static void moveFile(final File srcFile, final File destFile) throws IOException {
+        moveFile(srcFile, destFile, StandardCopyOption.COPY_ATTRIBUTES);
+    }
+
+    /**
+     * Moves a file.
+     * <p>
+     * When the destination file is on another file system, do a "copy and delete".
+     * </p>
+     *
+     * @param srcFile the file to be moved.
+     * @param destFile the destination file.
+     * @param copyOptions Copy options.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws FileExistsException if the destination file exists.
+     * @throws FileNotFoundException if the source file does not exist.
+     * @throws IOException if source or destination is invalid.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @since 2.9.0
+     */
+    public static void moveFile(final File srcFile, final File destFile, final CopyOption... copyOptions) throws IOException {
+        validateMoveParameters(srcFile, destFile);
+        requireFile(srcFile, "srcFile");
+        requireAbsent(destFile, "destFile");
+        final boolean rename = srcFile.renameTo(destFile);
+        if (!rename) {
+            copyFile(srcFile, destFile, copyOptions);
+            if (!srcFile.delete()) {
+                FileUtils.deleteQuietly(destFile);
+                throw new IOException("Failed to delete original file '" + srcFile + "' after copy to '" + destFile + "'");
+            }
+        }
+    }
+
+    /**
+     * Moves a file to a directory.
+     *
+     * @param srcFile the file to be moved.
+     * @param destDir the destination file.
+     * @param createDestDir If {@code true} create the destination directory, otherwise if {@code false} throw an
+     *        IOException.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws FileExistsException if the destination file exists.
+     * @throws FileNotFoundException if the source file does not exist.
+     * @throws IOException if source or destination is invalid.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @since 1.4
+     */
+    public static void moveFileToDirectory(final File srcFile, final File destDir, final boolean createDestDir) throws IOException {
+        validateMoveParameters(srcFile, destDir);
+        if (!destDir.exists() && createDestDir) {
+            mkdirs(destDir);
+        }
+        requireExistsChecked(destDir, "destDir");
+        requireDirectory(destDir, "destDir");
+        moveFile(srcFile, new File(destDir, srcFile.getName()));
+    }
+
+    /**
+     * Moves a file or directory to the destination directory.
+     * <p>
+     * When the destination is on another file system, do a "copy and delete".
+     * </p>
+     *
+     * @param src the file or directory to be moved.
+     * @param destDir the destination directory.
+     * @param createDestDir If {@code true} create the destination directory, otherwise if {@code false} throw an
+     *        IOException.
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws FileExistsException if the directory or file exists in the destination directory.
+     * @throws FileNotFoundException if the source file does not exist.
+     * @throws IOException if source or destination is invalid.
+     * @throws IOException if an error occurs or setting the last-modified time didn't succeed.
+     * @since 1.4
+     */
+    public static void moveToDirectory(final File src, final File destDir, final boolean createDestDir) throws IOException {
+        validateMoveParameters(src, destDir);
+        if (src.isDirectory()) {
+            moveDirectoryToDirectory(src, destDir, createDestDir);
+        } else {
+            moveFileToDirectory(src, destDir, createDestDir);
+        }
+    }
+
+    /**
+     * Creates a new OutputStream by opening or creating a file, returning an output stream that may be used to write bytes
+     * to the file.
+     *
+     * @param append Whether or not to append.
+     * @param file the File.
+     * @return a new OutputStream.
+     * @throws IOException if an I/O error occurs.
+     * @see PathUtils#newOutputStream(Path, boolean)
+     * @since 2.12.0
+     */
+    public static OutputStream newOutputStream(final File file, final boolean append) throws IOException {
+        return PathUtils.newOutputStream(Objects.requireNonNull(file, "file").toPath(), append);
+    }
+
+    /**
+     * Opens a {@link FileInputStream} for the specified file, providing better error messages than simply calling
+     * {@code new FileInputStream(file)}.
+     * <p>
+     * At the end of the method either the stream will be successfully opened, or an exception will have been thrown.
+     * </p>
+     * <p>
+     * An exception is thrown if the file does not exist. An exception is thrown if the file object exists but is a
+     * directory. An exception is thrown if the file exists but cannot be read.
+     * </p>
+     *
+     * @param file the file to open for input, must not be {@code null}
+     * @return a new {@link FileInputStream} for the specified file
+     * @throws NullPointerException if file is {@code null}.
+     * @throws FileNotFoundException if the file does not exist, is a directory rather than a regular file, or for some
+     *         other reason cannot be opened for reading.
+     * @throws IOException See FileNotFoundException above, FileNotFoundException is a subclass of IOException.
+     * @since 1.3
+     */
+    public static FileInputStream openInputStream(final File file) throws IOException {
+        Objects.requireNonNull(file, "file");
+        return new FileInputStream(file);
+    }
+
+    /**
+     * Opens a {@link FileOutputStream} for the specified file, checking and
+     * creating the parent directory if it does not exist.
+     * <p>
+     * At the end of the method either the stream will be successfully opened,
+     * or an exception will have been thrown.
+     * </p>
+     * <p>
+     * The parent directory will be created if it does not exist.
+     * The file will be created if it does not exist.
+     * An exception is thrown if the file object exists but is a directory.
+     * An exception is thrown if the file exists but cannot be written to.
+     * An exception is thrown if the parent directory cannot be created.
+     * </p>
+     *
+     * @param file the file to open for output, must not be {@code null}
+     * @return a new {@link FileOutputStream} for the specified file
+     * @throws NullPointerException if the file object is {@code null}.
+     * @throws IllegalArgumentException if the file object is a directory
+     * @throws IllegalArgumentException if the file is not writable.
+     * @throws IOException if the directories could not be created.
+     * @since 1.3
+     */
+    public static FileOutputStream openOutputStream(final File file) throws IOException {
+        return openOutputStream(file, false);
+    }
+
+    /**
+     * Opens a {@link FileOutputStream} for the specified file, checking and
+     * creating the parent directory if it does not exist.
+     * <p>
+     * At the end of the method either the stream will be successfully opened,
+     * or an exception will have been thrown.
+     * </p>
+     * <p>
+     * The parent directory will be created if it does not exist.
+     * The file will be created if it does not exist.
+     * An exception is thrown if the file object exists but is a directory.
+     * An exception is thrown if the file exists but cannot be written to.
+     * An exception is thrown if the parent directory cannot be created.
+     * </p>
+     *
+     * @param file   the file to open for output, must not be {@code null}
+     * @param append if {@code true}, then bytes will be added to the
+     *               end of the file rather than overwriting
+     * @return a new {@link FileOutputStream} for the specified file
+     * @throws NullPointerException if the file object is {@code null}.
+     * @throws IllegalArgumentException if the file object is a directory
+     * @throws IllegalArgumentException if the file is not writable.
+     * @throws IOException if the directories could not be created.
+     * @since 2.1
+     */
+    public static FileOutputStream openOutputStream(final File file, final boolean append) throws IOException {
+        Objects.requireNonNull(file, "file");
+        if (file.exists()) {
+            requireFile(file, "file");
+            requireCanWrite(file, "file");
+        } else {
+            createParentDirectories(file);
+        }
+        return new FileOutputStream(file, append);
+    }
+
+    /**
+     * Reads the contents of a file into a byte array.
+     * The file is always closed.
+     *
+     * @param file the file to read, must not be {@code null}
+     * @return the file contents, never {@code null}
+     * @throws NullPointerException if file is {@code null}.
+     * @throws FileNotFoundException if the file does not exist, is a directory rather than a regular file, or for some
+     *         other reason cannot be opened for reading.
+     * @throws IOException if an I/O error occurs.
+     * @since 1.1
+     */
+    public static byte[] readFileToByteArray(final File file) throws IOException {
+        Objects.requireNonNull(file, "file");
+        return Files.readAllBytes(file.toPath());
+    }
+
+    /**
+     * Reads the contents of a file into a String using the default encoding for the VM.
+     * The file is always closed.
+     *
+     * @param file the file to read, must not be {@code null}
+     * @return the file contents, never {@code null}
+     * @throws NullPointerException if file is {@code null}.
+     * @throws FileNotFoundException if the file does not exist, is a directory rather than a regular file, or for some
+     *         other reason cannot be opened for reading.
+     * @throws IOException if an I/O error occurs.
+     * @since 1.3.1
+     * @deprecated 2.5 use {@link #readFileToString(File, Charset)} instead (and specify the appropriate encoding)
+     */
+    @Deprecated
+    public static String readFileToString(final File file) throws IOException {
+        return readFileToString(file, Charset.defaultCharset());
+    }
+
+    /**
+     * Reads the contents of a file into a String.
+     * The file is always closed.
+     *
+     * @param file     the file to read, must not be {@code null}
+     * @param charsetName the name of the requested charset, {@code null} means platform default
+     * @return the file contents, never {@code null}
+     * @throws NullPointerException if file is {@code null}.
+     * @throws FileNotFoundException if the file does not exist, is a directory rather than a regular file, or for some
+     *         other reason cannot be opened for reading.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.3
+     */
+    public static String readFileToString(final File file, final Charset charsetName) throws IOException {
+        try (InputStream inputStream = Files.newInputStream(file.toPath())) {
+            return IOUtils.toString(inputStream, Charsets.toCharset(charsetName));
+        }
+    }
+
+    /**
+     * Reads the contents of a file into a String. The file is always closed.
+     *
+     * @param file     the file to read, must not be {@code null}
+     * @param charsetName the name of the requested charset, {@code null} means platform default
+     * @return the file contents, never {@code null}
+     * @throws NullPointerException if file is {@code null}.
+     * @throws FileNotFoundException if the file does not exist, is a directory rather than a regular file, or for some
+     *         other reason cannot be opened for reading.
+     * @throws IOException if an I/O error occurs.
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     * .UnsupportedEncodingException} in version 2.2 if the named charset is unavailable.
+     * @since 2.3
+     */
+    public static String readFileToString(final File file, final String charsetName) throws IOException {
+        return readFileToString(file, Charsets.toCharset(charsetName));
+    }
+
+    /**
+     * Reads the contents of a file line by line to a List of Strings using the default encoding for the VM.
+     * The file is always closed.
+     *
+     * @param file the file to read, must not be {@code null}
+     * @return the list of Strings representing each line in the file, never {@code null}
+     * @throws NullPointerException if file is {@code null}.
+     * @throws FileNotFoundException if the file does not exist, is a directory rather than a regular file, or for some
+     *         other reason cannot be opened for reading.
+     * @throws IOException if an I/O error occurs.
+     * @since 1.3
+     * @deprecated 2.5 use {@link #readLines(File, Charset)} instead (and specify the appropriate encoding)
+     */
+    @Deprecated
+    public static List<String> readLines(final File file) throws IOException {
+        return readLines(file, Charset.defaultCharset());
+    }
+
+    /**
+     * Reads the contents of a file line by line to a List of Strings.
+     * The file is always closed.
+     *
+     * @param file     the file to read, must not be {@code null}
+     * @param charset the charset to use, {@code null} means platform default
+     * @return the list of Strings representing each line in the file, never {@code null}
+     * @throws NullPointerException if file is {@code null}.
+     * @throws FileNotFoundException if the file does not exist, is a directory rather than a regular file, or for some
+     *         other reason cannot be opened for reading.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.3
+     */
+    public static List<String> readLines(final File file, final Charset charset) throws IOException {
+        return Files.readAllLines(file.toPath(), charset);
+    }
+
+    /**
+     * Reads the contents of a file line by line to a List of Strings. The file is always closed.
+     *
+     * @param file     the file to read, must not be {@code null}
+     * @param charsetName the name of the requested charset, {@code null} means platform default
+     * @return the list of Strings representing each line in the file, never {@code null}
+     * @throws NullPointerException if file is {@code null}.
+     * @throws FileNotFoundException if the file does not exist, is a directory rather than a regular file, or for some
+     *         other reason cannot be opened for reading.
+     * @throws IOException if an I/O error occurs.
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     * .UnsupportedEncodingException} in version 2.2 if the named charset is unavailable.
+     * @since 1.1
+     */
+    public static List<String> readLines(final File file, final String charsetName) throws IOException {
+        return readLines(file, Charsets.toCharset(charsetName));
+    }
+
+
+    private static void requireAbsent(final File file, final String name) throws FileExistsException {
+        if (file.exists()) {
+            throw new FileExistsException(String.format("File element in parameter '%s' already exists: '%s'", name, file));
+        }
+    }
+
+    /**
+     * Throws IllegalArgumentException if the given files' canonical representations are equal.
+     *
+     * @param file1 The first file to compare.
+     * @param file2 The second file to compare.
+     * @throws IOException if an I/O error occurs.
+     * @throws IllegalArgumentException if the given files' canonical representations are equal.
+     */
+    private static void requireCanonicalPathsNotEquals(final File file1, final File file2) throws IOException {
+        final String canonicalPath = file1.getCanonicalPath();
+        if (canonicalPath.equals(file2.getCanonicalPath())) {
+            throw new IllegalArgumentException(String
+                .format("File canonical paths are equal: '%s' (file1='%s', file2='%s')", canonicalPath, file1, file2));
+        }
+    }
+
+    /**
+     * Throws an {@link IllegalArgumentException} if the file is not writable. This provides a more precise exception
+     * message than a plain access denied.
+     *
+     * @param file The file to test.
+     * @param name The parameter name to use in the exception message.
+     * @throws NullPointerException if the given {@link File} is {@code null}.
+     * @throws IllegalArgumentException if the file is not writable.
+     */
+    private static void requireCanWrite(final File file, final String name) {
+        Objects.requireNonNull(file, "file");
+        if (!file.canWrite()) {
+            throw new IllegalArgumentException("File parameter '" + name + " is not writable: '" + file + "'");
+        }
+    }
+
+    /**
+     * Requires that the given {@link File} is a directory.
+     *
+     * @param directory The {@link File} to check.
+     * @param name The parameter name to use in the exception message in case of null input or if the file is not a directory.
+     * @return the given directory.
+     * @throws NullPointerException if the given {@link File} is {@code null}.
+     * @throws IllegalArgumentException if the given {@link File} does not exist or is not a directory.
+     */
+    private static File requireDirectory(final File directory, final String name) {
+        Objects.requireNonNull(directory, name);
+        if (!directory.isDirectory()) {
+            throw new IllegalArgumentException("Parameter '" + name + "' is not a directory: '" + directory + "'");
+        }
+        return directory;
+    }
+
+    /**
+     * Requires that the given {@link File} exists and is a directory.
+     *
+     * @param directory The {@link File} to check.
+     * @param name The parameter name to use in the exception message in case of null input.
+     * @return the given directory.
+     * @throws NullPointerException if the given {@link File} is {@code null}.
+     * @throws IllegalArgumentException if the given {@link File} does not exist or is not a directory.
+     */
+    private static File requireDirectoryExists(final File directory, final String name) {
+        requireExists(directory, name);
+        requireDirectory(directory, name);
+        return directory;
+    }
+
+    /**
+     * Requires that the given {@link File} is a directory if it exists.
+     *
+     * @param directory The {@link File} to check.
+     * @param name The parameter name to use in the exception message in case of null input.
+     * @return the given directory.
+     * @throws NullPointerException if the given {@link File} is {@code null}.
+     * @throws IllegalArgumentException if the given {@link File} exists but is not a directory.
+     */
+    private static File requireDirectoryIfExists(final File directory, final String name) {
+        Objects.requireNonNull(directory, name);
+        if (directory.exists()) {
+            requireDirectory(directory, name);
+        }
+        return directory;
+    }
+
+    /**
+     * Requires that the given {@link File} exists and throws an {@link IllegalArgumentException} if it doesn't.
+     *
+     * @param file The {@link File} to check.
+     * @param fileParamName The parameter name to use in the exception message in case of {@code null} input.
+     * @return the given file.
+     * @throws NullPointerException if the given {@link File} is {@code null}.
+     * @throws IllegalArgumentException if the given {@link File} does not exist.
+     */
+    private static File requireExists(final File file, final String fileParamName) {
+        Objects.requireNonNull(file, fileParamName);
+        if (!file.exists()) {
+            throw new IllegalArgumentException("File system element for parameter '" + fileParamName + "' does not exist: '" + file + "'");
+        }
+        return file;
+    }
+
+    /**
+     * Requires that the given {@link File} exists and throws an {@link FileNotFoundException} if it doesn't.
+     *
+     * @param file The {@link File} to check.
+     * @param fileParamName The parameter name to use in the exception message in case of {@code null} input.
+     * @return the given file.
+     * @throws NullPointerException if the given {@link File} is {@code null}.
+     * @throws FileNotFoundException if the given {@link File} does not exist.
+     */
+    private static File requireExistsChecked(final File file, final String fileParamName) throws FileNotFoundException {
+        Objects.requireNonNull(file, fileParamName);
+        if (!file.exists()) {
+            throw new FileNotFoundException("File system element for parameter '" + fileParamName + "' does not exist: '" + file + "'");
+        }
+        return file;
+    }
+
+    /**
+     * Requires that the given {@link File} is a file.
+     *
+     * @param file The {@link File} to check.
+     * @param name The parameter name to use in the exception message.
+     * @return the given file.
+     * @throws NullPointerException if the given {@link File} is {@code null}.
+     * @throws IllegalArgumentException if the given {@link File} does not exist or is not a file.
+     */
+    private static File requireFile(final File file, final String name) {
+        Objects.requireNonNull(file, name);
+        if (!file.isFile()) {
+            throw new IllegalArgumentException("Parameter '" + name + "' is not a file: " + file);
+        }
+        return file;
+    }
+
+    /**
+     * Requires parameter attributes for a file copy operation.
+     *
+     * @param source the source file
+     * @param destination the destination
+     * @throws NullPointerException if any of the given {@link File}s are {@code null}.
+     * @throws FileNotFoundException if the source does not exist.
+     */
+    private static void requireFileCopy(final File source, final File destination) throws FileNotFoundException {
+        requireExistsChecked(source, "source");
+        Objects.requireNonNull(destination, "destination");
+    }
+
+    /**
+     * Requires that the given {@link File} is a file if it exists.
+     *
+     * @param file The {@link File} to check.
+     * @param name The parameter name to use in the exception message in case of null input.
+     * @return the given directory.
+     * @throws NullPointerException if the given {@link File} is {@code null}.
+     * @throws IllegalArgumentException if the given {@link File} exists but is not a directory.
+     */
+    private static File requireFileIfExists(final File file, final String name) {
+        Objects.requireNonNull(file, name);
+        return file.exists() ? requireFile(file, name) : file;
+    }
+
+    /**
+     * Sets the given {@code targetFile}'s last modified date to the value from {@code sourceFile}.
+     *
+     * @param sourceFile The source file to query.
+     * @param targetFile The target file or directory to set.
+     * @throws NullPointerException if sourceFile is {@code null}.
+     * @throws NullPointerException if targetFile is {@code null}.
+     * @throws IOException if setting the last-modified time failed.
+     */
+    private static void setLastModified(final File sourceFile, final File targetFile) throws IOException {
+        Objects.requireNonNull(sourceFile, "sourceFile");
+        Objects.requireNonNull(targetFile, "targetFile");
+        if (targetFile.isFile()) {
+            PathUtils.setLastModifiedTime(targetFile.toPath(), sourceFile.toPath());
+        } else {
+            setLastModified(targetFile, lastModified(sourceFile));
+        }
+    }
+
+    /**
+     * Sets the given {@code targetFile}'s last modified date to the given value.
+     *
+     * @param file The source file to query.
+     * @param timeMillis The new last-modified time, measured in milliseconds since the epoch 01-01-1970 GMT.
+     * @throws NullPointerException if file is {@code null}.
+     * @throws IOException if setting the last-modified time failed.
+     */
+    private static void setLastModified(final File file, final long timeMillis) throws IOException {
+        Objects.requireNonNull(file, "file");
+        if (!file.setLastModified(timeMillis)) {
+            throw new IOException(String.format("Failed setLastModified(%s) on '%s'", timeMillis, file));
+        }
+    }
+
+    /**
+     * Returns the size of the specified file or directory. If the provided
+     * {@link File} is a regular file, then the file's length is returned.
+     * If the argument is a directory, then the size of the directory is
+     * calculated recursively. If a directory or subdirectory is security
+     * restricted, its size will not be included.
+     * <p>
+     * Note that overflow is not detected, and the return value may be negative if
+     * overflow occurs. See {@link #sizeOfAsBigInteger(File)} for an alternative
+     * method that does not overflow.
+     * </p>
+     *
+     * @param file the regular file or directory to return the size
+     *             of (must not be {@code null}).
+     *
+     * @return the length of the file, or recursive size of the directory,
+     * provided (in bytes).
+     *
+     * @throws NullPointerException     if the file is {@code null}.
+     * @throws IllegalArgumentException if the file does not exist.
+     * @throws UncheckedIOException if an IO error occurs.
+     * @since 2.0
+     */
+    public static long sizeOf(final File file) {
+        requireExists(file, "file");
+        return Uncheck.get(() -> PathUtils.sizeOf(file.toPath()));
+    }
+
+    /**
+     * Returns the size of the specified file or directory. If the provided
+     * {@link File} is a regular file, then the file's length is returned.
+     * If the argument is a directory, then the size of the directory is
+     * calculated recursively. If a directory or subdirectory is security
+     * restricted, its size will not be included.
+     *
+     * @param file the regular file or directory to return the size
+     *             of (must not be {@code null}).
+     *
+     * @return the length of the file, or recursive size of the directory,
+     * provided (in bytes).
+     *
+     * @throws NullPointerException     if the file is {@code null}.
+     * @throws IllegalArgumentException if the file does not exist.
+     * @throws UncheckedIOException if an IO error occurs.
+     * @since 2.4
+     */
+    public static BigInteger sizeOfAsBigInteger(final File file) {
+        requireExists(file, "file");
+        return Uncheck.get(() -> PathUtils.sizeOfAsBigInteger(file.toPath()));
+    }
+
+    /**
+     * Counts the size of a directory recursively (sum of the length of all files).
+     * <p>
+     * Note that overflow is not detected, and the return value may be negative if
+     * overflow occurs. See {@link #sizeOfDirectoryAsBigInteger(File)} for an alternative
+     * method that does not overflow.
+     * </p>
+     *
+     * @param directory directory to inspect, must not be {@code null}.
+     * @return size of directory in bytes, 0 if directory is security restricted, a negative number when the real total
+     * is greater than {@link Long#MAX_VALUE}.
+     * @throws NullPointerException if the directory is {@code null}.
+     * @throws UncheckedIOException if an IO error occurs.
+     */
+    public static long sizeOfDirectory(final File directory) {
+        requireDirectoryExists(directory, "directory");
+        return Uncheck.get(() -> PathUtils.sizeOfDirectory(directory.toPath()));
+    }
+
+    /**
+     * Counts the size of a directory recursively (sum of the length of all files).
+     *
+     * @param directory directory to inspect, must not be {@code null}.
+     * @return size of directory in bytes, 0 if directory is security restricted.
+     * @throws NullPointerException if the directory is {@code null}.
+     * @throws UncheckedIOException if an IO error occurs.
+     * @since 2.4
+     */
+    public static BigInteger sizeOfDirectoryAsBigInteger(final File directory) {
+        requireDirectoryExists(directory, "directory");
+        return Uncheck.get(() -> PathUtils.sizeOfDirectoryAsBigInteger(directory.toPath()));
+    }
+
+    /**
+     * Streams over the files in a given directory (and optionally
+     * its subdirectories) which match an array of extensions.
+     *
+     * @param directory  the directory to search in
+     * @param recursive  if true all subdirectories are searched as well
+     * @param extensions an array of extensions, ex. {"java","xml"}. If this
+     *                   parameter is {@code null}, all files are returned.
+     * @return an iterator of java.io.File with the matching files
+     * @throws IOException if an I/O error is thrown when accessing the starting file.
+     * @since 2.9.0
+     */
+    public static Stream<File> streamFiles(final File directory, final boolean recursive, final String... extensions) throws IOException {
+        // @formatter:off
+        final IOFileFilter filter = extensions == null
+            ? FileFileFilter.INSTANCE
+            : FileFileFilter.INSTANCE.and(new SuffixFileFilter(toSuffixes(extensions)));
+        // @formatter:on
+        return PathUtils.walk(directory.toPath(), filter, toMaxDepth(recursive), false, FileVisitOption.FOLLOW_LINKS).map(Path::toFile);
+    }
+
+    /**
+     * Converts from a {@link URL} to a {@link File}.
+     * <p>
+     * From version 1.1 this method will decode the URL.
+     * Syntax such as {@code file:///my%20docs/file.txt} will be
+     * correctly decoded to {@code /my docs/file.txt}. Starting with version
+     * 1.5, this method uses UTF-8 to decode percent-encoded octets to characters.
+     * Additionally, malformed percent-encoded octets are handled leniently by
+     * passing them through literally.
+     * </p>
+     *
+     * @param url the file URL to convert, {@code null} returns {@code null}
+     * @return the equivalent {@link File} object, or {@code null}
+     * if the URL's protocol is not {@code file}
+     */
+    public static File toFile(final URL url) {
+        if (url == null || !"file".equalsIgnoreCase(url.getProtocol())) {
+            return null;
+        }
+        final String filename = url.getFile().replace('/', File.separatorChar);
+        return new File(decodeUrl(filename));
+    }
+
+    /**
+     * Converts each of an array of {@link URL} to a {@link File}.
+     * <p>
+     * Returns an array of the same size as the input.
+     * If the input is {@code null}, an empty array is returned.
+     * If the input contains {@code null}, the output array contains {@code null} at the same
+     * index.
+     * </p>
+     * <p>
+     * This method will decode the URL.
+     * Syntax such as {@code file:///my%20docs/file.txt} will be
+     * correctly decoded to {@code /my docs/file.txt}.
+     * </p>
+     *
+     * @param urls the file URLs to convert, {@code null} returns empty array
+     * @return a non-{@code null} array of Files matching the input, with a {@code null} item
+     * if there was a {@code null} at that index in the input array
+     * @throws IllegalArgumentException if any file is not a URL file
+     * @throws IllegalArgumentException if any file is incorrectly encoded
+     * @since 1.1
+     */
+    public static File[] toFiles(final URL... urls) {
+        if (IOUtils.length(urls) == 0) {
+            return EMPTY_FILE_ARRAY;
+        }
+        final File[] files = new File[urls.length];
+        for (int i = 0; i < urls.length; i++) {
+            final URL url = urls[i];
+            if (url != null) {
+                if (!"file".equalsIgnoreCase(url.getProtocol())) {
+                    throw new IllegalArgumentException("Can only convert file URL to a File: " + url);
+                }
+                files[i] = toFile(url);
+            }
+        }
+        return files;
+    }
+
+    private static List<File> toList(final Stream<File> stream) {
+        return stream.collect(Collectors.toList());
+    }
+
+    /**
+     * Converts whether or not to recurse into a recursion max depth.
+     *
+     * @param recursive whether or not to recurse
+     * @return the recursion depth
+     */
+    private static int toMaxDepth(final boolean recursive) {
+        return recursive ? Integer.MAX_VALUE : 1;
+    }
+
+    /**
+     * Converts an array of file extensions to suffixes.
+     *
+     * @param extensions an array of extensions. Format: {"java", "xml"}
+     * @return an array of suffixes. Format: {".java", ".xml"}
+     * @throws NullPointerException if the parameter is null
+     */
+    private static String[] toSuffixes(final String... extensions) {
+        return Stream.of(Objects.requireNonNull(extensions, "extensions")).map(e -> "." + e).toArray(String[]::new);
+    }
+
+    /**
+     * Implements behavior similar to the Unix "touch" utility. Creates a new file with size 0, or, if the file exists, just
+     * updates the file's modified time.
+     * <p>
+     * NOTE: As from v1.3, this method throws an IOException if the last modified date of the file cannot be set. Also, as
+     * from v1.3 this method creates parent directories if they do not exist.
+     * </p>
+     *
+     * @param file the File to touch.
+     * @throws NullPointerException if the parameter is {@code null}.
+     * @throws IOException if setting the last-modified time failed or an I/O problem occurs.
+     */
+    public static void touch(final File file) throws IOException {
+        PathUtils.touch(Objects.requireNonNull(file, "file").toPath());
+    }
+
+    /**
+     * Converts each of an array of {@link File} to a {@link URL}.
+     * <p>
+     * Returns an array of the same size as the input.
+     * </p>
+     *
+     * @param files the files to convert, must not be {@code null}
+     * @return an array of URLs matching the input
+     * @throws IOException          if a file cannot be converted
+     * @throws NullPointerException if the parameter is null
+     */
+    public static URL[] toURLs(final File... files) throws IOException {
+        Objects.requireNonNull(files, "files");
+        final URL[] urls = new URL[files.length];
+        for (int i = 0; i < urls.length; i++) {
+            urls[i] = files[i].toURI().toURL();
+        }
+        return urls;
+    }
+
+    /**
+     * Validates the given arguments.
+     * <ul>
+     * <li>Throws {@link NullPointerException} if {@code source} is null</li>
+     * <li>Throws {@link NullPointerException} if {@code destination} is null</li>
+     * <li>Throws {@link FileNotFoundException} if {@code source} does not exist</li>
+     * </ul>
+     *
+     * @param source      the file or directory to be moved.
+     * @param destination the destination file or directory.
+     * @throws FileNotFoundException if the source file does not exist.
+     */
+    private static void validateMoveParameters(final File source, final File destination) throws FileNotFoundException {
+        Objects.requireNonNull(source, "source");
+        Objects.requireNonNull(destination, "destination");
+        if (!source.exists()) {
+            throw new FileNotFoundException("Source '" + source + "' does not exist");
+        }
+    }
+
+    /**
+     * Waits for the file system to propagate a file creation, with a timeout.
+     * <p>
+     * This method repeatedly tests {@link Files#exists(Path, LinkOption...)} until it returns
+     * true up to the maximum time specified in seconds.
+     * </p>
+     *
+     * @param file    the file to check, must not be {@code null}
+     * @param seconds the maximum time in seconds to wait
+     * @return true if file exists
+     * @throws NullPointerException if the file is {@code null}
+     */
+    public static boolean waitFor(final File file, final int seconds) {
+        Objects.requireNonNull(file, "file");
+        return PathUtils.waitFor(file.toPath(), Duration.ofSeconds(seconds), PathUtils.EMPTY_LINK_OPTION_ARRAY);
+    }
+
+    /**
+     * Writes a CharSequence to a file creating the file if it does not exist using the default encoding for the VM.
+     *
+     * @param file the file to write
+     * @param data the content to write to the file
+     * @throws IOException in case of an I/O error
+     * @since 2.0
+     * @deprecated 2.5 use {@link #write(File, CharSequence, Charset)} instead (and specify the appropriate encoding)
+     */
+    @Deprecated
+    public static void write(final File file, final CharSequence data) throws IOException {
+        write(file, data, Charset.defaultCharset(), false);
+    }
+
+    /**
+     * Writes a CharSequence to a file creating the file if it does not exist using the default encoding for the VM.
+     *
+     * @param file   the file to write
+     * @param data   the content to write to the file
+     * @param append if {@code true}, then the data will be added to the
+     *               end of the file rather than overwriting
+     * @throws IOException in case of an I/O error
+     * @since 2.1
+     * @deprecated 2.5 use {@link #write(File, CharSequence, Charset, boolean)} instead (and specify the appropriate encoding)
+     */
+    @Deprecated
+    public static void write(final File file, final CharSequence data, final boolean append) throws IOException {
+        write(file, data, Charset.defaultCharset(), append);
+    }
+
+    /**
+     * Writes a CharSequence to a file creating the file if it does not exist.
+     *
+     * @param file     the file to write
+     * @param data     the content to write to the file
+     * @param charset the name of the requested charset, {@code null} means platform default
+     * @throws IOException in case of an I/O error
+     * @since 2.3
+     */
+    public static void write(final File file, final CharSequence data, final Charset charset) throws IOException {
+        write(file, data, charset, false);
+    }
+
+    /**
+     * Writes a CharSequence to a file creating the file if it does not exist.
+     *
+     * @param file     the file to write
+     * @param data     the content to write to the file
+     * @param charset the charset to use, {@code null} means platform default
+     * @param append   if {@code true}, then the data will be added to the
+     *                 end of the file rather than overwriting
+     * @throws IOException in case of an I/O error
+     * @since 2.3
+     */
+    public static void write(final File file, final CharSequence data, final Charset charset, final boolean append) throws IOException {
+        writeStringToFile(file, Objects.toString(data, null), charset, append);
+    }
+
+    /**
+     * Writes a CharSequence to a file creating the file if it does not exist.
+     *
+     * @param file     the file to write
+     * @param data     the content to write to the file
+     * @param charsetName the name of the requested charset, {@code null} means platform default
+     * @throws IOException                          in case of an I/O error
+     * @throws java.io.UnsupportedEncodingException if the encoding is not supported by the VM
+     * @since 2.0
+     */
+    public static void write(final File file, final CharSequence data, final String charsetName) throws IOException {
+        write(file, data, charsetName, false);
+    }
+
+    /**
+     * Writes a CharSequence to a file creating the file if it does not exist.
+     *
+     * @param file     the file to write
+     * @param data     the content to write to the file
+     * @param charsetName the name of the requested charset, {@code null} means platform default
+     * @param append   if {@code true}, then the data will be added to the
+     *                 end of the file rather than overwriting
+     * @throws IOException                 in case of an I/O error
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     * .UnsupportedEncodingException} in version 2.2 if the encoding is not supported by the VM
+     * @since 2.1
+     */
+    public static void write(final File file, final CharSequence data, final String charsetName, final boolean append) throws IOException {
+        write(file, data, Charsets.toCharset(charsetName), append);
+    }
+
+    // Must be called with a directory
+
+    /**
+     * Writes a byte array to a file creating the file if it does not exist.
+     * <p>
+     * NOTE: As from v1.3, the parent directories of the file will be created
+     * if they do not exist.
+     * </p>
+     *
+     * @param file the file to write to
+     * @param data the content to write to the file
+     * @throws IOException in case of an I/O error
+     * @since 1.1
+     */
+    public static void writeByteArrayToFile(final File file, final byte[] data) throws IOException {
+        writeByteArrayToFile(file, data, false);
+    }
+
+    /**
+     * Writes a byte array to a file creating the file if it does not exist.
+     *
+     * @param file   the file to write to
+     * @param data   the content to write to the file
+     * @param append if {@code true}, then bytes will be added to the
+     *               end of the file rather than overwriting
+     * @throws IOException in case of an I/O error
+     * @since 2.1
+     */
+    public static void writeByteArrayToFile(final File file, final byte[] data, final boolean append) throws IOException {
+        writeByteArrayToFile(file, data, 0, data.length, append);
+    }
+
+    /**
+     * Writes {@code len} bytes from the specified byte array starting
+     * at offset {@code off} to a file, creating the file if it does
+     * not exist.
+     *
+     * @param file the file to write to
+     * @param data the content to write to the file
+     * @param off  the start offset in the data
+     * @param len  the number of bytes to write
+     * @throws IOException in case of an I/O error
+     * @since 2.5
+     */
+    public static void writeByteArrayToFile(final File file, final byte[] data, final int off, final int len) throws IOException {
+        writeByteArrayToFile(file, data, off, len, false);
+    }
+
+    /**
+     * Writes {@code len} bytes from the specified byte array starting
+     * at offset {@code off} to a file, creating the file if it does
+     * not exist.
+     *
+     * @param file   the file to write to
+     * @param data   the content to write to the file
+     * @param off    the start offset in the data
+     * @param len    the number of bytes to write
+     * @param append if {@code true}, then bytes will be added to the
+     *               end of the file rather than overwriting
+     * @throws IOException in case of an I/O error
+     * @since 2.5
+     */
+    public static void writeByteArrayToFile(final File file, final byte[] data, final int off, final int len, final boolean append) throws IOException {
+        try (OutputStream out = newOutputStream(file, append)) {
+            out.write(data, off, len);
+        }
+    }
+
+    /**
+     * Writes the {@code toString()} value of each item in a collection to
+     * the specified {@link File} line by line.
+     * The default VM encoding and the default line ending will be used.
+     *
+     * @param file  the file to write to
+     * @param lines the lines to write, {@code null} entries produce blank lines
+     * @throws IOException in case of an I/O error
+     * @since 1.3
+     */
+    public static void writeLines(final File file, final Collection<?> lines) throws IOException {
+        writeLines(file, null, lines, null, false);
+    }
+
+    /**
+     * Writes the {@code toString()} value of each item in a collection to
+     * the specified {@link File} line by line.
+     * The default VM encoding and the default line ending will be used.
+     *
+     * @param file   the file to write to
+     * @param lines  the lines to write, {@code null} entries produce blank lines
+     * @param append if {@code true}, then the lines will be added to the
+     *               end of the file rather than overwriting
+     * @throws IOException in case of an I/O error
+     * @since 2.1
+     */
+    public static void writeLines(final File file, final Collection<?> lines, final boolean append) throws IOException {
+        writeLines(file, null, lines, null, append);
+    }
+
+    /**
+     * Writes the {@code toString()} value of each item in a collection to
+     * the specified {@link File} line by line.
+     * The default VM encoding and the specified line ending will be used.
+     *
+     * @param file       the file to write to
+     * @param lines      the lines to write, {@code null} entries produce blank lines
+     * @param lineEnding the line separator to use, {@code null} is system default
+     * @throws IOException in case of an I/O error
+     * @since 1.3
+     */
+    public static void writeLines(final File file, final Collection<?> lines, final String lineEnding) throws IOException {
+        writeLines(file, null, lines, lineEnding, false);
+    }
+
+    /**
+     * Writes the {@code toString()} value of each item in a collection to
+     * the specified {@link File} line by line.
+     * The default VM encoding and the specified line ending will be used.
+     *
+     * @param file       the file to write to
+     * @param lines      the lines to write, {@code null} entries produce blank lines
+     * @param lineEnding the line separator to use, {@code null} is system default
+     * @param append     if {@code true}, then the lines will be added to the
+     *                   end of the file rather than overwriting
+     * @throws IOException in case of an I/O error
+     * @since 2.1
+     */
+    public static void writeLines(final File file, final Collection<?> lines, final String lineEnding, final boolean append) throws IOException {
+        writeLines(file, null, lines, lineEnding, append);
+    }
+
+    /**
+     * Writes the {@code toString()} value of each item in a collection to
+     * the specified {@link File} line by line.
+     * The specified character encoding and the default line ending will be used.
+     * <p>
+     * NOTE: As from v1.3, the parent directories of the file will be created
+     * if they do not exist.
+     * </p>
+     *
+     * @param file     the file to write to
+     * @param charsetName the name of the requested charset, {@code null} means platform default
+     * @param lines    the lines to write, {@code null} entries produce blank lines
+     * @throws IOException                          in case of an I/O error
+     * @throws java.io.UnsupportedEncodingException if the encoding is not supported by the VM
+     * @since 1.1
+     */
+    public static void writeLines(final File file, final String charsetName, final Collection<?> lines) throws IOException {
+        writeLines(file, charsetName, lines, null, false);
+    }
+
+    /**
+     * Writes the {@code toString()} value of each item in a collection to
+     * the specified {@link File} line by line, optionally appending.
+     * The specified character encoding and the default line ending will be used.
+     *
+     * @param file     the file to write to
+     * @param charsetName the name of the requested charset, {@code null} means platform default
+     * @param lines    the lines to write, {@code null} entries produce blank lines
+     * @param append   if {@code true}, then the lines will be added to the
+     *                 end of the file rather than overwriting
+     * @throws IOException                          in case of an I/O error
+     * @throws java.io.UnsupportedEncodingException if the encoding is not supported by the VM
+     * @since 2.1
+     */
+    public static void writeLines(final File file, final String charsetName, final Collection<?> lines, final boolean append) throws IOException {
+        writeLines(file, charsetName, lines, null, append);
+    }
+
+    /**
+     * Writes the {@code toString()} value of each item in a collection to
+     * the specified {@link File} line by line.
+     * The specified character encoding and the line ending will be used.
+     * <p>
+     * NOTE: As from v1.3, the parent directories of the file will be created
+     * if they do not exist.
+     * </p>
+     *
+     * @param file       the file to write to
+     * @param charsetName   the name of the requested charset, {@code null} means platform default
+     * @param lines      the lines to write, {@code null} entries produce blank lines
+     * @param lineEnding the line separator to use, {@code null} is system default
+     * @throws IOException                          in case of an I/O error
+     * @throws java.io.UnsupportedEncodingException if the encoding is not supported by the VM
+     * @since 1.1
+     */
+    public static void writeLines(final File file, final String charsetName, final Collection<?> lines, final String lineEnding) throws IOException {
+        writeLines(file, charsetName, lines, lineEnding, false);
+    }
+
+    /**
+     * Writes the {@code toString()} value of each item in a collection to
+     * the specified {@link File} line by line.
+     * The specified character encoding and the line ending will be used.
+     *
+     * @param file       the file to write to
+     * @param charsetName   the name of the requested charset, {@code null} means platform default
+     * @param lines      the lines to write, {@code null} entries produce blank lines
+     * @param lineEnding the line separator to use, {@code null} is system default
+     * @param append     if {@code true}, then the lines will be added to the
+     *                   end of the file rather than overwriting
+     * @throws IOException                          in case of an I/O error
+     * @throws java.io.UnsupportedEncodingException if the encoding is not supported by the VM
+     * @since 2.1
+     */
+    public static void writeLines(final File file, final String charsetName, final Collection<?> lines, final String lineEnding, final boolean append)
+        throws IOException {
+        try (OutputStream out = new BufferedOutputStream(newOutputStream(file, append))) {
+            IOUtils.writeLines(lines, lineEnding, out, charsetName);
+        }
+    }
+
+    /**
+     * Writes a String to a file creating the file if it does not exist using the default encoding for the VM.
+     *
+     * @param file the file to write
+     * @param data the content to write to the file
+     * @throws IOException in case of an I/O error
+     * @deprecated 2.5 use {@link #writeStringToFile(File, String, Charset)} instead (and specify the appropriate encoding)
+     */
+    @Deprecated
+    public static void writeStringToFile(final File file, final String data) throws IOException {
+        writeStringToFile(file, data, Charset.defaultCharset(), false);
+    }
+
+    /**
+     * Writes a String to a file creating the file if it does not exist using the default encoding for the VM.
+     *
+     * @param file   the file to write
+     * @param data   the content to write to the file
+     * @param append if {@code true}, then the String will be added to the
+     *               end of the file rather than overwriting
+     * @throws IOException in case of an I/O error
+     * @since 2.1
+     * @deprecated 2.5 use {@link #writeStringToFile(File, String, Charset, boolean)} instead (and specify the appropriate encoding)
+     */
+    @Deprecated
+    public static void writeStringToFile(final File file, final String data, final boolean append) throws IOException {
+        writeStringToFile(file, data, Charset.defaultCharset(), append);
+    }
+
+    /**
+     * Writes a String to a file creating the file if it does not exist.
+     * <p>
+     * NOTE: As from v1.3, the parent directories of the file will be created
+     * if they do not exist.
+     * </p>
+     *
+     * @param file     the file to write
+     * @param data     the content to write to the file
+     * @param charset the charset to use, {@code null} means platform default
+     * @throws IOException                          in case of an I/O error
+     * @throws java.io.UnsupportedEncodingException if the encoding is not supported by the VM
+     * @since 2.4
+     */
+    public static void writeStringToFile(final File file, final String data, final Charset charset) throws IOException {
+        writeStringToFile(file, data, charset, false);
+    }
+
+    /**
+     * Writes a String to a file creating the file if it does not exist.
+     *
+     * @param file     the file to write
+     * @param data     the content to write to the file
+     * @param charset the charset to use, {@code null} means platform default
+     * @param append   if {@code true}, then the String will be added to the
+     *                 end of the file rather than overwriting
+     * @throws IOException in case of an I/O error
+     * @since 2.3
+     */
+    public static void writeStringToFile(final File file, final String data, final Charset charset, final boolean append) throws IOException {
+        try (OutputStream out = newOutputStream(file, append)) {
+            IOUtils.write(data, out, charset);
+        }
+    }
+
+    /**
+     * Writes a String to a file creating the file if it does not exist.
+     * <p>
+     * NOTE: As from v1.3, the parent directories of the file will be created
+     * if they do not exist.
+     * </p>
+     *
+     * @param file     the file to write
+     * @param data     the content to write to the file
+     * @param charsetName the name of the requested charset, {@code null} means platform default
+     * @throws IOException                          in case of an I/O error
+     * @throws java.io.UnsupportedEncodingException if the encoding is not supported by the VM
+     */
+    public static void writeStringToFile(final File file, final String data, final String charsetName) throws IOException {
+        writeStringToFile(file, data, charsetName, false);
+    }
+
+    /**
+     * Writes a String to a file creating the file if it does not exist.
+     *
+     * @param file     the file to write
+     * @param data     the content to write to the file
+     * @param charsetName the name of the requested charset, {@code null} means platform default
+     * @param append   if {@code true}, then the String will be added to the
+     *                 end of the file rather than overwriting
+     * @throws IOException                 in case of an I/O error
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     * .UnsupportedEncodingException} in version 2.2 if the encoding is not supported by the VM
+     * @since 2.1
+     */
+    public static void writeStringToFile(final File file, final String data, final String charsetName, final boolean append) throws IOException {
+        writeStringToFile(file, data, Charsets.toCharset(charsetName), append);
+    }
+
+    /**
+     * Instances should NOT be constructed in standard programming.
+     * @deprecated Will be private in 3.0.
+     */
+    @Deprecated
+    public FileUtils() { //NOSONAR
+
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/FilenameUtils.java b/src/main/java/org/apache/commons/io/FilenameUtils.java
new file mode 100644
index 0000000..09c62f7
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/FilenameUtils.java
@@ -0,0 +1,1683 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.File;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Deque;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+/**
+ * General file name and file path manipulation utilities.
+ * <p>
+ * When dealing with file names you can hit problems when moving from a Windows
+ * based development machine to a Unix based production machine.
+ * This class aims to help avoid those problems.
+ * </p>
+ * <p>
+ * <b>NOTE</b>: You may be able to avoid using this class entirely simply by
+ * using JDK {@link java.io.File File} objects and the two argument constructor
+ * {@link java.io.File#File(java.io.File, String) File(File,String)}.
+ * </p>
+ * <p>
+ * Most methods on this class are designed to work the same on both Unix and Windows.
+ * Those that don't include 'System', 'Unix' or 'Windows' in their name.
+ * </p>
+ * <p>
+ * Most methods recognize both separators (forward and back), and both
+ * sets of prefixes. See the Javadoc of each method for details.
+ * </p>
+ * <p>
+ * This class defines six components within a file name
+ * (example C:\dev\project\file.txt):
+ * </p>
+ * <ul>
+ * <li>the prefix - C:\</li>
+ * <li>the path - dev\project\</li>
+ * <li>the full path - C:\dev\project\</li>
+ * <li>the name - file.txt</li>
+ * <li>the base name - file</li>
+ * <li>the extension - txt</li>
+ * </ul>
+ * <p>
+ * Note that this class works best if directory file names end with a separator.
+ * If you omit the last separator, it is impossible to determine if the file name
+ * corresponds to a file or a directory. As a result, we have chosen to say
+ * it corresponds to a file.
+ * </p>
+ * <p>
+ * This class only supports Unix and Windows style names.
+ * Prefixes are matched as follows:
+ * </p>
+ * <pre>
+ * Windows:
+ * a\b\c.txt           --&gt; ""          --&gt; relative
+ * \a\b\c.txt          --&gt; "\"         --&gt; current drive absolute
+ * C:a\b\c.txt         --&gt; "C:"        --&gt; drive relative
+ * C:\a\b\c.txt        --&gt; "C:\"       --&gt; absolute
+ * \\server\a\b\c.txt  --&gt; "\\server\" --&gt; UNC
+ *
+ * Unix:
+ * a/b/c.txt           --&gt; ""          --&gt; relative
+ * /a/b/c.txt          --&gt; "/"         --&gt; absolute
+ * ~/a/b/c.txt         --&gt; "~/"        --&gt; current user
+ * ~                   --&gt; "~/"        --&gt; current user (slash added)
+ * ~user/a/b/c.txt     --&gt; "~user/"    --&gt; named user
+ * ~user               --&gt; "~user/"    --&gt; named user (slash added)
+ * </pre>
+ * <p>
+ * Both prefix styles are matched always, irrespective of the machine that you are
+ * currently running on.
+ * </p>
+ * <p>
+ * Origin of code: Excalibur, Alexandria, Tomcat, Commons-Utils.
+ * </p>
+ *
+ * @since 1.1
+ */
+public class FilenameUtils {
+
+    private static final String[] EMPTY_STRING_ARRAY = {};
+
+    private static final String EMPTY_STRING = "";
+
+    private static final int NOT_FOUND = -1;
+
+    /**
+     * The extension separator character.
+     * @since 1.4
+     */
+    public static final char EXTENSION_SEPARATOR = '.';
+
+    /**
+     * The extension separator String.
+     * @since 1.4
+     */
+    public static final String EXTENSION_SEPARATOR_STR = Character.toString(EXTENSION_SEPARATOR);
+
+    /**
+     * The Unix separator character.
+     */
+    private static final char UNIX_NAME_SEPARATOR = '/';
+
+    /**
+     * The Windows separator character.
+     */
+    private static final char WINDOWS_NAME_SEPARATOR = '\\';
+
+    /**
+     * The system separator character.
+     */
+    private static final char SYSTEM_NAME_SEPARATOR = File.separatorChar;
+
+    /**
+     * The separator character that is the opposite of the system separator.
+     */
+    private static final char OTHER_SEPARATOR = flipSeparator(SYSTEM_NAME_SEPARATOR);
+
+    private static final Pattern IPV4_PATTERN = Pattern.compile("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$");
+
+    private static final int IPV4_MAX_OCTET_VALUE = 255;
+
+    private static final int IPV6_MAX_HEX_GROUPS = 8;
+
+    private static final int IPV6_MAX_HEX_DIGITS_PER_GROUP = 4;
+
+    private static final int MAX_UNSIGNED_SHORT = 0xffff;
+
+    private static final int BASE_16 = 16;
+
+    private static final Pattern REG_NAME_PART_PATTERN = Pattern.compile("^[a-zA-Z0-9][a-zA-Z0-9-]*$");
+
+    /**
+     * Concatenates a fileName to a base path using normal command line style rules.
+     * <p>
+     * The effect is equivalent to resultant directory after changing
+     * directory to the first argument, followed by changing directory to
+     * the second argument.
+     * </p>
+     * <p>
+     * The first argument is the base path, the second is the path to concatenate.
+     * The returned path is always normalized via {@link #normalize(String)},
+     * thus {@code ..} is handled.
+     * </p>
+     * <p>
+     * If {@code pathToAdd} is absolute (has an absolute prefix), then
+     * it will be normalized and returned.
+     * Otherwise, the paths will be joined, normalized and returned.
+     * </p>
+     * <p>
+     * The output will be the same on both Unix and Windows except
+     * for the separator character.
+     * </p>
+     * <pre>
+     * /foo/      + bar        --&gt;  /foo/bar
+     * /foo       + bar        --&gt;  /foo/bar
+     * /foo       + /bar       --&gt;  /bar
+     * /foo       + C:/bar     --&gt;  C:/bar
+     * /foo       + C:bar      --&gt;  C:bar [1]
+     * /foo/a/    + ../bar     --&gt;  /foo/bar
+     * /foo/      + ../../bar  --&gt;  null
+     * /foo/      + /bar       --&gt;  /bar
+     * /foo/..    + /bar       --&gt;  /bar
+     * /foo       + bar/c.txt  --&gt;  /foo/bar/c.txt
+     * /foo/c.txt + bar        --&gt;  /foo/c.txt/bar [2]
+     * </pre>
+     * <p>
+     * [1] Note that the Windows relative drive prefix is unreliable when
+     * used with this method.
+     * </p>
+     * <p>
+     * [2] Note that the first parameter must be a path. If it ends with a name, then
+     * the name will be built into the concatenated path. If this might be a problem,
+     * use {@link #getFullPath(String)} on the base path argument.
+     * </p>
+     *
+     * @param basePath  the base path to attach to, always treated as a path
+     * @param fullFileNameToAdd  the fileName (or path) to attach to the base
+     * @return the concatenated path, or null if invalid
+     * @throws IllegalArgumentException if the result path contains the null character ({@code U+0000})
+     */
+    public static String concat(final String basePath, final String fullFileNameToAdd) {
+        final int prefix = getPrefixLength(fullFileNameToAdd);
+        if (prefix < 0) {
+            return null;
+        }
+        if (prefix > 0) {
+            return normalize(fullFileNameToAdd);
+        }
+        if (basePath == null) {
+            return null;
+        }
+        final int len = basePath.length();
+        if (len == 0) {
+            return normalize(fullFileNameToAdd);
+        }
+        final char ch = basePath.charAt(len - 1);
+        if (isSeparator(ch)) {
+            return normalize(basePath + fullFileNameToAdd);
+        }
+        return normalize(basePath + '/' + fullFileNameToAdd);
+    }
+
+    /**
+     * Determines whether the {@code parent} directory contains the {@code child} element (a file or directory).
+     * <p>
+     * The files names are expected to be normalized.
+     * </p>
+     *
+     * Edge cases:
+     * <ul>
+     * <li>A {@code directory} must not be null: if null, throw IllegalArgumentException</li>
+     * <li>A directory does not contain itself: return false</li>
+     * <li>A null child file is not contained in any parent: return false</li>
+     * </ul>
+     *
+     * @param canonicalParent
+     *            the file to consider as the parent.
+     * @param canonicalChild
+     *            the file to consider as the child.
+     * @return true is the candidate leaf is under by the specified composite. False otherwise.
+     * @since 2.2
+     * @see FileUtils#directoryContains(File, File)
+     */
+    public static boolean directoryContains(final String canonicalParent, final String canonicalChild) {
+        if (isEmpty(canonicalParent) || isEmpty(canonicalChild)) {
+            return false;
+        }
+
+        if (IOCase.SYSTEM.checkEquals(canonicalParent, canonicalChild)) {
+            return false;
+        }
+
+        final char separator = toSeparator(canonicalParent.charAt(0) == UNIX_NAME_SEPARATOR);
+        final String parentWithEndSeparator = canonicalParent.charAt(canonicalParent.length() - 1) == separator ? canonicalParent : canonicalParent + separator;
+
+        return IOCase.SYSTEM.checkStartsWith(canonicalChild, parentWithEndSeparator);
+    }
+
+    /**
+     * Does the work of getting the path.
+     *
+     * @param fileName  the fileName
+     * @param includeSeparator  true to include the end separator
+     * @return the path
+     * @throws IllegalArgumentException if the result path contains the null character ({@code U+0000})
+     */
+    private static String doGetFullPath(final String fileName, final boolean includeSeparator) {
+        if (fileName == null) {
+            return null;
+        }
+        final int prefix = getPrefixLength(fileName);
+        if (prefix < 0) {
+            return null;
+        }
+        if (prefix >= fileName.length()) {
+            if (includeSeparator) {
+                return getPrefix(fileName);  // add end slash if necessary
+            }
+            return fileName;
+        }
+        final int index = indexOfLastSeparator(fileName);
+        if (index < 0) {
+            return fileName.substring(0, prefix);
+        }
+        int end = index + (includeSeparator ?  1 : 0);
+        if (end == 0) {
+            end++;
+        }
+        return fileName.substring(0, end);
+    }
+
+    /**
+     * Does the work of getting the path.
+     *
+     * @param fileName  the fileName
+     * @param separatorAdd  0 to omit the end separator, 1 to return it
+     * @return the path
+     * @throws IllegalArgumentException if the result path contains the null character ({@code U+0000})
+     */
+    private static String doGetPath(final String fileName, final int separatorAdd) {
+        if (fileName == null) {
+            return null;
+        }
+        final int prefix = getPrefixLength(fileName);
+        if (prefix < 0) {
+            return null;
+        }
+        final int index = indexOfLastSeparator(fileName);
+        final int endIndex = index + separatorAdd;
+        if (prefix >= fileName.length() || index < 0 || prefix >= endIndex) {
+            return EMPTY_STRING;
+        }
+        return requireNonNullChars(fileName.substring(prefix, endIndex));
+    }
+
+    /**
+     * Internal method to perform the normalization.
+     *
+     * @param fileName  the fileName
+     * @param separator The separator character to use
+     * @param keepSeparator  true to keep the final separator
+     * @return the normalized fileName
+     * @throws IllegalArgumentException if the fileName contains the null character ({@code U+0000})
+     */
+    private static String doNormalize(final String fileName, final char separator, final boolean keepSeparator) {
+        if (fileName == null) {
+            return null;
+        }
+
+        requireNonNullChars(fileName);
+
+        int size = fileName.length();
+        if (size == 0) {
+            return fileName;
+        }
+        final int prefix = getPrefixLength(fileName);
+        if (prefix < 0) {
+            return null;
+        }
+
+        final char[] array = new char[size + 2];  // +1 for possible extra slash, +2 for arraycopy
+        fileName.getChars(0, fileName.length(), array, 0);
+
+        // fix separators throughout
+        final char otherSeparator = flipSeparator(separator);
+        for (int i = 0; i < array.length; i++) {
+            if (array[i] == otherSeparator) {
+                array[i] = separator;
+            }
+        }
+
+        // add extra separator on the end to simplify code below
+        boolean lastIsDirectory = true;
+        if (array[size - 1] != separator) {
+            array[size++] = separator;
+            lastIsDirectory = false;
+        }
+
+        // adjoining slashes
+        // If we get here, prefix can only be 0 or greater, size 1 or greater
+        // If prefix is 0, set loop start to 1 to prevent index errors
+        for (int i = prefix != 0 ? prefix : 1; i < size; i++) {
+            if (array[i] == separator && array[i - 1] == separator) {
+                System.arraycopy(array, i, array, i - 1, size - i);
+                size--;
+                i--;
+            }
+        }
+
+        // dot slash
+        for (int i = prefix + 1; i < size; i++) {
+            if (array[i] == separator && array[i - 1] == '.' &&
+                    (i == prefix + 1 || array[i - 2] == separator)) {
+                if (i == size - 1) {
+                    lastIsDirectory = true;
+                }
+                System.arraycopy(array, i + 1, array, i - 1, size - i);
+                size -=2;
+                i--;
+            }
+        }
+
+        // double dot slash
+        outer:
+        for (int i = prefix + 2; i < size; i++) {
+            if (array[i] == separator && array[i - 1] == '.' && array[i - 2] == '.' &&
+                    (i == prefix + 2 || array[i - 3] == separator)) {
+                if (i == prefix + 2) {
+                    return null;
+                }
+                if (i == size - 1) {
+                    lastIsDirectory = true;
+                }
+                int j;
+                for (j = i - 4 ; j >= prefix; j--) {
+                    if (array[j] == separator) {
+                        // remove b/../ from a/b/../c
+                        System.arraycopy(array, i + 1, array, j + 1, size - i);
+                        size -= i - j;
+                        i = j + 1;
+                        continue outer;
+                    }
+                }
+                // remove a/../ from a/../c
+                System.arraycopy(array, i + 1, array, prefix, size - i);
+                size -= i + 1 - prefix;
+                i = prefix + 1;
+            }
+        }
+
+        if (size <= 0) {  // should never be less than 0
+            return EMPTY_STRING;
+        }
+        if (size <= prefix) {  // should never be less than prefix
+            return new String(array, 0, size);
+        }
+        if (lastIsDirectory && keepSeparator) {
+            return new String(array, 0, size);  // keep trailing separator
+        }
+        return new String(array, 0, size - 1);  // lose trailing separator
+    }
+
+    /**
+     * Checks whether two fileNames are equal exactly.
+     * <p>
+     * No processing is performed on the fileNames other than comparison,
+     * thus this is merely a null-safe case-sensitive equals.
+     * </p>
+     *
+     * @param fileName1  the first fileName to query, may be null
+     * @param fileName2  the second fileName to query, may be null
+     * @return true if the fileNames are equal, null equals null
+     * @see IOCase#SENSITIVE
+     */
+    public static boolean equals(final String fileName1, final String fileName2) {
+        return equals(fileName1, fileName2, false, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Checks whether two fileNames are equal, optionally normalizing and providing
+     * control over the case-sensitivity.
+     *
+     * @param fileName1  the first fileName to query, may be null
+     * @param fileName2  the second fileName to query, may be null
+     * @param normalize  whether to normalize the fileNames
+     * @param ioCase  what case sensitivity rule to use, null means case-sensitive
+     * @return true if the fileNames are equal, null equals null
+     * @since 1.3
+     */
+    public static boolean equals(String fileName1, String fileName2, final boolean normalize, final IOCase ioCase) {
+
+        if (fileName1 == null || fileName2 == null) {
+            return fileName1 == null && fileName2 == null;
+        }
+        if (normalize) {
+            fileName1 = normalize(fileName1);
+            if (fileName1 == null) {
+                return false;
+            }
+            fileName2 = normalize(fileName2);
+            if (fileName2 == null) {
+                return false;
+            }
+        }
+        return IOCase.value(ioCase, IOCase.SENSITIVE).checkEquals(fileName1, fileName2);
+    }
+
+    /**
+     * Checks whether two fileNames are equal after both have been normalized.
+     * <p>
+     * Both fileNames are first passed to {@link #normalize(String)}.
+     * The check is then performed in a case-sensitive manner.
+     * </p>
+     *
+     * @param fileName1  the first fileName to query, may be null
+     * @param fileName2  the second fileName to query, may be null
+     * @return true if the fileNames are equal, null equals null
+     * @see IOCase#SENSITIVE
+     */
+    public static boolean equalsNormalized(final String fileName1, final String fileName2) {
+        return equals(fileName1, fileName2, true, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Checks whether two fileNames are equal after both have been normalized
+     * and using the case rules of the system.
+     * <p>
+     * Both fileNames are first passed to {@link #normalize(String)}.
+     * The check is then performed case-sensitive on Unix and
+     * case-insensitive on Windows.
+     * </p>
+     *
+     * @param fileName1  the first fileName to query, may be null
+     * @param fileName2  the second fileName to query, may be null
+     * @return true if the fileNames are equal, null equals null
+     * @see IOCase#SYSTEM
+     */
+    public static boolean equalsNormalizedOnSystem(final String fileName1, final String fileName2) {
+        return equals(fileName1, fileName2, true, IOCase.SYSTEM);
+    }
+
+    /**
+     * Checks whether two fileNames are equal using the case rules of the system.
+     * <p>
+     * No processing is performed on the fileNames other than comparison.
+     * The check is case-sensitive on Unix and case-insensitive on Windows.
+     * </p>
+     *
+     * @param fileName1  the first fileName to query, may be null
+     * @param fileName2  the second fileName to query, may be null
+     * @return true if the fileNames are equal, null equals null
+     * @see IOCase#SYSTEM
+     */
+    public static boolean equalsOnSystem(final String fileName1, final String fileName2) {
+        return equals(fileName1, fileName2, false, IOCase.SYSTEM);
+    }
+
+    /**
+     * Flips the Windows name separator to Linux and vice-versa.
+     *
+     * @param ch The Windows or Linux name separator.
+     * @return The Windows or Linux name separator.
+     */
+    static char flipSeparator(final char ch) {
+        if (ch == UNIX_NAME_SEPARATOR) {
+            return WINDOWS_NAME_SEPARATOR;
+        }
+        if (ch == WINDOWS_NAME_SEPARATOR) {
+            return UNIX_NAME_SEPARATOR;
+        }
+        throw new IllegalArgumentException(String.valueOf(ch));
+    }
+
+    /**
+     * Special handling for NTFS ADS: Don't accept colon in the fileName.
+     *
+     * @param fileName a file name
+     * @return ADS offsets.
+     */
+    private static int getAdsCriticalOffset(final String fileName) {
+        // Step 1: Remove leading path segments.
+        final int offset1 = fileName.lastIndexOf(SYSTEM_NAME_SEPARATOR);
+        final int offset2 = fileName.lastIndexOf(OTHER_SEPARATOR);
+        if (offset1 == -1) {
+            if (offset2 == -1) {
+                return 0;
+            }
+            return offset2 + 1;
+        }
+        if (offset2 == -1) {
+            return offset1 + 1;
+        }
+        return Math.max(offset1, offset2) + 1;
+    }
+
+    /**
+     * Gets the base name, minus the full path and extension, from a full fileName.
+     * <p>
+     * This method will handle a file in either Unix or Windows format.
+     * The text after the last forward or backslash and before the last dot is returned.
+     * </p>
+     * <pre>
+     * a/b/c.txt --&gt; c
+     * a.txt     --&gt; a
+     * a/b/c     --&gt; c
+     * a/b/c/    --&gt; ""
+     * </pre>
+     * <p>
+     * The output will be the same irrespective of the machine that the code is running on.
+     * </p>
+     *
+     * @param fileName  the fileName to query, null returns null
+     * @return the name of the file without the path, or an empty string if none exists
+     * @throws IllegalArgumentException if the fileName contains the null character ({@code U+0000})
+     */
+    public static String getBaseName(final String fileName) {
+        return removeExtension(getName(fileName));
+    }
+
+    /**
+     * Gets the extension of a fileName.
+     * <p>
+     * This method returns the textual part of the fileName after the last dot.
+     * There must be no directory separator after the dot.
+     * </p>
+     * <pre>
+     * foo.txt      --&gt; "txt"
+     * a/b/c.jpg    --&gt; "jpg"
+     * a/b.txt/c    --&gt; ""
+     * a/b/c        --&gt; ""
+     * </pre>
+     * <p>
+     * The output will be the same irrespective of the machine that the code is running on, with the
+     * exception of a possible {@link IllegalArgumentException} on Windows (see below).
+     * </p>
+     * <p>
+     * <b>Note:</b> This method used to have a hidden problem for names like "foo.exe:bar.txt".
+     * In this case, the name wouldn't be the name of a file, but the identifier of an
+     * alternate data stream (bar.txt) on the file foo.exe. The method used to return
+     * ".txt" here, which would be misleading. Commons IO 2.7, and later versions, are throwing
+     * an {@link IllegalArgumentException} for names like this.
+     * </p>
+     *
+     * @param fileName the fileName to retrieve the extension of.
+     * @return the extension of the file or an empty string if none exists or {@code null}
+     * if the fileName is {@code null}.
+     * @throws IllegalArgumentException <b>Windows only:</b> The fileName parameter is, in fact,
+     * the identifier of an Alternate Data Stream, for example "foo.exe:bar.txt".
+     */
+    public static String getExtension(final String fileName) throws IllegalArgumentException {
+        if (fileName == null) {
+            return null;
+        }
+        final int index = indexOfExtension(fileName);
+        if (index == NOT_FOUND) {
+            return EMPTY_STRING;
+        }
+        return fileName.substring(index + 1);
+    }
+
+    /**
+     * Gets the full path from a full fileName, which is the prefix + path.
+     * <p>
+     * This method will handle a file in either Unix or Windows format.
+     * The method is entirely text based, and returns the text before and
+     * including the last forward or backslash.
+     * </p>
+     * <pre>
+     * C:\a\b\c.txt --&gt; C:\a\b\
+     * ~/a/b/c.txt  --&gt; ~/a/b/
+     * a.txt        --&gt; ""
+     * a/b/c        --&gt; a/b/
+     * a/b/c/       --&gt; a/b/c/
+     * C:           --&gt; C:
+     * C:\          --&gt; C:\
+     * ~            --&gt; ~/
+     * ~/           --&gt; ~/
+     * ~user        --&gt; ~user/
+     * ~user/       --&gt; ~user/
+     * </pre>
+     * <p>
+     * The output will be the same irrespective of the machine that the code is running on.
+     * </p>
+     *
+     * @param fileName  the fileName to query, null returns null
+     * @return the path of the file, an empty string if none exists, null if invalid
+     * @throws IllegalArgumentException if the result path contains the null character ({@code U+0000})
+     */
+    public static String getFullPath(final String fileName) {
+        return doGetFullPath(fileName, true);
+    }
+
+    /**
+     * Gets the full path from a full fileName, which is the prefix + path,
+     * and also excluding the final directory separator.
+     * <p>
+     * This method will handle a file in either Unix or Windows format.
+     * The method is entirely text based, and returns the text before the
+     * last forward or backslash.
+     * </p>
+     * <pre>
+     * C:\a\b\c.txt --&gt; C:\a\b
+     * ~/a/b/c.txt  --&gt; ~/a/b
+     * a.txt        --&gt; ""
+     * a/b/c        --&gt; a/b
+     * a/b/c/       --&gt; a/b/c
+     * C:           --&gt; C:
+     * C:\          --&gt; C:\
+     * ~            --&gt; ~
+     * ~/           --&gt; ~
+     * ~user        --&gt; ~user
+     * ~user/       --&gt; ~user
+     * </pre>
+     * <p>
+     * The output will be the same irrespective of the machine that the code is running on.
+     * </p>
+     *
+     * @param fileName  the fileName to query, null returns null
+     * @return the path of the file, an empty string if none exists, null if invalid
+     * @throws IllegalArgumentException if the result path contains the null character ({@code U+0000})
+     */
+    public static String getFullPathNoEndSeparator(final String fileName) {
+        return doGetFullPath(fileName, false);
+    }
+
+    /**
+     * Gets the name minus the path from a full fileName.
+     * <p>
+     * This method will handle a file in either Unix or Windows format.
+     * The text after the last forward or backslash is returned.
+     * </p>
+     * <pre>
+     * a/b/c.txt --&gt; c.txt
+     * a.txt     --&gt; a.txt
+     * a/b/c     --&gt; c
+     * a/b/c/    --&gt; ""
+     * </pre>
+     * <p>
+     * The output will be the same irrespective of the machine that the code is running on.
+     * </p>
+     *
+     * @param fileName  the fileName to query, null returns null
+     * @return the name of the file without the path, or an empty string if none exists
+     * @throws IllegalArgumentException if the fileName contains the null character ({@code U+0000})
+     */
+    public static String getName(final String fileName) {
+        if (fileName == null) {
+            return null;
+        }
+        return requireNonNullChars(fileName).substring(indexOfLastSeparator(fileName) + 1);
+    }
+
+    /**
+     * Gets the path from a full fileName, which excludes the prefix.
+     * <p>
+     * This method will handle a file in either Unix or Windows format.
+     * The method is entirely text based, and returns the text before and
+     * including the last forward or backslash.
+     * </p>
+     * <pre>
+     * C:\a\b\c.txt --&gt; a\b\
+     * ~/a/b/c.txt  --&gt; a/b/
+     * a.txt        --&gt; ""
+     * a/b/c        --&gt; a/b/
+     * a/b/c/       --&gt; a/b/c/
+     * </pre>
+     * <p>
+     * The output will be the same irrespective of the machine that the code is running on.
+     * </p>
+     * <p>
+     * This method drops the prefix from the result.
+     * See {@link #getFullPath(String)} for the method that retains the prefix.
+     * </p>
+     *
+     * @param fileName  the fileName to query, null returns null
+     * @return the path of the file, an empty string if none exists, null if invalid
+     * @throws IllegalArgumentException if the result path contains the null character ({@code U+0000})
+     */
+    public static String getPath(final String fileName) {
+        return doGetPath(fileName, 1);
+    }
+
+    /**
+     * Gets the path from a full fileName, which excludes the prefix, and
+     * also excluding the final directory separator.
+     * <p>
+     * This method will handle a file in either Unix or Windows format.
+     * The method is entirely text based, and returns the text before the
+     * last forward or backslash.
+     * </p>
+     * <pre>
+     * C:\a\b\c.txt --&gt; a\b
+     * ~/a/b/c.txt  --&gt; a/b
+     * a.txt        --&gt; ""
+     * a/b/c        --&gt; a/b
+     * a/b/c/       --&gt; a/b/c
+     * </pre>
+     * <p>
+     * The output will be the same irrespective of the machine that the code is running on.
+     * </p>
+     * <p>
+     * This method drops the prefix from the result.
+     * See {@link #getFullPathNoEndSeparator(String)} for the method that retains the prefix.
+     * </p>
+     *
+     * @param fileName  the fileName to query, null returns null
+     * @return the path of the file, an empty string if none exists, null if invalid
+     * @throws IllegalArgumentException if the result path contains the null character ({@code U+0000})
+     */
+    public static String getPathNoEndSeparator(final String fileName) {
+        return doGetPath(fileName, 0);
+    }
+
+    /**
+     * Gets the prefix from a full fileName, such as {@code C:/}
+     * or {@code ~/}.
+     * <p>
+     * This method will handle a file in either Unix or Windows format.
+     * The prefix includes the first slash in the full fileName where applicable.
+     * </p>
+     * <pre>
+     * Windows:
+     * a\b\c.txt           --&gt; ""          --&gt; relative
+     * \a\b\c.txt          --&gt; "\"         --&gt; current drive absolute
+     * C:a\b\c.txt         --&gt; "C:"        --&gt; drive relative
+     * C:\a\b\c.txt        --&gt; "C:\"       --&gt; absolute
+     * \\server\a\b\c.txt  --&gt; "\\server\" --&gt; UNC
+     *
+     * Unix:
+     * a/b/c.txt           --&gt; ""          --&gt; relative
+     * /a/b/c.txt          --&gt; "/"         --&gt; absolute
+     * ~/a/b/c.txt         --&gt; "~/"        --&gt; current user
+     * ~                   --&gt; "~/"        --&gt; current user (slash added)
+     * ~user/a/b/c.txt     --&gt; "~user/"    --&gt; named user
+     * ~user               --&gt; "~user/"    --&gt; named user (slash added)
+     * </pre>
+     * <p>
+     * The output will be the same irrespective of the machine that the code is running on.
+     * ie. both Unix and Windows prefixes are matched regardless.
+     * </p>
+     *
+     * @param fileName  the fileName to query, null returns null
+     * @return the prefix of the file, null if invalid
+     * @throws IllegalArgumentException if the result contains the null character ({@code U+0000})
+     */
+    public static String getPrefix(final String fileName) {
+        if (fileName == null) {
+            return null;
+        }
+        final int len = getPrefixLength(fileName);
+        if (len < 0) {
+            return null;
+        }
+        if (len > fileName.length()) {
+            requireNonNullChars(fileName);
+            return fileName + UNIX_NAME_SEPARATOR;
+        }
+        return requireNonNullChars(fileName.substring(0, len));
+    }
+
+    /**
+     * Returns the length of the fileName prefix, such as {@code C:/} or {@code ~/}.
+     * <p>
+     * This method will handle a file in either Unix or Windows format.
+     * </p>
+     * <p>
+     * The prefix length includes the first slash in the full fileName
+     * if applicable. Thus, it is possible that the length returned is greater
+     * than the length of the input string.
+     * </p>
+     * <pre>
+     * Windows:
+     * a\b\c.txt           --&gt; 0           --&gt; relative
+     * \a\b\c.txt          --&gt; 1           --&gt; current drive absolute
+     * C:a\b\c.txt         --&gt; 2           --&gt; drive relative
+     * C:\a\b\c.txt        --&gt; 3           --&gt; absolute
+     * \\server\a\b\c.txt  --&gt; 9           --&gt; UNC
+     * \\\a\b\c.txt        --&gt; -1          --&gt; error
+     *
+     * Unix:
+     * a/b/c.txt           --&gt; 0           --&gt; relative
+     * /a/b/c.txt          --&gt; 1           --&gt; absolute
+     * ~/a/b/c.txt         --&gt; 2           --&gt; current user
+     * ~                   --&gt; 2           --&gt; current user (slash added)
+     * ~user/a/b/c.txt     --&gt; 6           --&gt; named user
+     * ~user               --&gt; 6           --&gt; named user (slash added)
+     * //server/a/b/c.txt  --&gt; 9
+     * ///a/b/c.txt        --&gt; -1          --&gt; error
+     * C:                  --&gt; 0           --&gt; valid filename as only null character and / are reserved characters
+     * </pre>
+     * <p>
+     * The output will be the same irrespective of the machine that the code is running on.
+     * ie. both Unix and Windows prefixes are matched regardless.
+     * </p>
+     * <p>
+     * Note that a leading // (or \\) is used to indicate a UNC name on Windows.
+     * These must be followed by a server name, so double-slashes are not collapsed
+     * to a single slash at the start of the fileName.
+     * </p>
+     *
+     * @param fileName  the fileName to find the prefix in, null returns -1
+     * @return the length of the prefix, -1 if invalid or null
+     */
+    public static int getPrefixLength(final String fileName) {
+        if (fileName == null) {
+            return NOT_FOUND;
+        }
+        final int len = fileName.length();
+        if (len == 0) {
+            return 0;
+        }
+        char ch0 = fileName.charAt(0);
+        if (ch0 == ':') {
+            return NOT_FOUND;
+        }
+        if (len == 1) {
+            if (ch0 == '~') {
+                return 2;  // return a length greater than the input
+            }
+            return isSeparator(ch0) ? 1 : 0;
+        }
+        if (ch0 == '~') {
+            int posUnix = fileName.indexOf(UNIX_NAME_SEPARATOR, 1);
+            int posWin = fileName.indexOf(WINDOWS_NAME_SEPARATOR, 1);
+            if (posUnix == NOT_FOUND && posWin == NOT_FOUND) {
+                return len + 1;  // return a length greater than the input
+            }
+            posUnix = posUnix == NOT_FOUND ? posWin : posUnix;
+            posWin = posWin == NOT_FOUND ? posUnix : posWin;
+            return Math.min(posUnix, posWin) + 1;
+        }
+        final char ch1 = fileName.charAt(1);
+        if (ch1 == ':') {
+            ch0 = Character.toUpperCase(ch0);
+            if (ch0 >= 'A' && ch0 <= 'Z') {
+                if (len == 2 && !FileSystem.getCurrent().supportsDriveLetter()) {
+                    return 0;
+                }
+                if (len == 2 || !isSeparator(fileName.charAt(2))) {
+                    return 2;
+                }
+                return 3;
+            }
+            if (ch0 == UNIX_NAME_SEPARATOR) {
+                return 1;
+            }
+            return NOT_FOUND;
+
+        }
+        if (!isSeparator(ch0) || !isSeparator(ch1)) {
+            return isSeparator(ch0) ? 1 : 0;
+        }
+        int posUnix = fileName.indexOf(UNIX_NAME_SEPARATOR, 2);
+        int posWin = fileName.indexOf(WINDOWS_NAME_SEPARATOR, 2);
+        if (posUnix == NOT_FOUND && posWin == NOT_FOUND || posUnix == 2 || posWin == 2) {
+            return NOT_FOUND;
+        }
+        posUnix = posUnix == NOT_FOUND ? posWin : posUnix;
+        posWin = posWin == NOT_FOUND ? posUnix : posWin;
+        final int pos = Math.min(posUnix, posWin) + 1;
+        final String hostnamePart = fileName.substring(2, pos - 1);
+        return isValidHostName(hostnamePart) ? pos : NOT_FOUND;
+    }
+
+    /**
+     * Returns the index of the last extension separator character, which is a dot.
+     * <p>
+     * This method also checks that there is no directory separator after the last dot. To do this it uses
+     * {@link #indexOfLastSeparator(String)} which will handle a file in either Unix or Windows format.
+     * </p>
+     * <p>
+     * The output will be the same irrespective of the machine that the code is running on, with the
+     * exception of a possible {@link IllegalArgumentException} on Windows (see below).
+     * </p>
+     * <b>Note:</b> This method used to have a hidden problem for names like "foo.exe:bar.txt".
+     * In this case, the name wouldn't be the name of a file, but the identifier of an
+     * alternate data stream (bar.txt) on the file foo.exe. The method used to return
+     * ".txt" here, which would be misleading. Commons IO 2.7, and later versions, are throwing
+     * an {@link IllegalArgumentException} for names like this.
+     *
+     * @param fileName
+     *            the fileName to find the last extension separator in, null returns -1
+     * @return the index of the last extension separator character, or -1 if there is no such character
+     * @throws IllegalArgumentException <b>Windows only:</b> The fileName parameter is, in fact,
+     * the identifier of an Alternate Data Stream, for example "foo.exe:bar.txt".
+     */
+    public static int indexOfExtension(final String fileName) throws IllegalArgumentException {
+        if (fileName == null) {
+            return NOT_FOUND;
+        }
+        if (isSystemWindows()) {
+            // Special handling for NTFS ADS: Don't accept colon in the fileName.
+            final int offset = fileName.indexOf(':', getAdsCriticalOffset(fileName));
+            if (offset != -1) {
+                throw new IllegalArgumentException("NTFS ADS separator (':') in file name is forbidden.");
+            }
+        }
+        final int extensionPos = fileName.lastIndexOf(EXTENSION_SEPARATOR);
+        final int lastSeparator = indexOfLastSeparator(fileName);
+        return lastSeparator > extensionPos ? NOT_FOUND : extensionPos;
+    }
+
+    /**
+     * Returns the index of the last directory separator character.
+     * <p>
+     * This method will handle a file in either Unix or Windows format.
+     * The position of the last forward or backslash is returned.
+     * <p>
+     * The output will be the same irrespective of the machine that the code is running on.
+     *
+     * @param fileName  the fileName to find the last path separator in, null returns -1
+     * @return the index of the last separator character, or -1 if there
+     * is no such character
+     */
+    public static int indexOfLastSeparator(final String fileName) {
+        if (fileName == null) {
+            return NOT_FOUND;
+        }
+        final int lastUnixPos = fileName.lastIndexOf(UNIX_NAME_SEPARATOR);
+        final int lastWindowsPos = fileName.lastIndexOf(WINDOWS_NAME_SEPARATOR);
+        return Math.max(lastUnixPos, lastWindowsPos);
+    }
+
+    private static boolean isEmpty(final String string) {
+        return string == null || string.isEmpty();
+    }
+
+    /**
+     * Checks whether the extension of the fileName is one of those specified.
+     * <p>
+     * This method obtains the extension as the textual part of the fileName
+     * after the last dot. There must be no directory separator after the dot.
+     * The extension check is case-sensitive on all platforms.
+     *
+     * @param fileName  the fileName to query, null returns false
+     * @param extensions  the extensions to check for, null checks for no extension
+     * @return true if the fileName is one of the extensions
+     * @throws IllegalArgumentException if the fileName contains the null character ({@code U+0000})
+     */
+    public static boolean isExtension(final String fileName, final Collection<String> extensions) {
+        if (fileName == null) {
+            return false;
+        }
+        requireNonNullChars(fileName);
+
+        if (extensions == null || extensions.isEmpty()) {
+            return indexOfExtension(fileName) == NOT_FOUND;
+        }
+        return extensions.contains(getExtension(fileName));
+    }
+
+    /**
+     * Checks whether the extension of the fileName is that specified.
+     * <p>
+     * This method obtains the extension as the textual part of the fileName
+     * after the last dot. There must be no directory separator after the dot.
+     * The extension check is case-sensitive on all platforms.
+     *
+     * @param fileName  the fileName to query, null returns false
+     * @param extension  the extension to check for, null or empty checks for no extension
+     * @return true if the fileName has the specified extension
+     * @throws IllegalArgumentException if the fileName contains the null character ({@code U+0000})
+     */
+    public static boolean isExtension(final String fileName, final String extension) {
+        if (fileName == null) {
+            return false;
+        }
+        requireNonNullChars(fileName);
+
+        if (isEmpty(extension)) {
+            return indexOfExtension(fileName) == NOT_FOUND;
+        }
+        return getExtension(fileName).equals(extension);
+    }
+
+    /**
+     * Checks whether the extension of the fileName is one of those specified.
+     * <p>
+     * This method obtains the extension as the textual part of the fileName
+     * after the last dot. There must be no directory separator after the dot.
+     * The extension check is case-sensitive on all platforms.
+     *
+     * @param fileName  the fileName to query, null returns false
+     * @param extensions  the extensions to check for, null checks for no extension
+     * @return true if the fileName is one of the extensions
+     * @throws IllegalArgumentException if the fileName contains the null character ({@code U+0000})
+     */
+    public static boolean isExtension(final String fileName, final String... extensions) {
+        if (fileName == null) {
+            return false;
+        }
+        requireNonNullChars(fileName);
+
+        if (extensions == null || extensions.length == 0) {
+            return indexOfExtension(fileName) == NOT_FOUND;
+        }
+        final String fileExt = getExtension(fileName);
+        return Stream.of(extensions).anyMatch(fileExt::equals);
+    }
+
+    /**
+     * Checks whether a given string represents a valid IPv4 address.
+     *
+     * @param name the name to validate
+     * @return true if the given name is a valid IPv4 address
+     */
+    // mostly copied from org.apache.commons.validator.routines.InetAddressValidator#isValidInet4Address
+    private static boolean isIPv4Address(final String name) {
+        final Matcher m = IPV4_PATTERN.matcher(name);
+        if (!m.matches() || m.groupCount() != 4) {
+            return false;
+        }
+
+        // verify that address subgroups are legal
+        for (int i = 1; i <= 4; i++) {
+            final String ipSegment = m.group(i);
+            final int iIpSegment = Integer.parseInt(ipSegment);
+            if (iIpSegment > IPV4_MAX_OCTET_VALUE) {
+                return false;
+            }
+
+            if (ipSegment.length() > 1 && ipSegment.startsWith("0")) {
+                return false;
+            }
+
+        }
+
+        return true;
+    }
+
+    // copied from org.apache.commons.validator.routines.InetAddressValidator#isValidInet6Address
+    /**
+     * Checks whether a given string represents a valid IPv6 address.
+     *
+     * @param inet6Address the name to validate
+     * @return true if the given name is a valid IPv6 address
+     */
+    private static boolean isIPv6Address(final String inet6Address) {
+        final boolean containsCompressedZeroes = inet6Address.contains("::");
+        if (containsCompressedZeroes && inet6Address.indexOf("::") != inet6Address.lastIndexOf("::")) {
+            return false;
+        }
+        if (inet6Address.startsWith(":") && !inet6Address.startsWith("::")
+                || inet6Address.endsWith(":") && !inet6Address.endsWith("::")) {
+            return false;
+        }
+        String[] octets = inet6Address.split(":");
+        if (containsCompressedZeroes) {
+            final List<String> octetList = new ArrayList<>(Arrays.asList(octets));
+            if (inet6Address.endsWith("::")) {
+                // String.split() drops ending empty segments
+                octetList.add("");
+            } else if (inet6Address.startsWith("::") && !octetList.isEmpty()) {
+                octetList.remove(0);
+            }
+            octets = octetList.toArray(EMPTY_STRING_ARRAY);
+        }
+        if (octets.length > IPV6_MAX_HEX_GROUPS) {
+            return false;
+        }
+        int validOctets = 0;
+        int emptyOctets = 0; // consecutive empty chunks
+        for (int index = 0; index < octets.length; index++) {
+            final String octet = octets[index];
+            if (octet.isEmpty()) {
+                emptyOctets++;
+                if (emptyOctets > 1) {
+                    return false;
+                }
+            } else {
+                emptyOctets = 0;
+                // Is last chunk an IPv4 address?
+                if (index == octets.length - 1 && octet.contains(".")) {
+                    if (!isIPv4Address(octet)) {
+                        return false;
+                    }
+                    validOctets += 2;
+                    continue;
+                }
+                if (octet.length() > IPV6_MAX_HEX_DIGITS_PER_GROUP) {
+                    return false;
+                }
+                final int octetInt;
+                try {
+                    octetInt = Integer.parseInt(octet, BASE_16);
+                } catch (final NumberFormatException e) {
+                    return false;
+                }
+                if (octetInt < 0 || octetInt > MAX_UNSIGNED_SHORT) {
+                    return false;
+                }
+            }
+            validOctets++;
+        }
+        return validOctets <= IPV6_MAX_HEX_GROUPS && (validOctets >= IPV6_MAX_HEX_GROUPS || containsCompressedZeroes);
+    }
+
+    /**
+     * Checks whether a given string is a valid host name according to
+     * RFC 3986 - not accepting IP addresses.
+     *
+     * @see "https://tools.ietf.org/html/rfc3986#section-3.2.2"
+     * @param name the hostname to validate
+     * @return true if the given name is a valid host name
+     */
+    private static boolean isRFC3986HostName(final String name) {
+        final String[] parts = name.split("\\.", -1);
+        for (int i = 0; i < parts.length; i++) {
+            if (parts[i].isEmpty()) {
+                // trailing dot is legal, otherwise we've hit a .. sequence
+                return i == parts.length - 1;
+            }
+            if (!REG_NAME_PART_PATTERN.matcher(parts[i]).matches()) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Checks if the character is a separator.
+     *
+     * @param ch  the character to check
+     * @return true if it is a separator character
+     */
+    private static boolean isSeparator(final char ch) {
+        return ch == UNIX_NAME_SEPARATOR || ch == WINDOWS_NAME_SEPARATOR;
+    }
+
+    /**
+     * Determines if Windows file system is in use.
+     *
+     * @return true if the system is Windows
+     */
+    static boolean isSystemWindows() {
+        return SYSTEM_NAME_SEPARATOR == WINDOWS_NAME_SEPARATOR;
+    }
+
+    /**
+     * Checks whether a given string is a valid host name according to
+     * RFC 3986.
+     *
+     * <p>Accepted are IP addresses (v4 and v6) as well as what the
+     * RFC calls a "reg-name". Percent encoded names don't seem to be
+     * valid names in UNC paths.</p>
+     *
+     * @see "https://tools.ietf.org/html/rfc3986#section-3.2.2"
+     * @param name the hostname to validate
+     * @return true if the given name is a valid host name
+     */
+    private static boolean isValidHostName(final String name) {
+        return isIPv6Address(name) || isRFC3986HostName(name);
+    }
+
+    /**
+     * Normalizes a path, removing double and single dot path steps.
+     * <p>
+     * This method normalizes a path to a standard format.
+     * The input may contain separators in either Unix or Windows format.
+     * The output will contain separators in the format of the system.
+     * <p>
+     * A trailing slash will be retained.
+     * A double slash will be merged to a single slash (but UNC names are handled).
+     * A single dot path segment will be removed.
+     * A double dot will cause that path segment and the one before to be removed.
+     * If the double dot has no parent path segment to work with, {@code null}
+     * is returned.
+     * <p>
+     * The output will be the same on both Unix and Windows except
+     * for the separator character.
+     * <pre>
+     * /foo//               --&gt;   /foo/
+     * /foo/./              --&gt;   /foo/
+     * /foo/../bar          --&gt;   /bar
+     * /foo/../bar/         --&gt;   /bar/
+     * /foo/../bar/../baz   --&gt;   /baz
+     * //foo//./bar         --&gt;   //foo/bar
+     * /../                 --&gt;   null
+     * ../foo               --&gt;   null
+     * foo/bar/..           --&gt;   foo/
+     * foo/../../bar        --&gt;   null
+     * foo/../bar           --&gt;   bar
+     * //server/foo/../bar  --&gt;   //server/bar
+     * //server/../bar      --&gt;   null
+     * C:\foo\..\bar        --&gt;   C:\bar
+     * C:\..\bar            --&gt;   null
+     * ~/foo/../bar/        --&gt;   ~/bar/
+     * ~/../bar             --&gt;   null
+     * </pre>
+     * (Note the file separator returned will be correct for Windows/Unix)
+     *
+     * @param fileName  the fileName to normalize, null returns null
+     * @return the normalized fileName, or null if invalid
+     * @throws IllegalArgumentException if the fileName contains the null character ({@code U+0000})
+     */
+    public static String normalize(final String fileName) {
+        return doNormalize(fileName, SYSTEM_NAME_SEPARATOR, true);
+    }
+
+    /**
+     * Normalizes a path, removing double and single dot path steps.
+     * <p>
+     * This method normalizes a path to a standard format.
+     * The input may contain separators in either Unix or Windows format.
+     * The output will contain separators in the format specified.
+     * <p>
+     * A trailing slash will be retained.
+     * A double slash will be merged to a single slash (but UNC names are handled).
+     * A single dot path segment will be removed.
+     * A double dot will cause that path segment and the one before to be removed.
+     * If the double dot has no parent path segment to work with, {@code null}
+     * is returned.
+     * <p>
+     * The output will be the same on both Unix and Windows except
+     * for the separator character.
+     * <pre>
+     * /foo//               --&gt;   /foo/
+     * /foo/./              --&gt;   /foo/
+     * /foo/../bar          --&gt;   /bar
+     * /foo/../bar/         --&gt;   /bar/
+     * /foo/../bar/../baz   --&gt;   /baz
+     * //foo//./bar         --&gt;   /foo/bar
+     * /../                 --&gt;   null
+     * ../foo               --&gt;   null
+     * foo/bar/..           --&gt;   foo/
+     * foo/../../bar        --&gt;   null
+     * foo/../bar           --&gt;   bar
+     * //server/foo/../bar  --&gt;   //server/bar
+     * //server/../bar      --&gt;   null
+     * C:\foo\..\bar        --&gt;   C:\bar
+     * C:\..\bar            --&gt;   null
+     * ~/foo/../bar/        --&gt;   ~/bar/
+     * ~/../bar             --&gt;   null
+     * </pre>
+     * The output will be the same on both Unix and Windows including
+     * the separator character.
+     *
+     * @param fileName  the fileName to normalize, null returns null
+     * @param unixSeparator {@code true} if a Unix separator should
+     * be used or {@code false} if a Windows separator should be used.
+     * @return the normalized fileName, or null if invalid
+     * @throws IllegalArgumentException if the fileName contains the null character ({@code U+0000})
+     * @since 2.0
+     */
+    public static String normalize(final String fileName, final boolean unixSeparator) {
+        return doNormalize(fileName, toSeparator(unixSeparator), true);
+    }
+
+    /**
+     * Normalizes a path, removing double and single dot path steps,
+     * and removing any final directory separator.
+     * <p>
+     * This method normalizes a path to a standard format.
+     * The input may contain separators in either Unix or Windows format.
+     * The output will contain separators in the format of the system.
+     * <p>
+     * A trailing slash will be removed.
+     * A double slash will be merged to a single slash (but UNC names are handled).
+     * A single dot path segment will be removed.
+     * A double dot will cause that path segment and the one before to be removed.
+     * If the double dot has no parent path segment to work with, {@code null}
+     * is returned.
+     * <p>
+     * The output will be the same on both Unix and Windows except
+     * for the separator character.
+     * <pre>
+     * /foo//               --&gt;   /foo
+     * /foo/./              --&gt;   /foo
+     * /foo/../bar          --&gt;   /bar
+     * /foo/../bar/         --&gt;   /bar
+     * /foo/../bar/../baz   --&gt;   /baz
+     * //foo//./bar         --&gt;   /foo/bar
+     * /../                 --&gt;   null
+     * ../foo               --&gt;   null
+     * foo/bar/..           --&gt;   foo
+     * foo/../../bar        --&gt;   null
+     * foo/../bar           --&gt;   bar
+     * //server/foo/../bar  --&gt;   //server/bar
+     * //server/../bar      --&gt;   null
+     * C:\foo\..\bar        --&gt;   C:\bar
+     * C:\..\bar            --&gt;   null
+     * ~/foo/../bar/        --&gt;   ~/bar
+     * ~/../bar             --&gt;   null
+     * </pre>
+     * (Note the file separator returned will be correct for Windows/Unix)
+     *
+     * @param fileName  the fileName to normalize, null returns null
+     * @return the normalized fileName, or null if invalid
+     * @throws IllegalArgumentException if the fileName contains the null character ({@code U+0000})
+     */
+    public static String normalizeNoEndSeparator(final String fileName) {
+        return doNormalize(fileName, SYSTEM_NAME_SEPARATOR, false);
+    }
+
+    /**
+     * Normalizes a path, removing double and single dot path steps,
+     * and removing any final directory separator.
+     * <p>
+     * This method normalizes a path to a standard format.
+     * The input may contain separators in either Unix or Windows format.
+     * The output will contain separators in the format specified.
+     * <p>
+     * A trailing slash will be removed.
+     * A double slash will be merged to a single slash (but UNC names are handled).
+     * A single dot path segment will be removed.
+     * A double dot will cause that path segment and the one before to be removed.
+     * If the double dot has no parent path segment to work with, {@code null}
+     * is returned.
+     * <p>
+     * The output will be the same on both Unix and Windows including
+     * the separator character.
+     * <pre>
+     * /foo//               --&gt;   /foo
+     * /foo/./              --&gt;   /foo
+     * /foo/../bar          --&gt;   /bar
+     * /foo/../bar/         --&gt;   /bar
+     * /foo/../bar/../baz   --&gt;   /baz
+     * //foo//./bar         --&gt;   /foo/bar
+     * /../                 --&gt;   null
+     * ../foo               --&gt;   null
+     * foo/bar/..           --&gt;   foo
+     * foo/../../bar        --&gt;   null
+     * foo/../bar           --&gt;   bar
+     * //server/foo/../bar  --&gt;   //server/bar
+     * //server/../bar      --&gt;   null
+     * C:\foo\..\bar        --&gt;   C:\bar
+     * C:\..\bar            --&gt;   null
+     * ~/foo/../bar/        --&gt;   ~/bar
+     * ~/../bar             --&gt;   null
+     * </pre>
+     *
+     * @param fileName  the fileName to normalize, null returns null
+     * @param unixSeparator {@code true} if a Unix separator should
+     * be used or {@code false} if a Windows separator should be used.
+     * @return the normalized fileName, or null if invalid
+     * @throws IllegalArgumentException if the fileName contains the null character ({@code U+0000})
+     * @since 2.0
+     */
+    public static String normalizeNoEndSeparator(final String fileName, final boolean unixSeparator) {
+         return doNormalize(fileName, toSeparator(unixSeparator), false);
+    }
+
+    /**
+     * Removes the extension from a fileName.
+     * <p>
+     * This method returns the textual part of the fileName before the last dot.
+     * There must be no directory separator after the dot.
+     * <pre>
+     * foo.txt    --&gt; foo
+     * a\b\c.jpg  --&gt; a\b\c
+     * a\b\c      --&gt; a\b\c
+     * a.b\c      --&gt; a.b\c
+     * </pre>
+     * <p>
+     * The output will be the same irrespective of the machine that the code is running on.
+     *
+     * @param fileName  the fileName to query, null returns null
+     * @return the fileName minus the extension
+     * @throws IllegalArgumentException if the fileName contains the null character ({@code U+0000})
+     */
+    public static String removeExtension(final String fileName) {
+        if (fileName == null) {
+            return null;
+        }
+        requireNonNullChars(fileName);
+
+        final int index = indexOfExtension(fileName);
+        if (index == NOT_FOUND) {
+            return fileName;
+        }
+        return fileName.substring(0, index);
+    }
+
+    /**
+     * Checks the input for null characters ({@code U+0000}), a sign of unsanitized data being passed to file level functions.
+     *
+     * This may be used for poison byte attacks.
+     *
+     * @param path the path to check
+     * @return The input
+     * @throws IllegalArgumentException if path contains the null character ({@code U+0000})
+     */
+    private static String requireNonNullChars(final String path) {
+        if (path.indexOf(0) >= 0) {
+            throw new IllegalArgumentException(
+                "Null character present in file/path name. There are no known legitimate use cases for such data, but several injection attacks may use it");
+        }
+        return path;
+    }
+
+    /**
+     * Converts all separators to the system separator.
+     *
+     * @param path the path to be changed, null ignored.
+     * @return the updated path.
+     */
+    public static String separatorsToSystem(final String path) {
+        return FileSystem.getCurrent().normalizeSeparators(path);
+    }
+
+    /**
+     * Converts all separators to the Unix separator of forward slash.
+     *
+     * @param path the path to be changed, null ignored.
+     * @return the new path.
+     */
+    public static String separatorsToUnix(final String path) {
+        return FileSystem.LINUX.normalizeSeparators(path);
+    }
+
+    /**
+     * Converts all separators to the Windows separator of backslash.
+     *
+     * @param path the path to be changed, null ignored.
+     * @return the updated path.
+     */
+    public static String separatorsToWindows(final String path) {
+        return FileSystem.WINDOWS.normalizeSeparators(path);
+    }
+
+    /**
+     * Splits a string into a number of tokens.
+     * The text is split by '?' and '*'.
+     * Where multiple '*' occur consecutively they are collapsed into a single '*'.
+     *
+     * @param text  the text to split
+     * @return the array of tokens, never null
+     */
+    static String[] splitOnTokens(final String text) {
+        // used by wildcardMatch
+        // package level so a unit test may run on this
+
+        if (text.indexOf('?') == NOT_FOUND && text.indexOf('*') == NOT_FOUND) {
+            return new String[] { text };
+        }
+
+        final char[] array = text.toCharArray();
+        final ArrayList<String> list = new ArrayList<>();
+        final StringBuilder buffer = new StringBuilder();
+        char prevChar = 0;
+        for (final char ch : array) {
+            if (ch == '?' || ch == '*') {
+                if (buffer.length() != 0) {
+                    list.add(buffer.toString());
+                    buffer.setLength(0);
+                }
+                if (ch == '?') {
+                    list.add("?");
+                } else if (prevChar != '*') {// ch == '*' here; check if previous char was '*'
+                    list.add("*");
+                }
+            } else {
+                buffer.append(ch);
+            }
+            prevChar = ch;
+        }
+        if (buffer.length() != 0) {
+            list.add(buffer.toString());
+        }
+
+        return list.toArray(EMPTY_STRING_ARRAY);
+    }
+
+    /**
+     * Returns '/' if given true, '\\' otherwise.
+     *
+     * @param unixSeparator which separator to return.
+     * @return '/' if given true, '\\' otherwise.
+     */
+    private static char toSeparator(final boolean unixSeparator) {
+        return unixSeparator ? UNIX_NAME_SEPARATOR : WINDOWS_NAME_SEPARATOR;
+    }
+
+    /**
+     * Checks a fileName to see if it matches the specified wildcard matcher,
+     * always testing case-sensitive.
+     * <p>
+     * The wildcard matcher uses the characters '?' and '*' to represent a
+     * single or multiple (zero or more) wildcard characters.
+     * This is the same as often found on DOS/Unix command lines.
+     * The check is case-sensitive always.
+     * <pre>
+     * wildcardMatch("c.txt", "*.txt")      --&gt; true
+     * wildcardMatch("c.txt", "*.jpg")      --&gt; false
+     * wildcardMatch("a/b/c.txt", "a/b/*")  --&gt; true
+     * wildcardMatch("c.txt", "*.???")      --&gt; true
+     * wildcardMatch("c.txt", "*.????")     --&gt; false
+     * </pre>
+     * N.B. the sequence "*?" does not work properly at present in match strings.
+     *
+     * @param fileName  the fileName to match on
+     * @param wildcardMatcher  the wildcard string to match against
+     * @return true if the fileName matches the wildcard string
+     * @see IOCase#SENSITIVE
+     */
+    public static boolean wildcardMatch(final String fileName, final String wildcardMatcher) {
+        return wildcardMatch(fileName, wildcardMatcher, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Checks a fileName to see if it matches the specified wildcard matcher
+     * allowing control over case-sensitivity.
+     * <p>
+     * The wildcard matcher uses the characters '?' and '*' to represent a
+     * single or multiple (zero or more) wildcard characters.
+     * N.B. the sequence "*?" does not work properly at present in match strings.
+     *
+     * @param fileName  the fileName to match on
+     * @param wildcardMatcher  the wildcard string to match against
+     * @param ioCase  what case sensitivity rule to use, null means case-sensitive
+     * @return true if the fileName matches the wildcard string
+     * @since 1.3
+     */
+    public static boolean wildcardMatch(final String fileName, final String wildcardMatcher, IOCase ioCase) {
+        if (fileName == null && wildcardMatcher == null) {
+            return true;
+        }
+        if (fileName == null || wildcardMatcher == null) {
+            return false;
+        }
+        ioCase = IOCase.value(ioCase, IOCase.SENSITIVE);
+        final String[] wcs = splitOnTokens(wildcardMatcher);
+        boolean anyChars = false;
+        int textIdx = 0;
+        int wcsIdx = 0;
+        final Deque<int[]> backtrack = new ArrayDeque<>(wcs.length);
+
+        // loop around a backtrack stack, to handle complex * matching
+        do {
+            if (!backtrack.isEmpty()) {
+                final int[] array = backtrack.pop();
+                wcsIdx = array[0];
+                textIdx = array[1];
+                anyChars = true;
+            }
+
+            // loop whilst tokens and text left to process
+            while (wcsIdx < wcs.length) {
+
+                if (wcs[wcsIdx].equals("?")) {
+                    // ? so move to next text char
+                    textIdx++;
+                    if (textIdx > fileName.length()) {
+                        break;
+                    }
+                    anyChars = false;
+
+                } else if (wcs[wcsIdx].equals("*")) {
+                    // set any chars status
+                    anyChars = true;
+                    if (wcsIdx == wcs.length - 1) {
+                        textIdx = fileName.length();
+                    }
+
+                } else {
+                    // matching text token
+                    if (anyChars) {
+                        // any chars then try to locate text token
+                        textIdx = ioCase.checkIndexOf(fileName, textIdx, wcs[wcsIdx]);
+                        if (textIdx == NOT_FOUND) {
+                            // token not found
+                            break;
+                        }
+                        final int repeat = ioCase.checkIndexOf(fileName, textIdx + 1, wcs[wcsIdx]);
+                        if (repeat >= 0) {
+                            backtrack.push(new int[] {wcsIdx, repeat});
+                        }
+                    } else if (!ioCase.checkRegionMatches(fileName, textIdx, wcs[wcsIdx])) {
+                        // matching from current position
+                        // couldn't match token
+                        break;
+                    }
+
+                    // matched text token, move text index to end of matched token
+                    textIdx += wcs[wcsIdx].length();
+                    anyChars = false;
+                }
+
+                wcsIdx++;
+            }
+
+            // full match
+            if (wcsIdx == wcs.length && textIdx == fileName.length()) {
+                return true;
+            }
+
+        } while (!backtrack.isEmpty());
+
+        return false;
+    }
+
+    /**
+     * Checks a fileName to see if it matches the specified wildcard matcher
+     * using the case rules of the system.
+     * <p>
+     * The wildcard matcher uses the characters '?' and '*' to represent a
+     * single or multiple (zero or more) wildcard characters.
+     * This is the same as often found on DOS/Unix command lines.
+     * The check is case-sensitive on Unix and case-insensitive on Windows.
+     * <pre>
+     * wildcardMatch("c.txt", "*.txt")      --&gt; true
+     * wildcardMatch("c.txt", "*.jpg")      --&gt; false
+     * wildcardMatch("a/b/c.txt", "a/b/*")  --&gt; true
+     * wildcardMatch("c.txt", "*.???")      --&gt; true
+     * wildcardMatch("c.txt", "*.????")     --&gt; false
+     * </pre>
+     * N.B. the sequence "*?" does not work properly at present in match strings.
+     *
+     * @param fileName  the fileName to match on
+     * @param wildcardMatcher  the wildcard string to match against
+     * @return true if the fileName matches the wildcard string
+     * @see IOCase#SYSTEM
+     */
+    public static boolean wildcardMatchOnSystem(final String fileName, final String wildcardMatcher) {
+        return wildcardMatch(fileName, wildcardMatcher, IOCase.SYSTEM);
+    }
+
+    /**
+     * Instances should NOT be constructed in standard programming.
+     */
+    public FilenameUtils() {
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/HexDump.java b/src/main/java/org/apache/commons/io/HexDump.java
new file mode 100644
index 0000000..0360a49
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/HexDump.java
@@ -0,0 +1,233 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.nio.charset.Charset;
+import java.util.Objects;
+
+import org.apache.commons.io.output.CloseShieldOutputStream;
+
+/**
+ * Dumps data in hexadecimal format.
+ * <p>
+ * Provides a single function to take an array of bytes and display it
+ * in hexadecimal form.
+ * </p>
+ * <p>
+ * Origin of code: POI.
+ * </p>
+ */
+public class HexDump {
+
+    /**
+     * The line-separator (initializes to "line.separator" system property).
+     *
+     * @deprecated Use {@link System#lineSeparator()}.
+     */
+    @Deprecated
+    public static final String EOL = System.lineSeparator();
+
+    private static final char[] HEX_CODES =
+            {
+                '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
+                'A', 'B', 'C', 'D', 'E', 'F'
+            };
+
+    private static final int[] SHIFTS =
+            {
+                28, 24, 20, 16, 12, 8, 4, 0
+            };
+
+    /**
+     * Dumps an array of bytes to an Appendable. The output is formatted
+     * for human inspection, with a hexadecimal offset followed by the
+     * hexadecimal values of the next 16 bytes of data and the printable ASCII
+     * characters (if any) that those bytes represent printed per each line
+     * of output.
+     *
+     * @param data  the byte array to be dumped
+     * @param appendable  the Appendable to which the data is to be written
+     *
+     * @throws IOException is thrown if anything goes wrong writing
+     *         the data to appendable
+     * @throws NullPointerException if the output appendable is null
+     *
+     * @since 2.12.0
+     */
+    public static void dump(final byte[] data, final Appendable appendable)
+            throws IOException {
+        dump(data, 0, appendable, 0, data.length);
+    }
+
+    /**
+     * Dumps an array of bytes to an Appendable. The output is formatted
+     * for human inspection, with a hexadecimal offset followed by the
+     * hexadecimal values of the next 16 bytes of data and the printable ASCII
+     * characters (if any) that those bytes represent printed per each line
+     * of output.
+     * <p>
+     * The offset argument specifies the start offset of the data array
+     * within a larger entity like a file or an incoming stream. For example,
+     * if the data array contains the third kibibyte of a file, then the
+     * offset argument should be set to 2048. The offset value printed
+     * at the beginning of each line indicates where in that larger entity
+     * the first byte on that line is located.
+     * </p>
+     *
+     * @param data  the byte array to be dumped
+     * @param offset  offset of the byte array within a larger entity
+     * @param appendable  the Appendable to which the data is to be written
+     * @param index initial index into the byte array
+     * @param length number of bytes to dump from the array
+     *
+     * @throws IOException is thrown if anything goes wrong writing
+     *         the data to appendable
+     * @throws ArrayIndexOutOfBoundsException if the index or length is
+     *         outside the data array's bounds
+     * @throws NullPointerException if the output appendable is null
+     *
+     * @since 2.12.0
+     */
+    public static void dump(final byte[] data, final long offset,
+                            final Appendable appendable, final int index,
+                            final int length)
+            throws IOException, ArrayIndexOutOfBoundsException {
+        Objects.requireNonNull(appendable, "appendable");
+        if (index < 0 || index >= data.length) {
+            throw new ArrayIndexOutOfBoundsException(
+                    "illegal index: " + index + " into array of length "
+                    + data.length);
+        }
+        long display_offset = offset + index;
+        final StringBuilder buffer = new StringBuilder(74);
+
+        // TODO Use Objects.checkFromIndexSize(index, length, data.length) when upgrading to JDK9
+        if (length < 0 || (index + length) > data.length) {
+            throw new ArrayIndexOutOfBoundsException(String.format("Range [%s, %<s + %s) out of bounds for length %s", index, length, data.length));
+        }
+
+        final int endIndex = index + length;
+
+        for (int j = index; j < endIndex; j += 16) {
+            int chars_read = endIndex - j;
+
+            if (chars_read > 16) {
+                chars_read = 16;
+            }
+            dump(buffer, display_offset).append(' ');
+            for (int k = 0; k < 16; k++) {
+                if (k < chars_read) {
+                    dump(buffer, data[k + j]);
+                } else {
+                    buffer.append("  ");
+                }
+                buffer.append(' ');
+            }
+            for (int k = 0; k < chars_read; k++) {
+                if (data[k + j] >= ' ' && data[k + j] < 127) {
+                    buffer.append((char) data[k + j]);
+                } else {
+                    buffer.append('.');
+                }
+            }
+            buffer.append(System.lineSeparator());
+            appendable.append(buffer);
+            buffer.setLength(0);
+            display_offset += chars_read;
+        }
+    }
+
+    /**
+     * Dumps an array of bytes to an OutputStream. The output is formatted
+     * for human inspection, with a hexadecimal offset followed by the
+     * hexadecimal values of the next 16 bytes of data and the printable ASCII
+     * characters (if any) that those bytes represent printed per each line
+     * of output.
+     * <p>
+     * The offset argument specifies the start offset of the data array
+     * within a larger entity like a file or an incoming stream. For example,
+     * if the data array contains the third kibibyte of a file, then the
+     * offset argument should be set to 2048. The offset value printed
+     * at the beginning of each line indicates where in that larger entity
+     * the first byte on that line is located.
+     * </p>
+     * <p>
+     * All bytes between the given index (inclusive) and the end of the
+     * data array are dumped.
+     * </p>
+     *
+     * @param data  the byte array to be dumped
+     * @param offset  offset of the byte array within a larger entity
+     * @param stream  the OutputStream to which the data is to be
+     *               written
+     * @param index initial index into the byte array
+     *
+     * @throws IOException is thrown if anything goes wrong writing
+     *         the data to stream
+     * @throws ArrayIndexOutOfBoundsException if the index is
+     *         outside the data array's bounds
+     * @throws NullPointerException if the output stream is null
+     */
+    public static void dump(final byte[] data, final long offset,
+                            final OutputStream stream, final int index)
+            throws IOException, ArrayIndexOutOfBoundsException {
+        Objects.requireNonNull(stream, "stream");
+
+        try (OutputStreamWriter out = new OutputStreamWriter(CloseShieldOutputStream.wrap(stream), Charset.defaultCharset())) {
+            dump(data, offset, out, index, data.length - index);
+        }
+    }
+
+    /**
+     * Dumps a byte value into a StringBuilder.
+     *
+     * @param _cbuffer the StringBuilder to dump the value in
+     * @param value  the byte value to be dumped
+     * @return StringBuilder containing the dumped value.
+     */
+    private static StringBuilder dump(final StringBuilder _cbuffer, final byte value) {
+        for (int j = 0; j < 2; j++) {
+            _cbuffer.append(HEX_CODES[value >> SHIFTS[j + 6] & 15]);
+        }
+        return _cbuffer;
+    }
+
+    /**
+     * Dumps a long value into a StringBuilder.
+     *
+     * @param _lbuffer the StringBuilder to dump the value in
+     * @param value  the long value to be dumped
+     * @return StringBuilder containing the dumped value.
+     */
+    private static StringBuilder dump(final StringBuilder _lbuffer, final long value) {
+        for (int j = 0; j < 8; j++) {
+            _lbuffer
+                    .append(HEX_CODES[(int) (value >> SHIFTS[j]) & 15]);
+        }
+        return _lbuffer;
+    }
+
+    /**
+     * Instances should NOT be constructed in standard programming.
+     */
+    public HexDump() {
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/IOCase.java b/src/main/java/org/apache/commons/io/IOCase.java
new file mode 100644
index 0000000..dbb6f0a
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/IOCase.java
@@ -0,0 +1,276 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.util.Objects;
+import java.util.stream.Stream;
+
+/**
+ * Enumeration of IO case sensitivity.
+ * <p>
+ * Different filing systems have different rules for case-sensitivity.
+ * Windows is case-insensitive, Unix is case-sensitive.
+ * </p>
+ * <p>
+ * This class captures that difference, providing an enumeration to
+ * control how file name comparisons should be performed. It also provides
+ * methods that use the enumeration to perform comparisons.
+ * </p>
+ * <p>
+ * Wherever possible, you should use the {@code check} methods in this
+ * class to compare file names.
+ * </p>
+ *
+ * @since 1.3
+ */
+public enum IOCase {
+
+    /**
+     * The constant for case-sensitive regardless of operating system.
+     */
+    SENSITIVE("Sensitive", true),
+
+    /**
+     * The constant for case-insensitive regardless of operating system.
+     */
+    INSENSITIVE("Insensitive", false),
+
+    /**
+     * The constant for case sensitivity determined by the current operating system.
+     * Windows is case-insensitive when comparing file names, Unix is case-sensitive.
+     * <p>
+     * <strong>Note:</strong> This only caters for Windows and Unix. Other operating
+     * systems (e.g. OSX and OpenVMS) are treated as case-sensitive if they use the
+     * Unix file separator and case-insensitive if they use the Windows file separator
+     * (see {@link java.io.File#separatorChar}).
+     * </p>
+     * <p>
+     * If you serialize this constant on Windows, and deserialize on Unix, or vice
+     * versa, then the value of the case-sensitivity flag will change.
+     * </p>
+     */
+    SYSTEM("System", !FilenameUtils.isSystemWindows());
+
+    /** Serialization version. */
+    private static final long serialVersionUID = -6343169151696340687L;
+
+    /**
+     * Factory method to create an IOCase from a name.
+     *
+     * @param name  the name to find
+     * @return the IOCase object
+     * @throws IllegalArgumentException if the name is invalid
+     */
+    public static IOCase forName(final String name) {
+        return Stream.of(IOCase.values()).filter(ioCase -> ioCase.getName().equals(name)).findFirst()
+                .orElseThrow(() -> new IllegalArgumentException("Invalid IOCase name: " + name));
+    }
+
+    /**
+     * Tests for cases sensitivity in a null-safe manner.
+     *
+     * @param ioCase an IOCase.
+     * @return true if the input is non-null and {@link #isCaseSensitive()}.
+     * @since 2.10.0
+     */
+    public static boolean isCaseSensitive(final IOCase ioCase) {
+        return ioCase != null && ioCase.isCaseSensitive();
+    }
+
+    /**
+     * Returns the given value if not-null, the defaultValue if null.
+     *
+     * @param value the value to test.
+     * @param defaultValue the default value.
+     * @return the given value if not-null, the defaultValue if null.
+     * @since 2.12.0
+     */
+    public static IOCase value(final IOCase value, final IOCase defaultValue) {
+        return value != null ? value : defaultValue;
+    }
+
+    /** The enumeration name. */
+    private final String name;
+
+    /** The sensitivity flag. */
+    private final transient boolean sensitive;
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param name  the name
+     * @param sensitive  the sensitivity
+     */
+    IOCase(final String name, final boolean sensitive) {
+        this.name = name;
+        this.sensitive = sensitive;
+    }
+
+    /**
+     * Compares two strings using the case-sensitivity rule.
+     * <p>
+     * This method mimics {@link String#compareTo} but takes case-sensitivity
+     * into account.
+     * </p>
+     *
+     * @param str1  the first string to compare, not null
+     * @param str2  the second string to compare, not null
+     * @return true if equal using the case rules
+     * @throws NullPointerException if either string is null
+     */
+    public int checkCompareTo(final String str1, final String str2) {
+        Objects.requireNonNull(str1, "str1");
+        Objects.requireNonNull(str2, "str2");
+        return sensitive ? str1.compareTo(str2) : str1.compareToIgnoreCase(str2);
+    }
+
+    /**
+     * Checks if one string ends with another using the case-sensitivity rule.
+     * <p>
+     * This method mimics {@link String#endsWith} but takes case-sensitivity
+     * into account.
+     * </p>
+     *
+     * @param str  the string to check
+     * @param end  the end to compare against
+     * @return true if equal using the case rules, false if either input is null
+     */
+    public boolean checkEndsWith(final String str, final String end) {
+        if (str == null || end == null) {
+            return false;
+        }
+        final int endLen = end.length();
+        return str.regionMatches(!sensitive, str.length() - endLen, end, 0, endLen);
+    }
+
+    /**
+     * Compares two strings using the case-sensitivity rule.
+     * <p>
+     * This method mimics {@link String#equals} but takes case-sensitivity
+     * into account.
+     * </p>
+     *
+     * @param str1  the first string to compare, not null
+     * @param str2  the second string to compare, not null
+     * @return true if equal using the case rules
+     * @throws NullPointerException if either string is null
+     */
+    public boolean checkEquals(final String str1, final String str2) {
+        Objects.requireNonNull(str1, "str1");
+        Objects.requireNonNull(str2, "str2");
+        return sensitive ? str1.equals(str2) : str1.equalsIgnoreCase(str2);
+    }
+
+    /**
+     * Checks if one string contains another starting at a specific index using the
+     * case-sensitivity rule.
+     * <p>
+     * This method mimics parts of {@link String#indexOf(String, int)}
+     * but takes case-sensitivity into account.
+     * </p>
+     *
+     * @param str  the string to check, not null
+     * @param strStartIndex  the index to start at in str
+     * @param search  the start to search for, not null
+     * @return the first index of the search String,
+     *  -1 if no match or {@code null} string input
+     * @throws NullPointerException if either string is null
+     * @since 2.0
+     */
+    public int checkIndexOf(final String str, final int strStartIndex, final String search) {
+        final int endIndex = str.length() - search.length();
+        if (endIndex >= strStartIndex) {
+            for (int i = strStartIndex; i <= endIndex; i++) {
+                if (checkRegionMatches(str, i, search)) {
+                    return i;
+                }
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * Checks if one string contains another at a specific index using the case-sensitivity rule.
+     * <p>
+     * This method mimics parts of {@link String#regionMatches(boolean, int, String, int, int)}
+     * but takes case-sensitivity into account.
+     * </p>
+     *
+     * @param str  the string to check, not null
+     * @param strStartIndex  the index to start at in str
+     * @param search  the start to search for, not null
+     * @return true if equal using the case rules
+     * @throws NullPointerException if either string is null
+     */
+    public boolean checkRegionMatches(final String str, final int strStartIndex, final String search) {
+        return str.regionMatches(!sensitive, strStartIndex, search, 0, search.length());
+    }
+
+    /**
+     * Checks if one string starts with another using the case-sensitivity rule.
+     * <p>
+     * This method mimics {@link String#startsWith(String)} but takes case-sensitivity
+     * into account.
+     * </p>
+     *
+     * @param str  the string to check
+     * @param start  the start to compare against
+     * @return true if equal using the case rules, false if either input is null
+     */
+    public boolean checkStartsWith(final String str, final String start) {
+        return str != null && start != null && str.regionMatches(!sensitive, 0, start, 0, start.length());
+    }
+
+    /**
+     * Gets the name of the constant.
+     *
+     * @return the name of the constant
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Does the object represent case-sensitive comparison.
+     *
+     * @return true if case-sensitive
+     */
+    public boolean isCaseSensitive() {
+        return sensitive;
+    }
+
+    /**
+     * Replaces the enumeration from the stream with a real one.
+     * This ensures that the correct flag is set for SYSTEM.
+     *
+     * @return the resolved object
+     */
+    private Object readResolve() {
+        return forName(name);
+    }
+
+    /**
+     * Gets a string describing the sensitivity.
+     *
+     * @return a string describing the sensitivity
+     */
+    @Override
+    public String toString() {
+        return name;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/IOExceptionList.java b/src/main/java/org/apache/commons/io/IOExceptionList.java
new file mode 100644
index 0000000..ca3f880
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/IOExceptionList.java
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * An IOException based on a list of Throwable causes.
+ * <p>
+ * The first exception in the list is used as this exception's cause and is accessible with the usual
+ * {@link #getCause()} while the complete list is accessible with {@link #getCauseList()}.
+ * </p>
+ *
+ * @since 2.7
+ */
+public class IOExceptionList extends IOException implements Iterable<Throwable> {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * Throws this exception if the list is not null or empty.
+     *
+     * @param causeList The list to test.
+     * @param message The detail message, see {@link #getMessage()}.
+     * @throws IOExceptionList if the list is not null or empty.
+     * @since 2.12.0
+     */
+    public static void checkEmpty(final List<? extends Throwable> causeList, final Object message) throws IOExceptionList {
+        if (!isEmpty(causeList)) {
+            throw new IOExceptionList(Objects.toString(message, null), causeList);
+        }
+    }
+
+    private static boolean isEmpty(final List<? extends Throwable> causeList) {
+        return size(causeList) == 0;
+    }
+
+    private static int size(final List<? extends Throwable> causeList) {
+        return causeList != null ? causeList.size() : 0;
+    }
+
+    private static String toMessage(final List<? extends Throwable> causeList) {
+        return String.format("%,d exception(s): %s", size(causeList), causeList);
+    }
+
+    private final List<? extends Throwable> causeList;
+
+    /**
+     * Creates a new exception caused by a list of exceptions.
+     *
+     * @param causeList a list of cause exceptions.
+     */
+    public IOExceptionList(final List<? extends Throwable> causeList) {
+        this(toMessage(causeList), causeList);
+    }
+
+    /**
+     * Creates a new exception caused by a list of exceptions.
+     *
+     * @param message The detail message, see {@link #getMessage()}.
+     * @param causeList a list of cause exceptions.
+     * @since 2.9.0
+     */
+    public IOExceptionList(final String message, final List<? extends Throwable> causeList) {
+        super(message != null ? message : toMessage(causeList), isEmpty(causeList) ? null : causeList.get(0));
+        this.causeList = causeList == null ? Collections.emptyList() : causeList;
+    }
+
+    /**
+     * Gets the cause exception at the given index.
+     *
+     * @param <T> type of exception to return.
+     * @param index index in the cause list.
+     * @return The list of causes.
+     */
+    public <T extends Throwable> T getCause(final int index) {
+        return (T) causeList.get(index);
+    }
+
+    /**
+     * Gets the cause exception at the given index.
+     *
+     * @param <T> type of exception to return.
+     * @param index index in the cause list.
+     * @param clazz type of exception to return.
+     * @return The list of causes.
+     */
+    public <T extends Throwable> T getCause(final int index, final Class<T> clazz) {
+        return clazz.cast(getCause(index));
+    }
+
+    /**
+     * Gets the cause list.
+     *
+     * @param <T> type of exception to return.
+     * @return The list of causes.
+     */
+    public <T extends Throwable> List<T> getCauseList() {
+        return (List<T>) causeList;
+    }
+
+    /**
+     * Works around Throwable and Generics, may fail at runtime depending on the argument value.
+     *
+     * @param <T> type of exception to return.
+     * @param clazz the target type
+     * @return The list of causes.
+     */
+    public <T extends Throwable> List<T> getCauseList(final Class<T> clazz) {
+        return (List<T>) causeList;
+    }
+
+    @Override
+    public Iterator<Throwable> iterator() {
+        return getCauseList().iterator();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/IOExceptionWithCause.java b/src/main/java/org/apache/commons/io/IOExceptionWithCause.java
new file mode 100644
index 0000000..cd72dcd
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/IOExceptionWithCause.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io;
+
+import java.io.IOException;
+
+/**
+ * Subclasses IOException with the {@link Throwable} constructors missing before Java 6.
+ *
+ * @since 1.4
+ * @deprecated (since 2.5) use {@link IOException} instead
+ */
+@Deprecated
+public class IOExceptionWithCause extends IOException {
+
+    /**
+     * Defines the serial version UID.
+     */
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * Constructs a new instance with the given message and cause.
+     * <p>
+     * As specified in {@link Throwable}, the message in the given {@code cause} is not used in this instance's
+     * message.
+     * </p>
+     *
+     * @param message
+     *            the message (see {@link #getMessage()})
+     * @param cause
+     *            the cause (see {@link #getCause()}). A {@code null} value is allowed.
+     */
+    public IOExceptionWithCause(final String message, final Throwable cause) {
+        super(message, cause);
+    }
+
+    /**
+     * Constructs a new instance with the given cause.
+     * <p>
+     * The message is set to {@code cause==null ? null : cause.toString()}, which by default contains the class
+     * and message of {@code cause}. This constructor is useful for call sites that just wrap another throwable.
+     * </p>
+     *
+     * @param cause
+     *            the cause (see {@link #getCause()}). A {@code null} value is allowed.
+     */
+    public IOExceptionWithCause(final Throwable cause) {
+        super(cause);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/IOIndexedException.java b/src/main/java/org/apache/commons/io/IOIndexedException.java
new file mode 100644
index 0000000..71253d4
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/IOIndexedException.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io;
+
+import java.io.IOException;
+
+/**
+ * A IOException associated with a source index.
+ *
+ * @since 2.7
+ */
+public class IOIndexedException extends IOException {
+
+    private static final long serialVersionUID = 1L;
+    /**
+     * Converts input to a suitable String for exception message.
+     *
+     * @param index An index into a source collection.
+     * @param cause A cause.
+     * @return A message.
+     */
+    protected static String toMessage(final int index, final Throwable cause) {
+        // Letting index be any int
+        final String unspecified = "Null";
+        final String name = cause == null ? unspecified : cause.getClass().getSimpleName();
+        final String msg = cause == null ? unspecified : cause.getMessage();
+        return String.format("%s #%,d: %s", name, index, msg);
+    }
+
+    private final int index;
+
+    /**
+     * Creates a new exception.
+     *
+     * @param index index of this exception.
+     * @param cause cause exceptions.
+     */
+    public IOIndexedException(final int index, final Throwable cause) {
+        super(toMessage(index, cause), cause);
+        this.index = index;
+    }
+
+    /**
+     * The index of this exception.
+     *
+     * @return index of this exception.
+     */
+    public int getIndex() {
+        return index;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/IOUtils.java b/src/main/java/org/apache/commons/io/IOUtils.java
new file mode 100644
index 0000000..9ec9477
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/IOUtils.java
@@ -0,0 +1,3728 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.ByteArrayInputStream;
+import java.io.CharArrayWriter;
+import java.io.Closeable;
+import java.io.EOFException;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Reader;
+import java.io.UncheckedIOException;
+import java.io.Writer;
+import java.net.HttpURLConnection;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.URI;
+import java.net.URL;
+import java.net.URLConnection;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.channels.Channels;
+import java.nio.channels.ReadableByteChannel;
+import java.nio.channels.Selector;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.function.IOConsumer;
+import org.apache.commons.io.input.QueueInputStream;
+import org.apache.commons.io.output.AppendableWriter;
+import org.apache.commons.io.output.ByteArrayOutputStream;
+import org.apache.commons.io.output.NullOutputStream;
+import org.apache.commons.io.output.NullWriter;
+import org.apache.commons.io.output.StringBuilderWriter;
+import org.apache.commons.io.output.ThresholdingOutputStream;
+import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream;
+
+/**
+ * General IO stream manipulation utilities.
+ * <p>
+ * This class provides static utility methods for input/output operations.
+ * </p>
+ * <ul>
+ * <li>closeQuietly - these methods close a stream ignoring nulls and exceptions
+ * <li>toXxx/read - these methods read data from a stream
+ * <li>write - these methods write data to a stream
+ * <li>copy - these methods copy all the data from one stream to another
+ * <li>contentEquals - these methods compare the content of two streams
+ * </ul>
+ * <p>
+ * The byte-to-char methods and char-to-byte methods involve a conversion step.
+ * Two methods are provided in each case, one that uses the platform default
+ * encoding and the other which allows you to specify an encoding. You are
+ * encouraged to always specify an encoding because relying on the platform
+ * default can lead to unexpected results, for example when moving from
+ * development to production.
+ * </p>
+ * <p>
+ * All the methods in this class that read a stream are buffered internally.
+ * This means that there is no cause to use a {@link BufferedInputStream}
+ * or {@link BufferedReader}. The default buffer size of 4K has been shown
+ * to be efficient in tests.
+ * </p>
+ * <p>
+ * The various copy methods all delegate the actual copying to one of the following methods:
+ * </p>
+ * <ul>
+ * <li>{@link #copyLarge(InputStream, OutputStream, byte[])}</li>
+ * <li>{@link #copyLarge(InputStream, OutputStream, long, long, byte[])}</li>
+ * <li>{@link #copyLarge(Reader, Writer, char[])}</li>
+ * <li>{@link #copyLarge(Reader, Writer, long, long, char[])}</li>
+ * </ul>
+ * For example, {@link #copy(InputStream, OutputStream)} calls {@link #copyLarge(InputStream, OutputStream)}
+ * which calls {@link #copy(InputStream, OutputStream, int)} which creates the buffer and calls
+ * {@link #copyLarge(InputStream, OutputStream, byte[])}.
+ * <p>
+ * Applications can re-use buffers by using the underlying methods directly.
+ * This may improve performance for applications that need to do a lot of copying.
+ * </p>
+ * <p>
+ * Wherever possible, the methods in this class do <em>not</em> flush or close
+ * the stream. This is to avoid making non-portable assumptions about the
+ * streams' origin and further use. Thus the caller is still responsible for
+ * closing streams after use.
+ * </p>
+ * <p>
+ * Origin of code: Excalibur.
+ * </p>
+ */
+public class IOUtils {
+    // NOTE: This class is focused on InputStream, OutputStream, Reader and
+    // Writer. Each method should take at least one of these as a parameter,
+    // or return one of them.
+
+    /**
+     * CR char.
+     *
+     * @since 2.9.0
+     */
+    public static final int CR = '\r';
+
+    /**
+     * The default buffer size ({@value}) to use in copy methods.
+     */
+    public static final int DEFAULT_BUFFER_SIZE = 8192;
+
+    /**
+     * The system directory separator character.
+     */
+    public static final char DIR_SEPARATOR = File.separatorChar;
+
+    /**
+     * The Unix directory separator character.
+     */
+    public static final char DIR_SEPARATOR_UNIX = '/';
+
+    /**
+     * The Windows directory separator character.
+     */
+    public static final char DIR_SEPARATOR_WINDOWS = '\\';
+
+    /**
+     * A singleton empty byte array.
+     *
+     *  @since 2.9.0
+     */
+    public static final byte[] EMPTY_BYTE_ARRAY = {};
+
+    /**
+     * Represents the end-of-file (or stream).
+     * @since 2.5 (made public)
+     */
+    public static final int EOF = -1;
+
+    /**
+     * LF char.
+     *
+     * @since 2.9.0
+     */
+    public static final int LF = '\n';
+
+    /**
+     * The system line separator string.
+     *
+     * @deprecated Use {@link System#lineSeparator()}.
+     */
+    @Deprecated
+    public static final String LINE_SEPARATOR = System.lineSeparator();
+
+    /**
+     * The Unix line separator string.
+     *
+     * @see StandardLineSeparator#LF
+     */
+    public static final String LINE_SEPARATOR_UNIX = StandardLineSeparator.LF.getString();
+
+    /**
+     * The Windows line separator string.
+     *
+     * @see StandardLineSeparator#CRLF
+     */
+    public static final String LINE_SEPARATOR_WINDOWS = StandardLineSeparator.CRLF.getString();
+
+    /**
+     * Internal byte array buffer.
+     */
+    private static final ThreadLocal<byte[]> SKIP_BYTE_BUFFER = ThreadLocal.withInitial(IOUtils::byteArray);
+
+    /**
+     * Internal byte array buffer.
+     */
+    private static final ThreadLocal<char[]> SKIP_CHAR_BUFFER = ThreadLocal.withInitial(IOUtils::charArray);
+
+    /**
+     * Returns the given InputStream if it is already a {@link BufferedInputStream}, otherwise creates a
+     * BufferedInputStream from the given InputStream.
+     *
+     * @param inputStream the InputStream to wrap or return (not null)
+     * @return the given InputStream or a new {@link BufferedInputStream} for the given InputStream
+     * @throws NullPointerException if the input parameter is null
+     * @since 2.5
+     */
+    @SuppressWarnings("resource") // parameter null check
+    public static BufferedInputStream buffer(final InputStream inputStream) {
+        // reject null early on rather than waiting for IO operation to fail
+        // not checked by BufferedInputStream
+        Objects.requireNonNull(inputStream, "inputStream");
+        return inputStream instanceof BufferedInputStream ?
+                (BufferedInputStream) inputStream : new BufferedInputStream(inputStream);
+    }
+
+    /**
+     * Returns the given InputStream if it is already a {@link BufferedInputStream}, otherwise creates a
+     * BufferedInputStream from the given InputStream.
+     *
+     * @param inputStream the InputStream to wrap or return (not null)
+     * @param size the buffer size, if a new BufferedInputStream is created.
+     * @return the given InputStream or a new {@link BufferedInputStream} for the given InputStream
+     * @throws NullPointerException if the input parameter is null
+     * @since 2.5
+     */
+    @SuppressWarnings("resource") // parameter null check
+    public static BufferedInputStream buffer(final InputStream inputStream, final int size) {
+        // reject null early on rather than waiting for IO operation to fail
+        // not checked by BufferedInputStream
+        Objects.requireNonNull(inputStream, "inputStream");
+        return inputStream instanceof BufferedInputStream ?
+                (BufferedInputStream) inputStream : new BufferedInputStream(inputStream, size);
+    }
+
+    /**
+     * Returns the given OutputStream if it is already a {@link BufferedOutputStream}, otherwise creates a
+     * BufferedOutputStream from the given OutputStream.
+     *
+     * @param outputStream the OutputStream to wrap or return (not null)
+     * @return the given OutputStream or a new {@link BufferedOutputStream} for the given OutputStream
+     * @throws NullPointerException if the input parameter is null
+     * @since 2.5
+     */
+    @SuppressWarnings("resource") // parameter null check
+    public static BufferedOutputStream buffer(final OutputStream outputStream) {
+        // reject null early on rather than waiting for IO operation to fail
+        // not checked by BufferedInputStream
+        Objects.requireNonNull(outputStream, "outputStream");
+        return outputStream instanceof BufferedOutputStream ?
+                (BufferedOutputStream) outputStream : new BufferedOutputStream(outputStream);
+    }
+
+    /**
+     * Returns the given OutputStream if it is already a {@link BufferedOutputStream}, otherwise creates a
+     * BufferedOutputStream from the given OutputStream.
+     *
+     * @param outputStream the OutputStream to wrap or return (not null)
+     * @param size the buffer size, if a new BufferedOutputStream is created.
+     * @return the given OutputStream or a new {@link BufferedOutputStream} for the given OutputStream
+     * @throws NullPointerException if the input parameter is null
+     * @since 2.5
+     */
+    @SuppressWarnings("resource") // parameter null check
+    public static BufferedOutputStream buffer(final OutputStream outputStream, final int size) {
+        // reject null early on rather than waiting for IO operation to fail
+        // not checked by BufferedInputStream
+        Objects.requireNonNull(outputStream, "outputStream");
+        return outputStream instanceof BufferedOutputStream ?
+                (BufferedOutputStream) outputStream : new BufferedOutputStream(outputStream, size);
+    }
+
+    /**
+     * Returns the given reader if it is already a {@link BufferedReader}, otherwise creates a BufferedReader from
+     * the given reader.
+     *
+     * @param reader the reader to wrap or return (not null)
+     * @return the given reader or a new {@link BufferedReader} for the given reader
+     * @throws NullPointerException if the input parameter is null
+     * @since 2.5
+     */
+    public static BufferedReader buffer(final Reader reader) {
+        return reader instanceof BufferedReader ? (BufferedReader) reader : new BufferedReader(reader);
+    }
+
+    /**
+     * Returns the given reader if it is already a {@link BufferedReader}, otherwise creates a BufferedReader from the
+     * given reader.
+     *
+     * @param reader the reader to wrap or return (not null)
+     * @param size the buffer size, if a new BufferedReader is created.
+     * @return the given reader or a new {@link BufferedReader} for the given reader
+     * @throws NullPointerException if the input parameter is null
+     * @since 2.5
+     */
+    public static BufferedReader buffer(final Reader reader, final int size) {
+        return reader instanceof BufferedReader ? (BufferedReader) reader : new BufferedReader(reader, size);
+    }
+
+    /**
+     * Returns the given Writer if it is already a {@link BufferedWriter}, otherwise creates a BufferedWriter from the
+     * given Writer.
+     *
+     * @param writer the Writer to wrap or return (not null)
+     * @return the given Writer or a new {@link BufferedWriter} for the given Writer
+     * @throws NullPointerException if the input parameter is null
+     * @since 2.5
+     */
+    public static BufferedWriter buffer(final Writer writer) {
+        return writer instanceof BufferedWriter ? (BufferedWriter) writer : new BufferedWriter(writer);
+    }
+
+    /**
+     * Returns the given Writer if it is already a {@link BufferedWriter}, otherwise creates a BufferedWriter from the
+     * given Writer.
+     *
+     * @param writer the Writer to wrap or return (not null)
+     * @param size the buffer size, if a new BufferedWriter is created.
+     * @return the given Writer or a new {@link BufferedWriter} for the given Writer
+     * @throws NullPointerException if the input parameter is null
+     * @since 2.5
+     */
+    public static BufferedWriter buffer(final Writer writer, final int size) {
+        return writer instanceof BufferedWriter ? (BufferedWriter) writer : new BufferedWriter(writer, size);
+    }
+
+    /**
+     * Returns a new byte array of size {@link #DEFAULT_BUFFER_SIZE}.
+     *
+     * @return a new byte array of size {@link #DEFAULT_BUFFER_SIZE}.
+     * @since 2.9.0
+     */
+    public static byte[] byteArray() {
+        return byteArray(DEFAULT_BUFFER_SIZE);
+    }
+
+    /**
+     * Returns a new byte array of the given size.
+     *
+     * TODO Consider guarding or warning against large allocations...
+     *
+     * @param size array size.
+     * @return a new byte array of the given size.
+     * @throws NegativeArraySizeException if the size is negative.
+     * @since 2.9.0
+     */
+    public static byte[] byteArray(final int size) {
+        return new byte[size];
+    }
+
+    /**
+     * Returns a new char array of size {@link #DEFAULT_BUFFER_SIZE}.
+     *
+     * @return a new char array of size {@link #DEFAULT_BUFFER_SIZE}.
+     * @since 2.9.0
+     */
+    private static char[] charArray() {
+        return charArray(DEFAULT_BUFFER_SIZE);
+    }
+
+    /**
+     * Returns a new char array of the given size.
+     *
+     * TODO Consider guarding or warning against large allocations...
+     *
+     * @param size array size.
+     * @return a new char array of the given size.
+     * @since 2.9.0
+     */
+    private static char[] charArray(final int size) {
+        return new char[size];
+    }
+
+    /**
+     * Closes the given {@link Closeable} as a null-safe operation.
+     *
+     * @param closeable The resource to close, may be null.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.7
+     */
+    public static void close(final Closeable closeable) throws IOException {
+        if (closeable != null) {
+            closeable.close();
+        }
+    }
+
+    /**
+     * Closes the given {@link Closeable}s as null-safe operations.
+     *
+     * @param closeables The resource(s) to close, may be null.
+     * @throws IOExceptionList if an I/O error occurs.
+     * @since 2.8.0
+     */
+    public static void close(final Closeable... closeables) throws IOExceptionList {
+        IOConsumer.forAll(IOUtils::close, closeables);
+    }
+
+    /**
+     * Closes the given {@link Closeable} as a null-safe operation.
+     *
+     * @param closeable The resource to close, may be null.
+     * @param consumer Consume the IOException thrown by {@link Closeable#close()}.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.7
+     */
+    public static void close(final Closeable closeable, final IOConsumer<IOException> consumer) throws IOException {
+        if (closeable != null) {
+            try {
+                closeable.close();
+            } catch (final IOException e) {
+                if (consumer != null) {
+                    consumer.accept(e);
+                }
+            }
+        }
+    }
+
+    /**
+     * Closes a URLConnection.
+     *
+     * @param conn the connection to close.
+     * @since 2.4
+     */
+    public static void close(final URLConnection conn) {
+        if (conn instanceof HttpURLConnection) {
+            ((HttpURLConnection) conn).disconnect();
+        }
+    }
+
+    /**
+     * Avoids the need to type cast.
+     *
+     * @param closeable the object to close, may be null
+     */
+    private static void closeQ(final Closeable closeable) {
+        closeQuietly(closeable, null);
+    }
+
+    /**
+     * Closes a {@link Closeable} unconditionally.
+     *
+     * <p>
+     * Equivalent to {@link Closeable#close()}, except any exceptions will be ignored. This is typically used in
+     * finally blocks.
+     * <p>
+     * Example code:
+     * </p>
+     * <pre>
+     * Closeable closeable = null;
+     * try {
+     *     closeable = new FileReader(&quot;foo.txt&quot;);
+     *     // process closeable
+     *     closeable.close();
+     * } catch (Exception e) {
+     *     // error handling
+     * } finally {
+     *     IOUtils.closeQuietly(closeable);
+     * }
+     * </pre>
+     * <p>
+     * Closing all streams:
+     * </p>
+     * <pre>
+     * try {
+     *     return IOUtils.copy(inputStream, outputStream);
+     * } finally {
+     *     IOUtils.closeQuietly(inputStream);
+     *     IOUtils.closeQuietly(outputStream);
+     * }
+     * </pre>
+     * <p>
+     * Also consider using a try-with-resources statement where appropriate.
+     * </p>
+     *
+     * @param closeable the objects to close, may be null or already closed
+     * @since 2.0
+     *
+     * @see Throwable#addSuppressed(Throwable)
+     */
+    public static void closeQuietly(final Closeable closeable) {
+        closeQuietly(closeable, null);
+    }
+
+    /**
+     * Closes a {@link Closeable} unconditionally.
+     * <p>
+     * Equivalent to {@link Closeable#close()}, except any exceptions will be ignored.
+     * <p>
+     * This is typically used in finally blocks to ensure that the closeable is closed
+     * even if an Exception was thrown before the normal close statement was reached.
+     * <br>
+     * <b>It should not be used to replace the close statement(s)
+     * which should be present for the non-exceptional case.</b>
+     * <br>
+     * It is only intended to simplify tidying up where normal processing has already failed
+     * and reporting close failure as well is not necessary or useful.
+     * <p>
+     * Example code:
+     * </p>
+     * <pre>
+     * Closeable closeable = null;
+     * try {
+     *     closeable = new FileReader(&quot;foo.txt&quot;);
+     *     // processing using the closeable; may throw an Exception
+     *     closeable.close(); // Normal close - exceptions not ignored
+     * } catch (Exception e) {
+     *     // error handling
+     * } finally {
+     *     <b>IOUtils.closeQuietly(closeable); // In case normal close was skipped due to Exception</b>
+     * }
+     * </pre>
+     * <p>
+     * Closing all streams:
+     * <br>
+     * <pre>
+     * try {
+     *     return IOUtils.copy(inputStream, outputStream);
+     * } finally {
+     *     IOUtils.closeQuietly(inputStream, outputStream);
+     * }
+     * </pre>
+     * <p>
+     * Also consider using a try-with-resources statement where appropriate.
+     * </p>
+     * @param closeables the objects to close, may be null or already closed
+     * @see #closeQuietly(Closeable)
+     * @since 2.5
+     * @see Throwable#addSuppressed(Throwable)
+     */
+    public static void closeQuietly(final Closeable... closeables) {
+        if (closeables != null) {
+            closeQuietly(Arrays.stream(closeables));
+        }
+    }
+
+    /**
+     * Closes the given {@link Closeable} as a null-safe operation while consuming IOException by the given {@code consumer}.
+     *
+     * @param closeable The resource to close, may be null.
+     * @param consumer Consumes the IOException thrown by {@link Closeable#close()}.
+     * @since 2.7
+     */
+    public static void closeQuietly(final Closeable closeable, final Consumer<IOException> consumer) {
+        if (closeable != null) {
+            try {
+                closeable.close();
+            } catch (final IOException e) {
+                if (consumer != null) {
+                    consumer.accept(e);
+                }
+            }
+        }
+    }
+
+    /**
+     * Closes an {@link InputStream} unconditionally.
+     * <p>
+     * Equivalent to {@link InputStream#close()}, except any exceptions will be ignored.
+     * This is typically used in finally blocks.
+     * </p>
+     * <p>
+     * Example code:
+     * </p>
+     * <pre>
+     *   byte[] data = new byte[1024];
+     *   InputStream in = null;
+     *   try {
+     *       in = new FileInputStream("foo.txt");
+     *       in.read(data);
+     *       in.close(); //close errors are handled
+     *   } catch (Exception e) {
+     *       // error handling
+     *   } finally {
+     *       IOUtils.closeQuietly(in);
+     *   }
+     * </pre>
+     * <p>
+     * Also consider using a try-with-resources statement where appropriate.
+     * </p>
+     *
+     * @param input the InputStream to close, may be null or already closed
+     * @see Throwable#addSuppressed(Throwable)
+     */
+    public static void closeQuietly(final InputStream input) {
+        closeQ(input);
+    }
+
+    /**
+     * Closes an iterable of {@link Closeable} unconditionally.
+     * <p>
+     * Equivalent calling {@link Closeable#close()} on each element, except any exceptions will be ignored.
+     * </p>
+     *
+     * @param closeables the objects to close, may be null or already closed
+     * @see #closeQuietly(Closeable)
+     * @since 2.12.0
+     */
+    public static void closeQuietly(final Iterable<Closeable> closeables) {
+        if (closeables != null) {
+            closeables.forEach(IOUtils::closeQuietly);
+        }
+    }
+
+    /**
+     * Closes an {@link OutputStream} unconditionally.
+     * <p>
+     * Equivalent to {@link OutputStream#close()}, except any exceptions will be ignored.
+     * This is typically used in finally blocks.
+     * </p>
+     * <p>
+     * Example code:
+     * </p>
+     * <pre>
+     * byte[] data = "Hello, World".getBytes();
+     *
+     * OutputStream out = null;
+     * try {
+     *     out = new FileOutputStream("foo.txt");
+     *     out.write(data);
+     *     out.close(); //close errors are handled
+     * } catch (IOException e) {
+     *     // error handling
+     * } finally {
+     *     IOUtils.closeQuietly(out);
+     * }
+     * </pre>
+     * <p>
+     * Also consider using a try-with-resources statement where appropriate.
+     * </p>
+     *
+     * @param output the OutputStream to close, may be null or already closed
+     * @see Throwable#addSuppressed(Throwable)
+     */
+    public static void closeQuietly(final OutputStream output) {
+        closeQ(output);
+    }
+
+    /**
+     * Closes an {@link Reader} unconditionally.
+     * <p>
+     * Equivalent to {@link Reader#close()}, except any exceptions will be ignored.
+     * This is typically used in finally blocks.
+     * </p>
+     * <p>
+     * Example code:
+     * </p>
+     * <pre>
+     *   char[] data = new char[1024];
+     *   Reader in = null;
+     *   try {
+     *       in = new FileReader("foo.txt");
+     *       in.read(data);
+     *       in.close(); //close errors are handled
+     *   } catch (Exception e) {
+     *       // error handling
+     *   } finally {
+     *       IOUtils.closeQuietly(in);
+     *   }
+     * </pre>
+     * <p>
+     * Also consider using a try-with-resources statement where appropriate.
+     * </p>
+     *
+     * @param reader the Reader to close, may be null or already closed
+     * @see Throwable#addSuppressed(Throwable)
+     */
+    public static void closeQuietly(final Reader reader) {
+        closeQ(reader);
+    }
+
+    /**
+     * Closes a {@link Selector} unconditionally.
+     * <p>
+     * Equivalent to {@link Selector#close()}, except any exceptions will be ignored.
+     * This is typically used in finally blocks.
+     * </p>
+     * <p>
+     * Example code:
+     * </p>
+     * <pre>
+     *   Selector selector = null;
+     *   try {
+     *       selector = Selector.open();
+     *       // process socket
+     *
+     *   } catch (Exception e) {
+     *       // error handling
+     *   } finally {
+     *       IOUtils.closeQuietly(selector);
+     *   }
+     * </pre>
+     * <p>
+     * Also consider using a try-with-resources statement where appropriate.
+     * </p>
+     *
+     * @param selector the Selector to close, may be null or already closed
+     * @since 2.2
+     * @see Throwable#addSuppressed(Throwable)
+     */
+    public static void closeQuietly(final Selector selector) {
+        closeQ(selector);
+    }
+
+    /**
+     * Closes a {@link ServerSocket} unconditionally.
+     * <p>
+     * Equivalent to {@link ServerSocket#close()}, except any exceptions will be ignored.
+     * This is typically used in finally blocks.
+     * </p>
+     * <p>
+     * Example code:
+     * </p>
+     * <pre>
+     *   ServerSocket socket = null;
+     *   try {
+     *       socket = new ServerSocket();
+     *       // process socket
+     *       socket.close();
+     *   } catch (Exception e) {
+     *       // error handling
+     *   } finally {
+     *       IOUtils.closeQuietly(socket);
+     *   }
+     * </pre>
+     * <p>
+     * Also consider using a try-with-resources statement where appropriate.
+     * </p>
+     *
+     * @param serverSocket the ServerSocket to close, may be null or already closed
+     * @since 2.2
+     * @see Throwable#addSuppressed(Throwable)
+     */
+    public static void closeQuietly(final ServerSocket serverSocket) {
+        closeQ(serverSocket);
+    }
+
+    /**
+     * Closes a {@link Socket} unconditionally.
+     * <p>
+     * Equivalent to {@link Socket#close()}, except any exceptions will be ignored.
+     * This is typically used in finally blocks.
+     * </p>
+     * <p>
+     * Example code:
+     * </p>
+     * <pre>
+     *   Socket socket = null;
+     *   try {
+     *       socket = new Socket("http://www.foo.com/", 80);
+     *       // process socket
+     *       socket.close();
+     *   } catch (Exception e) {
+     *       // error handling
+     *   } finally {
+     *       IOUtils.closeQuietly(socket);
+     *   }
+     * </pre>
+     * <p>
+     * Also consider using a try-with-resources statement where appropriate.
+     * </p>
+     *
+     * @param socket the Socket to close, may be null or already closed
+     * @since 2.0
+     * @see Throwable#addSuppressed(Throwable)
+     */
+    public static void closeQuietly(final Socket socket) {
+        closeQ(socket);
+    }
+
+    /**
+     * Closes a stream of {@link Closeable} unconditionally.
+     * <p>
+     * Equivalent calling {@link Closeable#close()} on each element, except any exceptions will be ignored.
+     * </p>
+     *
+     * @param closeables the objects to close, may be null or already closed
+     * @see #closeQuietly(Closeable)
+     * @since 2.12.0
+     */
+    public static void closeQuietly(final Stream<Closeable> closeables) {
+        if (closeables != null) {
+            closeables.forEach(IOUtils::closeQuietly);
+        }
+    }
+
+    /**
+     * Closes an {@link Writer} unconditionally.
+     * <p>
+     * Equivalent to {@link Writer#close()}, except any exceptions will be ignored.
+     * This is typically used in finally blocks.
+     * </p>
+     * <p>
+     * Example code:
+     * </p>
+     * <pre>
+     *   Writer out = null;
+     *   try {
+     *       out = new StringWriter();
+     *       out.write("Hello World");
+     *       out.close(); //close errors are handled
+     *   } catch (Exception e) {
+     *       // error handling
+     *   } finally {
+     *       IOUtils.closeQuietly(out);
+     *   }
+     * </pre>
+     * <p>
+     * Also consider using a try-with-resources statement where appropriate.
+     * </p>
+     *
+     * @param writer the Writer to close, may be null or already closed
+     * @see Throwable#addSuppressed(Throwable)
+     */
+    public static void closeQuietly(final Writer writer) {
+        closeQ(writer);
+    }
+
+    /**
+     * Consumes bytes from a {@link InputStream} and ignores them.
+     * <p>
+     * The buffer size is given by {@link #DEFAULT_BUFFER_SIZE}.
+     * </p>
+     *
+     * @param input the {@link InputStream} to read.
+     * @return the number of bytes copied. or {@code 0} if {@code input is null}.
+     * @throws NullPointerException if the InputStream is {@code null}.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.8.0
+     */
+    public static long consume(final InputStream input) throws IOException {
+        return copyLarge(input, NullOutputStream.INSTANCE);
+    }
+
+    /**
+     * Consumes characters from a {@link Reader} and ignores them.
+     * <p>
+     * The buffer size is given by {@link #DEFAULT_BUFFER_SIZE}.
+     * </p>
+     *
+     * @param input the {@link Reader} to read.
+     * @return the number of bytes copied. or {@code 0} if {@code input is null}.
+     * @throws NullPointerException if the Reader is {@code null}.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.12.0
+     */
+    public static long consume(final Reader input) throws IOException {
+        return copyLarge(input, NullWriter.INSTANCE);
+    }
+
+    /**
+     * Compares the contents of two Streams to determine if they are equal or
+     * not.
+     * <p>
+     * This method buffers the input internally using
+     * {@link BufferedInputStream} if they are not already buffered.
+     * </p>
+     *
+     * @param input1 the first stream
+     * @param input2 the second stream
+     * @return true if the content of the streams are equal or they both don't
+     * exist, false otherwise
+     * @throws NullPointerException if either input is null
+     * @throws IOException          if an I/O error occurs
+     */
+    public static boolean contentEquals(final InputStream input1, final InputStream input2) throws IOException {
+        // Before making any changes, please test with
+        // org.apache.commons.io.jmh.IOUtilsContentEqualsInputStreamsBenchmark
+        if (input1 == input2) {
+            return true;
+        }
+        if (input1 == null || input2 == null) {
+            return false;
+        }
+
+        // reuse one
+        final byte[] array1 = getByteArray();
+        // allocate another
+        final byte[] array2 = byteArray();
+        int pos1;
+        int pos2;
+        int count1;
+        int count2;
+        while (true) {
+            pos1 = 0;
+            pos2 = 0;
+            for (int index = 0; index < DEFAULT_BUFFER_SIZE; index++) {
+                if (pos1 == index) {
+                    do {
+                        count1 = input1.read(array1, pos1, DEFAULT_BUFFER_SIZE - pos1);
+                    } while (count1 == 0);
+                    if (count1 == EOF) {
+                        return pos2 == index && input2.read() == EOF;
+                    }
+                    pos1 += count1;
+                }
+                if (pos2 == index) {
+                    do {
+                        count2 = input2.read(array2, pos2, DEFAULT_BUFFER_SIZE - pos2);
+                    } while (count2 == 0);
+                    if (count2 == EOF) {
+                        return pos1 == index && input1.read() == EOF;
+                    }
+                    pos2 += count2;
+                }
+                if (array1[index] != array2[index]) {
+                    return false;
+                }
+            }
+        }
+    }
+
+    // TODO Consider making public
+    private static boolean contentEquals(final Iterator<?> iterator1, final Iterator<?> iterator2) {
+        while (iterator1.hasNext()) {
+            if (!iterator2.hasNext()) {
+                return false;
+            }
+            if (!Objects.equals(iterator1.next(), iterator2.next())) {
+                return false;
+            }
+        }
+        return !iterator2.hasNext();
+    }
+
+    /**
+     * Compares the contents of two Readers to determine if they are equal or not.
+     * <p>
+     * This method buffers the input internally using {@link BufferedReader} if they are not already buffered.
+     * </p>
+     *
+     * @param input1 the first reader
+     * @param input2 the second reader
+     * @return true if the content of the readers are equal or they both don't exist, false otherwise
+     * @throws NullPointerException if either input is null
+     * @throws IOException if an I/O error occurs
+     * @since 1.1
+     */
+    public static boolean contentEquals(final Reader input1, final Reader input2) throws IOException {
+        if (input1 == input2) {
+            return true;
+        }
+        if (input1 == null || input2 == null) {
+            return false;
+        }
+
+        // reuse one
+        final char[] array1 = getCharArray();
+        // but allocate another
+        final char[] array2 = charArray();
+        int pos1;
+        int pos2;
+        int count1;
+        int count2;
+        while (true) {
+            pos1 = 0;
+            pos2 = 0;
+            for (int index = 0; index < DEFAULT_BUFFER_SIZE; index++) {
+                if (pos1 == index) {
+                    do {
+                        count1 = input1.read(array1, pos1, DEFAULT_BUFFER_SIZE - pos1);
+                    } while (count1 == 0);
+                    if (count1 == EOF) {
+                        return pos2 == index && input2.read() == EOF;
+                    }
+                    pos1 += count1;
+                }
+                if (pos2 == index) {
+                    do {
+                        count2 = input2.read(array2, pos2, DEFAULT_BUFFER_SIZE - pos2);
+                    } while (count2 == 0);
+                    if (count2 == EOF) {
+                        return pos1 == index && input1.read() == EOF;
+                    }
+                    pos2 += count2;
+                }
+                if (array1[index] != array2[index]) {
+                    return false;
+                }
+            }
+        }
+    }
+
+    // TODO Consider making public
+    private static boolean contentEquals(final Stream<?> stream1, final Stream<?> stream2) {
+        if (stream1 == stream2) {
+            return true;
+        }
+        if (stream1 == null || stream2 == null) {
+            return false;
+        }
+        return contentEquals(stream1.iterator(), stream2.iterator());
+    }
+
+    // TODO Consider making public
+    private static boolean contentEqualsIgnoreEOL(final BufferedReader reader1, final BufferedReader reader2) {
+        if (reader1 == reader2) {
+            return true;
+        }
+        if (reader1 == null || reader2 == null) {
+            return false;
+        }
+        return contentEquals(reader1.lines(), reader2.lines());
+    }
+
+    /**
+     * Compares the contents of two Readers to determine if they are equal or
+     * not, ignoring EOL characters.
+     * <p>
+     * This method buffers the input internally using
+     * {@link BufferedReader} if they are not already buffered.
+     * </p>
+     *
+     * @param reader1 the first reader
+     * @param reader2 the second reader
+     * @return true if the content of the readers are equal (ignoring EOL differences),  false otherwise
+     * @throws NullPointerException if either input is null
+     * @throws UncheckedIOException if an I/O error occurs
+     * @since 2.2
+     */
+    @SuppressWarnings("resource")
+    public static boolean contentEqualsIgnoreEOL(final Reader reader1, final Reader reader2) throws UncheckedIOException {
+        if (reader1 == reader2) {
+            return true;
+        }
+        if (reader1 == null || reader2 == null) {
+            return false;
+        }
+        return contentEqualsIgnoreEOL(toBufferedReader(reader1), toBufferedReader(reader2));
+    }
+
+    /**
+     * Copies bytes from an {@link InputStream} to an {@link OutputStream}.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a {@link BufferedInputStream}.
+     * </p>
+     * <p>
+     * Large streams (over 2GB) will return a bytes copied value of {@code -1} after the copy has completed since
+     * the correct number of bytes cannot be returned as an int. For large streams use the
+     * {@link #copyLarge(InputStream, OutputStream)} method.
+     * </p>
+     *
+     * @param inputStream the {@link InputStream} to read.
+     * @param outputStream the {@link OutputStream} to write.
+     * @return the number of bytes copied, or -1 if greater than {@link Integer#MAX_VALUE}.
+     * @throws NullPointerException if the InputStream is {@code null}.
+     * @throws NullPointerException if the OutputStream is {@code null}.
+     * @throws IOException if an I/O error occurs.
+     * @since 1.1
+     */
+    public static int copy(final InputStream inputStream, final OutputStream outputStream) throws IOException {
+        final long count = copyLarge(inputStream, outputStream);
+        return count > Integer.MAX_VALUE ? EOF : (int) count;
+    }
+
+    /**
+     * Copies bytes from an {@link InputStream} to an {@link OutputStream} using an internal buffer of the
+     * given size.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a {@link BufferedInputStream}.
+     * </p>
+     *
+     * @param inputStream the {@link InputStream} to read.
+     * @param outputStream the {@link OutputStream} to write to
+     * @param bufferSize the bufferSize used to copy from the input to the output
+     * @return the number of bytes copied.
+     * @throws NullPointerException if the InputStream is {@code null}.
+     * @throws NullPointerException if the OutputStream is {@code null}.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.5
+     */
+    public static long copy(final InputStream inputStream, final OutputStream outputStream, final int bufferSize)
+            throws IOException {
+        return copyLarge(inputStream, outputStream, IOUtils.byteArray(bufferSize));
+    }
+
+    /**
+     * Copies bytes from an {@link InputStream} to chars on a
+     * {@link Writer} using the default character encoding of the platform.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     * <p>
+     * This method uses {@link InputStreamReader}.
+     * </p>
+     *
+     * @param input the {@link InputStream} to read from
+     * @param writer the {@link Writer} to write to
+     * @throws NullPointerException if the input or output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 1.1
+     * @deprecated 2.5 use {@link #copy(InputStream, Writer, Charset)} instead
+     */
+    @Deprecated
+    public static void copy(final InputStream input, final Writer writer)
+            throws IOException {
+        copy(input, writer, Charset.defaultCharset());
+    }
+
+    /**
+     * Copies bytes from an {@link InputStream} to chars on a
+     * {@link Writer} using the specified character encoding.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     * <p>
+     * This method uses {@link InputStreamReader}.
+     * </p>
+     *
+     * @param input the {@link InputStream} to read from
+     * @param writer the {@link Writer} to write to
+     * @param inputCharset the charset to use for the input stream, null means platform default
+     * @throws NullPointerException if the input or output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.3
+     */
+    public static void copy(final InputStream input, final Writer writer, final Charset inputCharset)
+            throws IOException {
+        final InputStreamReader reader = new InputStreamReader(input, Charsets.toCharset(inputCharset));
+        copy(reader, writer);
+    }
+
+    /**
+     * Copies bytes from an {@link InputStream} to chars on a
+     * {@link Writer} using the specified character encoding.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     * <p>
+     * Character encoding names can be found at
+     * <a href="http://www.iana.org/assignments/character-sets">IANA</a>.
+     * </p>
+     * <p>
+     * This method uses {@link InputStreamReader}.
+     * </p>
+     *
+     * @param input the {@link InputStream} to read from
+     * @param writer the {@link Writer} to write to
+     * @param inputCharsetName the name of the requested charset for the InputStream, null means platform default
+     * @throws NullPointerException                         if the input or output is null
+     * @throws IOException                                  if an I/O error occurs
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     *                                                      .UnsupportedEncodingException} in version 2.2 if the
+     *                                                      encoding is not supported.
+     * @since 1.1
+     */
+    public static void copy(final InputStream input, final Writer writer, final String inputCharsetName)
+            throws IOException {
+        copy(input, writer, Charsets.toCharset(inputCharsetName));
+    }
+
+    /**
+     * Copies bytes from a {@link java.io.ByteArrayOutputStream} to a {@link QueueInputStream}.
+     * <p>
+     * Unlike using JDK {@link java.io.PipedInputStream} and {@link java.io.PipedOutputStream} for this, this
+     * solution works safely in a single thread environment.
+     * </p>
+     * <p>
+     * Example usage:
+     * </p>
+     *
+     * <pre>
+     * ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+     * outputStream.writeBytes("hello world".getBytes(StandardCharsets.UTF_8));
+     *
+     * InputStream inputStream = IOUtils.copy(outputStream);
+     * </pre>
+     *
+     * @param outputStream the {@link java.io.ByteArrayOutputStream} to read.
+     * @return the {@link QueueInputStream} filled with the content of the outputStream.
+     * @throws NullPointerException if the {@link java.io.ByteArrayOutputStream} is {@code null}.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.12
+     */
+    @SuppressWarnings("resource") // streams are closed by the caller.
+    public static QueueInputStream copy(final java.io.ByteArrayOutputStream outputStream) throws IOException {
+        Objects.requireNonNull(outputStream, "outputStream");
+        final QueueInputStream in = new QueueInputStream();
+        outputStream.writeTo(in.newQueueOutputStream());
+        return in;
+    }
+
+    /**
+     * Copies chars from a {@link Reader} to a {@link Appendable}.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedReader}.
+     * </p>
+     * <p>
+     * Large streams (over 2GB) will return a chars copied value of
+     * {@code -1} after the copy has completed since the correct
+     * number of chars cannot be returned as an int. For large streams
+     * use the {@link #copyLarge(Reader, Writer)} method.
+     * </p>
+     *
+     * @param reader the {@link Reader} to read from
+     * @param output the {@link Appendable} to write to
+     * @return the number of characters copied, or -1 if &gt; Integer.MAX_VALUE
+     * @throws NullPointerException if the input or output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.7
+     */
+    public static long copy(final Reader reader, final Appendable output) throws IOException {
+        return copy(reader, output, CharBuffer.allocate(DEFAULT_BUFFER_SIZE));
+    }
+
+    /**
+     * Copies chars from a {@link Reader} to an {@link Appendable}.
+     * <p>
+     * This method uses the provided buffer, so there is no need to use a
+     * {@link BufferedReader}.
+     * </p>
+     *
+     * @param reader the {@link Reader} to read from
+     * @param output the {@link Appendable} to write to
+     * @param buffer the buffer to be used for the copy
+     * @return the number of characters copied
+     * @throws NullPointerException if the input or output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.7
+     */
+    public static long copy(final Reader reader, final Appendable output, final CharBuffer buffer) throws IOException {
+        long count = 0;
+        int n;
+        while (EOF != (n = reader.read(buffer))) {
+            buffer.flip();
+            output.append(buffer, 0, n);
+            count += n;
+        }
+        return count;
+    }
+
+    /**
+     * Copies chars from a {@link Reader} to bytes on an
+     * {@link OutputStream} using the default character encoding of the
+     * platform, and calling flush.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedReader}.
+     * </p>
+     * <p>
+     * Due to the implementation of OutputStreamWriter, this method performs a
+     * flush.
+     * </p>
+     * <p>
+     * This method uses {@link OutputStreamWriter}.
+     * </p>
+     *
+     * @param reader the {@link Reader} to read from
+     * @param output the {@link OutputStream} to write to
+     * @throws NullPointerException if the input or output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 1.1
+     * @deprecated 2.5 use {@link #copy(Reader, OutputStream, Charset)} instead
+     */
+    @Deprecated
+    public static void copy(final Reader reader, final OutputStream output)
+            throws IOException {
+        copy(reader, output, Charset.defaultCharset());
+    }
+
+    /**
+     * Copies chars from a {@link Reader} to bytes on an
+     * {@link OutputStream} using the specified character encoding, and
+     * calling flush.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedReader}.
+     * </p>
+     * <p>
+     * Due to the implementation of OutputStreamWriter, this method performs a
+     * flush.
+     * </p>
+     * <p>
+     * This method uses {@link OutputStreamWriter}.
+     * </p>
+     *
+     * @param reader the {@link Reader} to read from
+     * @param output the {@link OutputStream} to write to
+     * @param outputCharset the charset to use for the OutputStream, null means platform default
+     * @throws NullPointerException if the input or output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.3
+     */
+    public static void copy(final Reader reader, final OutputStream output, final Charset outputCharset)
+            throws IOException {
+        final OutputStreamWriter writer = new OutputStreamWriter(output, Charsets.toCharset(outputCharset));
+        copy(reader, writer);
+        // XXX Unless anyone is planning on rewriting OutputStreamWriter,
+        // we have to flush here.
+        writer.flush();
+    }
+
+    /**
+     * Copies chars from a {@link Reader} to bytes on an
+     * {@link OutputStream} using the specified character encoding, and
+     * calling flush.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedReader}.
+     * </p>
+     * <p>
+     * Character encoding names can be found at
+     * <a href="http://www.iana.org/assignments/character-sets">IANA</a>.
+     * </p>
+     * <p>
+     * Due to the implementation of OutputStreamWriter, this method performs a
+     * flush.
+     * </p>
+     * <p>
+     * This method uses {@link OutputStreamWriter}.
+     * </p>
+     *
+     * @param reader the {@link Reader} to read from
+     * @param output the {@link OutputStream} to write to
+     * @param outputCharsetName the name of the requested charset for the OutputStream, null means platform default
+     * @throws NullPointerException                         if the input or output is null
+     * @throws IOException                                  if an I/O error occurs
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     *                                                      .UnsupportedEncodingException} in version 2.2 if the
+     *                                                      encoding is not supported.
+     * @since 1.1
+     */
+    public static void copy(final Reader reader, final OutputStream output, final String outputCharsetName)
+            throws IOException {
+        copy(reader, output, Charsets.toCharset(outputCharsetName));
+    }
+
+    /**
+     * Copies chars from a {@link Reader} to a {@link Writer}.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedReader}.
+     * </p>
+     * <p>
+     * Large streams (over 2GB) will return a chars copied value of
+     * {@code -1} after the copy has completed since the correct
+     * number of chars cannot be returned as an int. For large streams
+     * use the {@link #copyLarge(Reader, Writer)} method.
+     * </p>
+     *
+     * @param reader the {@link Reader} to read.
+     * @param writer the {@link Writer} to write.
+     * @return the number of characters copied, or -1 if &gt; Integer.MAX_VALUE
+     * @throws NullPointerException if the input or output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 1.1
+     */
+    public static int copy(final Reader reader, final Writer writer) throws IOException {
+        final long count = copyLarge(reader, writer);
+        if (count > Integer.MAX_VALUE) {
+            return EOF;
+        }
+        return (int) count;
+    }
+
+    /**
+     * Copies bytes from a {@link URL} to an {@link OutputStream}.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a {@link BufferedInputStream}.
+     * </p>
+     * <p>
+     * The buffer size is given by {@link #DEFAULT_BUFFER_SIZE}.
+     * </p>
+     *
+     * @param url the {@link URL} to read.
+     * @param file the {@link OutputStream} to write.
+     * @return the number of bytes copied.
+     * @throws NullPointerException if the URL is {@code null}.
+     * @throws NullPointerException if the OutputStream is {@code null}.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.9.0
+     */
+    public static long copy(final URL url, final File file) throws IOException {
+        try (OutputStream outputStream = Files.newOutputStream(Objects.requireNonNull(file, "file").toPath())) {
+            return copy(url, outputStream);
+        }
+    }
+
+    /**
+     * Copies bytes from a {@link URL} to an {@link OutputStream}.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a {@link BufferedInputStream}.
+     * </p>
+     * <p>
+     * The buffer size is given by {@link #DEFAULT_BUFFER_SIZE}.
+     * </p>
+     *
+     * @param url the {@link URL} to read.
+     * @param outputStream the {@link OutputStream} to write.
+     * @return the number of bytes copied.
+     * @throws NullPointerException if the URL is {@code null}.
+     * @throws NullPointerException if the OutputStream is {@code null}.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.9.0
+     */
+    public static long copy(final URL url, final OutputStream outputStream) throws IOException {
+        try (InputStream inputStream = Objects.requireNonNull(url, "url").openStream()) {
+            return copyLarge(inputStream, outputStream);
+        }
+    }
+
+    /**
+     * Copies bytes from a large (over 2GB) {@link InputStream} to an
+     * {@link OutputStream}.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     * <p>
+     * The buffer size is given by {@link #DEFAULT_BUFFER_SIZE}.
+     * </p>
+     *
+     * @param inputStream the {@link InputStream} to read.
+     * @param outputStream the {@link OutputStream} to write.
+     * @return the number of bytes copied.
+     * @throws NullPointerException if the InputStream is {@code null}.
+     * @throws NullPointerException if the OutputStream is {@code null}.
+     * @throws IOException if an I/O error occurs.
+     * @since 1.3
+     */
+    public static long copyLarge(final InputStream inputStream, final OutputStream outputStream)
+            throws IOException {
+        return copy(inputStream, outputStream, DEFAULT_BUFFER_SIZE);
+    }
+
+    /**
+     * Copies bytes from a large (over 2GB) {@link InputStream} to an
+     * {@link OutputStream}.
+     * <p>
+     * This method uses the provided buffer, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     *
+     * @param inputStream the {@link InputStream} to read.
+     * @param outputStream the {@link OutputStream} to write.
+     * @param buffer the buffer to use for the copy
+     * @return the number of bytes copied.
+     * @throws NullPointerException if the InputStream is {@code null}.
+     * @throws NullPointerException if the OutputStream is {@code null}.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.2
+     */
+    @SuppressWarnings("resource") // streams are closed by the caller.
+    public static long copyLarge(final InputStream inputStream, final OutputStream outputStream, final byte[] buffer)
+        throws IOException {
+        Objects.requireNonNull(inputStream, "inputStream");
+        Objects.requireNonNull(outputStream, "outputStream");
+        long count = 0;
+        int n;
+        while (EOF != (n = inputStream.read(buffer))) {
+            outputStream.write(buffer, 0, n);
+            count += n;
+        }
+        return count;
+    }
+
+    /**
+     * Copies some or all bytes from a large (over 2GB) {@link InputStream} to an
+     * {@link OutputStream}, optionally skipping input bytes.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     * <p>
+     * Note that the implementation uses {@link #skip(InputStream, long)}.
+     * This means that the method may be considerably less efficient than using the actual skip implementation,
+     * this is done to guarantee that the correct number of characters are skipped.
+     * </p>
+     * The buffer size is given by {@link #DEFAULT_BUFFER_SIZE}.
+     *
+     * @param input the {@link InputStream} to read from
+     * @param output the {@link OutputStream} to write to
+     * @param inputOffset : number of bytes to skip from input before copying
+     * -ve values are ignored
+     * @param length : number of bytes to copy. -ve means all
+     * @return the number of bytes copied
+     * @throws NullPointerException if the input or output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.2
+     */
+    public static long copyLarge(final InputStream input, final OutputStream output, final long inputOffset,
+                                 final long length) throws IOException {
+        return copyLarge(input, output, inputOffset, length, getByteArray());
+    }
+
+    /**
+     * Copies some or all bytes from a large (over 2GB) {@link InputStream} to an
+     * {@link OutputStream}, optionally skipping input bytes.
+     * <p>
+     * This method uses the provided buffer, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     * <p>
+     * Note that the implementation uses {@link #skip(InputStream, long)}.
+     * This means that the method may be considerably less efficient than using the actual skip implementation,
+     * this is done to guarantee that the correct number of characters are skipped.
+     * </p>
+     *
+     * @param input the {@link InputStream} to read from
+     * @param output the {@link OutputStream} to write to
+     * @param inputOffset : number of bytes to skip from input before copying
+     * -ve values are ignored
+     * @param length : number of bytes to copy. -ve means all
+     * @param buffer the buffer to use for the copy
+     * @return the number of bytes copied
+     * @throws NullPointerException if the input or output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.2
+     */
+    public static long copyLarge(final InputStream input, final OutputStream output,
+                                 final long inputOffset, final long length, final byte[] buffer) throws IOException {
+        if (inputOffset > 0) {
+            skipFully(input, inputOffset);
+        }
+        if (length == 0) {
+            return 0;
+        }
+        final int bufferLength = buffer.length;
+        int bytesToRead = bufferLength;
+        if (length > 0 && length < bufferLength) {
+            bytesToRead = (int) length;
+        }
+        int read;
+        long totalRead = 0;
+        while (bytesToRead > 0 && EOF != (read = input.read(buffer, 0, bytesToRead))) {
+            output.write(buffer, 0, read);
+            totalRead += read;
+            if (length > 0) { // only adjust length if not reading to the end
+                // Note the cast must work because buffer.length is an integer
+                bytesToRead = (int) Math.min(length - totalRead, bufferLength);
+            }
+        }
+        return totalRead;
+    }
+
+    /**
+     * Copies chars from a large (over 2GB) {@link Reader} to a {@link Writer}.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedReader}.
+     * </p>
+     * <p>
+     * The buffer size is given by {@link #DEFAULT_BUFFER_SIZE}.
+     * </p>
+     *
+     * @param reader the {@link Reader} to source.
+     * @param writer the {@link Writer} to target.
+     * @return the number of characters copied
+     * @throws NullPointerException if the input or output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 1.3
+     */
+    public static long copyLarge(final Reader reader, final Writer writer) throws IOException {
+        return copyLarge(reader, writer, getCharArray());
+    }
+
+    /**
+     * Copies chars from a large (over 2GB) {@link Reader} to a {@link Writer}.
+     * <p>
+     * This method uses the provided buffer, so there is no need to use a
+     * {@link BufferedReader}.
+     * </p>
+     *
+     * @param reader the {@link Reader} to source.
+     * @param writer the {@link Writer} to target.
+     * @param buffer the buffer to be used for the copy
+     * @return the number of characters copied
+     * @throws NullPointerException if the input or output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.2
+     */
+    public static long copyLarge(final Reader reader, final Writer writer, final char[] buffer) throws IOException {
+        long count = 0;
+        int n;
+        while (EOF != (n = reader.read(buffer))) {
+            writer.write(buffer, 0, n);
+            count += n;
+        }
+        return count;
+    }
+
+    /**
+     * Copies some or all chars from a large (over 2GB) {@link InputStream} to an
+     * {@link OutputStream}, optionally skipping input chars.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedReader}.
+     * </p>
+     * <p>
+     * The buffer size is given by {@link #DEFAULT_BUFFER_SIZE}.
+     * </p>
+     *
+     * @param reader the {@link Reader} to read from
+     * @param writer the {@link Writer} to write to
+     * @param inputOffset : number of chars to skip from input before copying
+     * -ve values are ignored
+     * @param length : number of chars to copy. -ve means all
+     * @return the number of chars copied
+     * @throws NullPointerException if the input or output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.2
+     */
+    public static long copyLarge(final Reader reader, final Writer writer, final long inputOffset, final long length)
+            throws IOException {
+        return copyLarge(reader, writer, inputOffset, length, getCharArray());
+    }
+
+    /**
+     * Copies some or all chars from a large (over 2GB) {@link InputStream} to an
+     * {@link OutputStream}, optionally skipping input chars.
+     * <p>
+     * This method uses the provided buffer, so there is no need to use a
+     * {@link BufferedReader}.
+     * </p>
+     *
+     * @param reader the {@link Reader} to read from
+     * @param writer the {@link Writer} to write to
+     * @param inputOffset : number of chars to skip from input before copying
+     * -ve values are ignored
+     * @param length : number of chars to copy. -ve means all
+     * @param buffer the buffer to be used for the copy
+     * @return the number of chars copied
+     * @throws NullPointerException if the input or output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.2
+     */
+    public static long copyLarge(final Reader reader, final Writer writer, final long inputOffset, final long length,
+                                 final char[] buffer)
+            throws IOException {
+        if (inputOffset > 0) {
+            skipFully(reader, inputOffset);
+        }
+        if (length == 0) {
+            return 0;
+        }
+        int bytesToRead = buffer.length;
+        if (length > 0 && length < buffer.length) {
+            bytesToRead = (int) length;
+        }
+        int read;
+        long totalRead = 0;
+        while (bytesToRead > 0 && EOF != (read = reader.read(buffer, 0, bytesToRead))) {
+            writer.write(buffer, 0, read);
+            totalRead += read;
+            if (length > 0) { // only adjust length if not reading to the end
+                // Note the cast must work because buffer.length is an integer
+                bytesToRead = (int) Math.min(length - totalRead, buffer.length);
+            }
+        }
+        return totalRead;
+    }
+
+    /**
+     * Gets the thread local byte array.
+     *
+     * @return the thread local byte array.
+     */
+    static byte[] getByteArray() {
+        return SKIP_BYTE_BUFFER.get();
+    }
+
+    /**
+     * Gets the thread local char array.
+     *
+     * @return the thread local char array.
+     */
+    static char[] getCharArray() {
+        return SKIP_CHAR_BUFFER.get();
+    }
+
+    /**
+     * Returns the length of the given array in a null-safe manner.
+     *
+     * @param array an array or null
+     * @return the array length -- or 0 if the given array is null.
+     * @since 2.7
+     */
+    public static int length(final byte[] array) {
+        return array == null ? 0 : array.length;
+    }
+
+    /**
+     * Returns the length of the given array in a null-safe manner.
+     *
+     * @param array an array or null
+     * @return the array length -- or 0 if the given array is null.
+     * @since 2.7
+     */
+    public static int length(final char[] array) {
+        return array == null ? 0 : array.length;
+    }
+
+    /**
+     * Returns the length of the given CharSequence in a null-safe manner.
+     *
+     * @param csq a CharSequence or null
+     * @return the CharSequence length -- or 0 if the given CharSequence is null.
+     * @since 2.7
+     */
+    public static int length(final CharSequence csq) {
+        return csq == null ? 0 : csq.length();
+    }
+
+    /**
+     * Returns the length of the given array in a null-safe manner.
+     *
+     * @param array an array or null
+     * @return the array length -- or 0 if the given array is null.
+     * @since 2.7
+     */
+    public static int length(final Object[] array) {
+        return array == null ? 0 : array.length;
+    }
+
+    /**
+     * Returns an Iterator for the lines in an {@link InputStream}, using
+     * the character encoding specified (or default encoding if null).
+     * <p>
+     * {@link LineIterator} holds a reference to the open
+     * {@link InputStream} specified here. When you have finished with
+     * the iterator you should close the stream to free internal resources.
+     * This can be done by using a try-with-resources block, closing the stream directly, or by calling
+     * {@link LineIterator#close()}.
+     * </p>
+     * <p>
+     * The recommended usage pattern is:
+     * </p>
+     * <pre>
+     * try {
+     *   LineIterator it = IOUtils.lineIterator(stream, charset);
+     *   while (it.hasNext()) {
+     *     String line = it.nextLine();
+     *     /// do something with line
+     *   }
+     * } finally {
+     *   IOUtils.closeQuietly(stream);
+     * }
+     * </pre>
+     *
+     * @param input the {@link InputStream} to read from, not null
+     * @param charset the charset to use, null means platform default
+     * @return an Iterator of the lines in the reader, never null
+     * @throws IllegalArgumentException if the input is null
+     * @since 2.3
+     */
+    public static LineIterator lineIterator(final InputStream input, final Charset charset) {
+        return new LineIterator(new InputStreamReader(input, Charsets.toCharset(charset)));
+    }
+
+    /**
+     * Returns an Iterator for the lines in an {@link InputStream}, using
+     * the character encoding specified (or default encoding if null).
+     * <p>
+     * {@link LineIterator} holds a reference to the open
+     * {@link InputStream} specified here. When you have finished with
+     * the iterator you should close the stream to free internal resources.
+     * This can be done by using a try-with-resources block, closing the stream directly, or by calling
+     * {@link LineIterator#close()}.
+     * </p>
+     * <p>
+     * The recommended usage pattern is:
+     * </p>
+     * <pre>
+     * try {
+     *   LineIterator it = IOUtils.lineIterator(stream, StandardCharsets.UTF_8.name());
+     *   while (it.hasNext()) {
+     *     String line = it.nextLine();
+     *     /// do something with line
+     *   }
+     * } finally {
+     *   IOUtils.closeQuietly(stream);
+     * }
+     * </pre>
+     *
+     * @param input the {@link InputStream} to read from, not null
+     * @param charsetName the encoding to use, null means platform default
+     * @return an Iterator of the lines in the reader, never null
+     * @throws IllegalArgumentException                     if the input is null
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     *                                                      .UnsupportedEncodingException} in version 2.2 if the
+     *                                                      encoding is not supported.
+     * @since 1.2
+     */
+    public static LineIterator lineIterator(final InputStream input, final String charsetName) {
+        return lineIterator(input, Charsets.toCharset(charsetName));
+    }
+
+    /**
+     * Returns an Iterator for the lines in a {@link Reader}.
+     * <p>
+     * {@link LineIterator} holds a reference to the open
+     * {@link Reader} specified here. When you have finished with the
+     * iterator you should close the reader to free internal resources.
+     * This can be done by using a try-with-resources block, closing the reader directly, or by calling
+     * {@link LineIterator#close()}.
+     * </p>
+     * <p>
+     * The recommended usage pattern is:
+     * </p>
+     * <pre>
+     * try {
+     *   LineIterator it = IOUtils.lineIterator(reader);
+     *   while (it.hasNext()) {
+     *     String line = it.nextLine();
+     *     /// do something with line
+     *   }
+     * } finally {
+     *   IOUtils.closeQuietly(reader);
+     * }
+     * </pre>
+     *
+     * @param reader the {@link Reader} to read from, not null
+     * @return an Iterator of the lines in the reader, never null
+     * @throws IllegalArgumentException if the reader is null
+     * @since 1.2
+     */
+    public static LineIterator lineIterator(final Reader reader) {
+        return new LineIterator(reader);
+    }
+
+    /**
+     * Reads bytes from an input stream.
+     * This implementation guarantees that it will read as many bytes
+     * as possible before giving up; this may not always be the case for
+     * subclasses of {@link InputStream}.
+     *
+     * @param input where to read input from
+     * @param buffer destination
+     * @return actual length read; may be less than requested if EOF was reached
+     * @throws IOException if a read error occurs
+     * @since 2.2
+     */
+    public static int read(final InputStream input, final byte[] buffer) throws IOException {
+        return read(input, buffer, 0, buffer.length);
+    }
+
+    /**
+     * Reads bytes from an input stream.
+     * This implementation guarantees that it will read as many bytes
+     * as possible before giving up; this may not always be the case for
+     * subclasses of {@link InputStream}.
+     *
+     * @param input where to read input from
+     * @param buffer destination
+     * @param offset initial offset into buffer
+     * @param length length to read, must be &gt;= 0
+     * @return actual length read; may be less than requested if EOF was reached
+     * @throws IllegalArgumentException if length is negative
+     * @throws IOException              if a read error occurs
+     * @since 2.2
+     */
+    public static int read(final InputStream input, final byte[] buffer, final int offset, final int length)
+            throws IOException {
+        if (length < 0) {
+            throw new IllegalArgumentException("Length must not be negative: " + length);
+        }
+        int remaining = length;
+        while (remaining > 0) {
+            final int location = length - remaining;
+            final int count = input.read(buffer, offset + location, remaining);
+            if (EOF == count) { // EOF
+                break;
+            }
+            remaining -= count;
+        }
+        return length - remaining;
+    }
+
+    /**
+     * Reads bytes from a ReadableByteChannel.
+     * <p>
+     * This implementation guarantees that it will read as many bytes
+     * as possible before giving up; this may not always be the case for
+     * subclasses of {@link ReadableByteChannel}.
+     * </p>
+     *
+     * @param input the byte channel to read
+     * @param buffer byte buffer destination
+     * @return the actual length read; may be less than requested if EOF was reached
+     * @throws IOException if a read error occurs
+     * @since 2.5
+     */
+    public static int read(final ReadableByteChannel input, final ByteBuffer buffer) throws IOException {
+        final int length = buffer.remaining();
+        while (buffer.remaining() > 0) {
+            final int count = input.read(buffer);
+            if (EOF == count) { // EOF
+                break;
+            }
+        }
+        return length - buffer.remaining();
+    }
+
+    /**
+     * Reads characters from an input character stream.
+     * This implementation guarantees that it will read as many characters
+     * as possible before giving up; this may not always be the case for
+     * subclasses of {@link Reader}.
+     *
+     * @param reader where to read input from
+     * @param buffer destination
+     * @return actual length read; may be less than requested if EOF was reached
+     * @throws IOException if a read error occurs
+     * @since 2.2
+     */
+    public static int read(final Reader reader, final char[] buffer) throws IOException {
+        return read(reader, buffer, 0, buffer.length);
+    }
+
+    /**
+     * Reads characters from an input character stream.
+     * This implementation guarantees that it will read as many characters
+     * as possible before giving up; this may not always be the case for
+     * subclasses of {@link Reader}.
+     *
+     * @param reader where to read input from
+     * @param buffer destination
+     * @param offset initial offset into buffer
+     * @param length length to read, must be &gt;= 0
+     * @return actual length read; may be less than requested if EOF was reached
+     * @throws IllegalArgumentException if length is negative
+     * @throws IOException              if a read error occurs
+     * @since 2.2
+     */
+    public static int read(final Reader reader, final char[] buffer, final int offset, final int length)
+            throws IOException {
+        if (length < 0) {
+            throw new IllegalArgumentException("Length must not be negative: " + length);
+        }
+        int remaining = length;
+        while (remaining > 0) {
+            final int location = length - remaining;
+            final int count = reader.read(buffer, offset + location, remaining);
+            if (EOF == count) { // EOF
+                break;
+            }
+            remaining -= count;
+        }
+        return length - remaining;
+    }
+
+    /**
+     * Reads the requested number of bytes or fail if there are not enough left.
+     * <p>
+     * This allows for the possibility that {@link InputStream#read(byte[], int, int)} may
+     * not read as many bytes as requested (most likely because of reaching EOF).
+     * </p>
+     *
+     * @param input where to read input from
+     * @param buffer destination
+     *
+     * @throws IOException              if there is a problem reading the file
+     * @throws IllegalArgumentException if length is negative
+     * @throws EOFException             if the number of bytes read was incorrect
+     * @since 2.2
+     */
+    public static void readFully(final InputStream input, final byte[] buffer) throws IOException {
+        readFully(input, buffer, 0, buffer.length);
+    }
+
+    /**
+     * Reads the requested number of bytes or fail if there are not enough left.
+     * <p>
+     * This allows for the possibility that {@link InputStream#read(byte[], int, int)} may
+     * not read as many bytes as requested (most likely because of reaching EOF).
+     * </p>
+     *
+     * @param input where to read input from
+     * @param buffer destination
+     * @param offset initial offset into buffer
+     * @param length length to read, must be &gt;= 0
+     *
+     * @throws IOException              if there is a problem reading the file
+     * @throws IllegalArgumentException if length is negative
+     * @throws EOFException             if the number of bytes read was incorrect
+     * @since 2.2
+     */
+    public static void readFully(final InputStream input, final byte[] buffer, final int offset, final int length)
+            throws IOException {
+        final int actual = read(input, buffer, offset, length);
+        if (actual != length) {
+            throw new EOFException("Length to read: " + length + " actual: " + actual);
+        }
+    }
+
+    /**
+     * Reads the requested number of bytes or fail if there are not enough left.
+     * <p>
+     * This allows for the possibility that {@link InputStream#read(byte[], int, int)} may
+     * not read as many bytes as requested (most likely because of reaching EOF).
+     * </p>
+     *
+     * @param input where to read input from
+     * @param length length to read, must be &gt;= 0
+     * @return the bytes read from input
+     * @throws IOException              if there is a problem reading the file
+     * @throws IllegalArgumentException if length is negative
+     * @throws EOFException             if the number of bytes read was incorrect
+     * @since 2.5
+     */
+    public static byte[] readFully(final InputStream input, final int length) throws IOException {
+        final byte[] buffer = IOUtils.byteArray(length);
+        readFully(input, buffer, 0, buffer.length);
+        return buffer;
+    }
+
+    /**
+     * Reads the requested number of bytes or fail if there are not enough left.
+     * <p>
+     * This allows for the possibility that {@link ReadableByteChannel#read(ByteBuffer)} may
+     * not read as many bytes as requested (most likely because of reaching EOF).
+     * </p>
+     *
+     * @param input the byte channel to read
+     * @param buffer byte buffer destination
+     * @throws IOException  if there is a problem reading the file
+     * @throws EOFException if the number of bytes read was incorrect
+     * @since 2.5
+     */
+    public static void readFully(final ReadableByteChannel input, final ByteBuffer buffer) throws IOException {
+        final int expected = buffer.remaining();
+        final int actual = read(input, buffer);
+        if (actual != expected) {
+            throw new EOFException("Length to read: " + expected + " actual: " + actual);
+        }
+    }
+
+    /**
+     * Reads the requested number of characters or fail if there are not enough left.
+     * <p>
+     * This allows for the possibility that {@link Reader#read(char[], int, int)} may
+     * not read as many characters as requested (most likely because of reaching EOF).
+     * </p>
+     *
+     * @param reader where to read input from
+     * @param buffer destination
+     * @throws IOException              if there is a problem reading the file
+     * @throws IllegalArgumentException if length is negative
+     * @throws EOFException             if the number of characters read was incorrect
+     * @since 2.2
+     */
+    public static void readFully(final Reader reader, final char[] buffer) throws IOException {
+        readFully(reader, buffer, 0, buffer.length);
+    }
+
+    /**
+     * Reads the requested number of characters or fail if there are not enough left.
+     * <p>
+     * This allows for the possibility that {@link Reader#read(char[], int, int)} may
+     * not read as many characters as requested (most likely because of reaching EOF).
+     * </p>
+     *
+     * @param reader where to read input from
+     * @param buffer destination
+     * @param offset initial offset into buffer
+     * @param length length to read, must be &gt;= 0
+     * @throws IOException              if there is a problem reading the file
+     * @throws IllegalArgumentException if length is negative
+     * @throws EOFException             if the number of characters read was incorrect
+     * @since 2.2
+     */
+    public static void readFully(final Reader reader, final char[] buffer, final int offset, final int length)
+            throws IOException {
+        final int actual = read(reader, buffer, offset, length);
+        if (actual != length) {
+            throw new EOFException("Length to read: " + length + " actual: " + actual);
+        }
+    }
+
+    /**
+     * Gets the contents of an {@link InputStream} as a list of Strings,
+     * one entry per line, using the default character encoding of the platform.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     *
+     * @param input the {@link InputStream} to read from, not null
+     * @return the list of Strings, never null
+     * @throws NullPointerException if the input is null
+     * @throws UncheckedIOException if an I/O error occurs
+     * @since 1.1
+     * @deprecated 2.5 use {@link #readLines(InputStream, Charset)} instead
+     */
+    @Deprecated
+    public static List<String> readLines(final InputStream input) throws UncheckedIOException {
+        return readLines(input, Charset.defaultCharset());
+    }
+
+    /**
+     * Gets the contents of an {@link InputStream} as a list of Strings,
+     * one entry per line, using the specified character encoding.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     *
+     * @param input the {@link InputStream} to read from, not null
+     * @param charset the charset to use, null means platform default
+     * @return the list of Strings, never null
+     * @throws NullPointerException if the input is null
+     * @throws UncheckedIOException if an I/O error occurs
+     * @since 2.3
+     */
+    public static List<String> readLines(final InputStream input, final Charset charset) throws UncheckedIOException {
+        return readLines(new InputStreamReader(input, Charsets.toCharset(charset)));
+    }
+
+    /**
+     * Gets the contents of an {@link InputStream} as a list of Strings,
+     * one entry per line, using the specified character encoding.
+     * <p>
+     * Character encoding names can be found at
+     * <a href="http://www.iana.org/assignments/character-sets">IANA</a>.
+     * </p>
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     *
+     * @param input the {@link InputStream} to read from, not null
+     * @param charsetName the name of the requested charset, null means platform default
+     * @return the list of Strings, never null
+     * @throws NullPointerException                         if the input is null
+     * @throws UncheckedIOException                         if an I/O error occurs
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     *                                                      .UnsupportedEncodingException} in version 2.2 if the
+     *                                                      encoding is not supported.
+     * @since 1.1
+     */
+    public static List<String> readLines(final InputStream input, final String charsetName) throws UncheckedIOException {
+        return readLines(input, Charsets.toCharset(charsetName));
+    }
+
+    /**
+     * Gets the contents of a {@link Reader} as a list of Strings,
+     * one entry per line.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedReader}.
+     * </p>
+     *
+     * @param reader the {@link Reader} to read from, not null
+     * @return the list of Strings, never null
+     * @throws NullPointerException if the input is null
+     * @throws UncheckedIOException if an I/O error occurs
+     * @since 1.1
+     */
+    @SuppressWarnings("resource") // reader wraps input and is the responsibility of the caller.
+    public static List<String> readLines(final Reader reader) throws UncheckedIOException {
+        return toBufferedReader(reader).lines().collect(Collectors.toList());
+    }
+
+    /**
+     * Gets the contents of a resource as a byte array.
+     * <p>
+     * Delegates to {@link #resourceToByteArray(String, ClassLoader) resourceToByteArray(String, null)}.
+     * </p>
+     *
+     * @param name The resource name.
+     * @return the requested byte array
+     * @throws IOException if an I/O error occurs or the resource is not found.
+     * @see #resourceToByteArray(String, ClassLoader)
+     * @since 2.6
+     */
+    public static byte[] resourceToByteArray(final String name) throws IOException {
+        return resourceToByteArray(name, null);
+    }
+
+    /**
+     * Gets the contents of a resource as a byte array.
+     * <p>
+     * Delegates to {@link #resourceToURL(String, ClassLoader)}.
+     * </p>
+     *
+     * @param name The resource name.
+     * @param classLoader the class loader that the resolution of the resource is delegated to
+     * @return the requested byte array
+     * @throws IOException if an I/O error occurs or the resource is not found.
+     * @see #resourceToURL(String, ClassLoader)
+     * @since 2.6
+     */
+    public static byte[] resourceToByteArray(final String name, final ClassLoader classLoader) throws IOException {
+        return toByteArray(resourceToURL(name, classLoader));
+    }
+
+    /**
+     * Gets the contents of a resource as a String using the specified character encoding.
+     * <p>
+     * Delegates to {@link #resourceToString(String, Charset, ClassLoader) resourceToString(String, Charset, null)}.
+     * </p>
+     *
+     * @param name The resource name.
+     * @param charset the charset to use, null means platform default
+     * @return the requested String
+     * @throws IOException if an I/O error occurs or the resource is not found.
+     * @see #resourceToString(String, Charset, ClassLoader)
+     * @since 2.6
+     */
+    public static String resourceToString(final String name, final Charset charset) throws IOException {
+        return resourceToString(name, charset, null);
+    }
+
+    /**
+     * Gets the contents of a resource as a String using the specified character encoding.
+     * <p>
+     * Delegates to {@link #resourceToURL(String, ClassLoader)}.
+     * </p>
+     *
+     * @param name The resource name.
+     * @param charset the Charset to use, null means platform default
+     * @param classLoader the class loader that the resolution of the resource is delegated to
+     * @return the requested String
+     * @throws IOException if an I/O error occurs.
+     * @see #resourceToURL(String, ClassLoader)
+     * @since 2.6
+     */
+    public static String resourceToString(final String name, final Charset charset, final ClassLoader classLoader) throws IOException {
+        return toString(resourceToURL(name, classLoader), charset);
+    }
+
+    /**
+     * Gets a URL pointing to the given resource.
+     * <p>
+     * Delegates to {@link #resourceToURL(String, ClassLoader) resourceToURL(String, null)}.
+     * </p>
+     *
+     * @param name The resource name.
+     * @return A URL object for reading the resource.
+     * @throws IOException if the resource is not found.
+     * @since 2.6
+     */
+    public static URL resourceToURL(final String name) throws IOException {
+        return resourceToURL(name, null);
+    }
+
+    /**
+     * Gets a URL pointing to the given resource.
+     * <p>
+     * If the {@code classLoader} is not null, call {@link ClassLoader#getResource(String)}, otherwise call
+     * {@link Class#getResource(String) IOUtils.class.getResource(name)}.
+     * </p>
+     *
+     * @param name The resource name.
+     * @param classLoader Delegate to this class loader if not null
+     * @return A URL object for reading the resource.
+     * @throws IOException if the resource is not found.
+     * @since 2.6
+     */
+    public static URL resourceToURL(final String name, final ClassLoader classLoader) throws IOException {
+        // What about the thread context class loader?
+        // What about the system class loader?
+        final URL resource = classLoader == null ? IOUtils.class.getResource(name) : classLoader.getResource(name);
+        if (resource == null) {
+            throw new IOException("Resource not found: " + name);
+        }
+        return resource;
+    }
+
+    /**
+     * Skips bytes from an input byte stream.
+     * This implementation guarantees that it will read as many bytes
+     * as possible before giving up; this may not always be the case for
+     * skip() implementations in subclasses of {@link InputStream}.
+     * <p>
+     * Note that the implementation uses {@link InputStream#read(byte[], int, int)} rather
+     * than delegating to {@link InputStream#skip(long)}.
+     * This means that the method may be considerably less efficient than using the actual skip implementation,
+     * this is done to guarantee that the correct number of bytes are skipped.
+     * </p>
+     *
+     * @param input byte stream to skip
+     * @param toSkip number of bytes to skip.
+     * @return number of bytes actually skipped.
+     * @throws IOException              if there is a problem reading the file
+     * @throws IllegalArgumentException if toSkip is negative
+     * @see InputStream#skip(long)
+     * @see <a href="https://issues.apache.org/jira/browse/IO-203">IO-203 - Add skipFully() method for InputStreams</a>
+     * @since 2.0
+     */
+    public static long skip(final InputStream input, final long toSkip) throws IOException {
+        if (toSkip < 0) {
+            throw new IllegalArgumentException("Skip count must be non-negative, actual: " + toSkip);
+        }
+        /*
+         * N.B. no need to synchronize access to SKIP_BYTE_BUFFER: - we don't care if the buffer is created multiple
+         * times (the data is ignored) - we always use the same size buffer, so if it is recreated it will still be
+         * OK (if the buffer size were variable, we would need to synch. to ensure some other thread did not create a
+         * smaller one)
+         */
+        long remain = toSkip;
+        while (remain > 0) {
+            // See https://issues.apache.org/jira/browse/IO-203 for why we use read() rather than delegating to skip()
+            final byte[] byteArray = getByteArray();
+            final long n = input.read(byteArray, 0, (int) Math.min(remain, byteArray.length));
+            if (n < 0) { // EOF
+                break;
+            }
+            remain -= n;
+        }
+        return toSkip - remain;
+    }
+
+    /**
+     * Skips bytes from a ReadableByteChannel.
+     * This implementation guarantees that it will read as many bytes
+     * as possible before giving up.
+     *
+     * @param input ReadableByteChannel to skip
+     * @param toSkip number of bytes to skip.
+     * @return number of bytes actually skipped.
+     * @throws IOException              if there is a problem reading the ReadableByteChannel
+     * @throws IllegalArgumentException if toSkip is negative
+     * @since 2.5
+     */
+    public static long skip(final ReadableByteChannel input, final long toSkip) throws IOException {
+        if (toSkip < 0) {
+            throw new IllegalArgumentException("Skip count must be non-negative, actual: " + toSkip);
+        }
+        final ByteBuffer skipByteBuffer = ByteBuffer.allocate((int) Math.min(toSkip, DEFAULT_BUFFER_SIZE));
+        long remain = toSkip;
+        while (remain > 0) {
+            skipByteBuffer.position(0);
+            skipByteBuffer.limit((int) Math.min(remain, DEFAULT_BUFFER_SIZE));
+            final int n = input.read(skipByteBuffer);
+            if (n == EOF) {
+                break;
+            }
+            remain -= n;
+        }
+        return toSkip - remain;
+    }
+
+    /**
+     * Skips characters from an input character stream.
+     * This implementation guarantees that it will read as many characters
+     * as possible before giving up; this may not always be the case for
+     * skip() implementations in subclasses of {@link Reader}.
+     * <p>
+     * Note that the implementation uses {@link Reader#read(char[], int, int)} rather
+     * than delegating to {@link Reader#skip(long)}.
+     * This means that the method may be considerably less efficient than using the actual skip implementation,
+     * this is done to guarantee that the correct number of characters are skipped.
+     * </p>
+     *
+     * @param reader character stream to skip
+     * @param toSkip number of characters to skip.
+     * @return number of characters actually skipped.
+     * @throws IOException              if there is a problem reading the file
+     * @throws IllegalArgumentException if toSkip is negative
+     * @see Reader#skip(long)
+     * @see <a href="https://issues.apache.org/jira/browse/IO-203">IO-203 - Add skipFully() method for InputStreams</a>
+     * @since 2.0
+     */
+    public static long skip(final Reader reader, final long toSkip) throws IOException {
+        if (toSkip < 0) {
+            throw new IllegalArgumentException("Skip count must be non-negative, actual: " + toSkip);
+        }
+        long remain = toSkip;
+        while (remain > 0) {
+            // See https://issues.apache.org/jira/browse/IO-203 for why we use read() rather than delegating to skip()
+            final char[] charArray = getCharArray();
+            final long n = reader.read(charArray, 0, (int) Math.min(remain, charArray.length));
+            if (n < 0) { // EOF
+                break;
+            }
+            remain -= n;
+        }
+        return toSkip - remain;
+    }
+
+    /**
+     * Skips the requested number of bytes or fail if there are not enough left.
+     * <p>
+     * This allows for the possibility that {@link InputStream#skip(long)} may
+     * not skip as many bytes as requested (most likely because of reaching EOF).
+     * </p>
+     * <p>
+     * Note that the implementation uses {@link #skip(InputStream, long)}.
+     * This means that the method may be considerably less efficient than using the actual skip implementation,
+     * this is done to guarantee that the correct number of characters are skipped.
+     * </p>
+     *
+     * @param input stream to skip
+     * @param toSkip the number of bytes to skip
+     * @throws IOException              if there is a problem reading the file
+     * @throws IllegalArgumentException if toSkip is negative
+     * @throws EOFException             if the number of bytes skipped was incorrect
+     * @see InputStream#skip(long)
+     * @since 2.0
+     */
+    public static void skipFully(final InputStream input, final long toSkip) throws IOException {
+        if (toSkip < 0) {
+            throw new IllegalArgumentException("Bytes to skip must not be negative: " + toSkip);
+        }
+        final long skipped = skip(input, toSkip);
+        if (skipped != toSkip) {
+            throw new EOFException("Bytes to skip: " + toSkip + " actual: " + skipped);
+        }
+    }
+
+    /**
+     * Skips the requested number of bytes or fail if there are not enough left.
+     *
+     * @param input ReadableByteChannel to skip
+     * @param toSkip the number of bytes to skip
+     * @throws IOException              if there is a problem reading the ReadableByteChannel
+     * @throws IllegalArgumentException if toSkip is negative
+     * @throws EOFException             if the number of bytes skipped was incorrect
+     * @since 2.5
+     */
+    public static void skipFully(final ReadableByteChannel input, final long toSkip) throws IOException {
+        if (toSkip < 0) {
+            throw new IllegalArgumentException("Bytes to skip must not be negative: " + toSkip);
+        }
+        final long skipped = skip(input, toSkip);
+        if (skipped != toSkip) {
+            throw new EOFException("Bytes to skip: " + toSkip + " actual: " + skipped);
+        }
+    }
+
+    /**
+     * Skips the requested number of characters or fail if there are not enough left.
+     * <p>
+     * This allows for the possibility that {@link Reader#skip(long)} may
+     * not skip as many characters as requested (most likely because of reaching EOF).
+     * </p>
+     * <p>
+     * Note that the implementation uses {@link #skip(Reader, long)}.
+     * This means that the method may be considerably less efficient than using the actual skip implementation,
+     * this is done to guarantee that the correct number of characters are skipped.
+     * </p>
+     *
+     * @param reader stream to skip
+     * @param toSkip the number of characters to skip
+     * @throws IOException              if there is a problem reading the file
+     * @throws IllegalArgumentException if toSkip is negative
+     * @throws EOFException             if the number of characters skipped was incorrect
+     * @see Reader#skip(long)
+     * @since 2.0
+     */
+    public static void skipFully(final Reader reader, final long toSkip) throws IOException {
+        final long skipped = skip(reader, toSkip);
+        if (skipped != toSkip) {
+            throw new EOFException("Chars to skip: " + toSkip + " actual: " + skipped);
+        }
+    }
+
+    /**
+     * Fetches entire contents of an {@link InputStream} and represent
+     * same data as result InputStream.
+     * <p>
+     * This method is useful where,
+     * </p>
+     * <ul>
+     * <li>Source InputStream is slow.</li>
+     * <li>It has network resources associated, so we cannot keep it open for
+     * long time.</li>
+     * <li>It has network timeout associated.</li>
+     * </ul>
+     * <p>
+     * It can be used in favor of {@link #toByteArray(InputStream)}, since it
+     * avoids unnecessary allocation and copy of byte[].<br>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     *
+     * @param input Stream to be fully buffered.
+     * @return A fully buffered stream.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.0
+     */
+    public static InputStream toBufferedInputStream(final InputStream input) throws IOException {
+        return ByteArrayOutputStream.toBufferedInputStream(input);
+    }
+
+    /**
+     * Fetches entire contents of an {@link InputStream} and represent
+     * same data as result InputStream.
+     * <p>
+     * This method is useful where,
+     * </p>
+     * <ul>
+     * <li>Source InputStream is slow.</li>
+     * <li>It has network resources associated, so we cannot keep it open for
+     * long time.</li>
+     * <li>It has network timeout associated.</li>
+     * </ul>
+     * <p>
+     * It can be used in favor of {@link #toByteArray(InputStream)}, since it
+     * avoids unnecessary allocation and copy of byte[].<br>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     *
+     * @param input Stream to be fully buffered.
+     * @param size the initial buffer size
+     * @return A fully buffered stream.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.5
+     */
+    public static InputStream toBufferedInputStream(final InputStream input, final int size) throws IOException {
+        return ByteArrayOutputStream.toBufferedInputStream(input, size);
+    }
+
+    /**
+     * Returns the given reader if it is a {@link BufferedReader}, otherwise creates a BufferedReader from the given
+     * reader.
+     *
+     * @param reader the reader to wrap or return (not null)
+     * @return the given reader or a new {@link BufferedReader} for the given reader
+     * @throws NullPointerException if the input parameter is null
+     * @see #buffer(Reader)
+     * @since 2.2
+     */
+    public static BufferedReader toBufferedReader(final Reader reader) {
+        return reader instanceof BufferedReader ? (BufferedReader) reader : new BufferedReader(reader);
+    }
+
+    /**
+     * Returns the given reader if it is a {@link BufferedReader}, otherwise creates a BufferedReader from the given
+     * reader.
+     *
+     * @param reader the reader to wrap or return (not null)
+     * @param size the buffer size, if a new BufferedReader is created.
+     * @return the given reader or a new {@link BufferedReader} for the given reader
+     * @throws NullPointerException if the input parameter is null
+     * @see #buffer(Reader)
+     * @since 2.5
+     */
+    public static BufferedReader toBufferedReader(final Reader reader, final int size) {
+        return reader instanceof BufferedReader ? (BufferedReader) reader : new BufferedReader(reader, size);
+    }
+
+    /**
+     * Gets the contents of an {@link InputStream} as a {@code byte[]}.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     *
+     * @param inputStream the {@link InputStream} to read.
+     * @return the requested byte array.
+     * @throws NullPointerException if the InputStream is {@code null}.
+     * @throws IOException if an I/O error occurs or reading more than {@link Integer#MAX_VALUE} occurs.
+     */
+    public static byte[] toByteArray(final InputStream inputStream) throws IOException {
+        // We use a ThresholdingOutputStream to avoid reading AND writing more than Integer.MAX_VALUE.
+        try (UnsynchronizedByteArrayOutputStream ubaOutput = new UnsynchronizedByteArrayOutputStream();
+            ThresholdingOutputStream thresholdOutput = new ThresholdingOutputStream(Integer.MAX_VALUE, os -> {
+                throw new IllegalArgumentException(String.format("Cannot read more than %,d into a byte array", Integer.MAX_VALUE));
+            }, os -> ubaOutput)) {
+            copy(inputStream, thresholdOutput);
+            return ubaOutput.toByteArray();
+        }
+    }
+
+    /**
+     * Gets the contents of an {@link InputStream} as a {@code byte[]}. Use this method instead of
+     * {@link #toByteArray(InputStream)} when {@link InputStream} size is known.
+     *
+     * @param input the {@link InputStream} to read.
+     * @param size the size of {@link InputStream} to read, where 0 &lt; {@code size} &lt;= length of input stream.
+     * @return byte [] of length {@code size}.
+     * @throws IOException if an I/O error occurs or {@link InputStream} length is smaller than parameter {@code size}.
+     * @throws IllegalArgumentException if {@code size} is less than zero.
+     * @since 2.1
+     */
+    public static byte[] toByteArray(final InputStream input, final int size) throws IOException {
+
+        if (size < 0) {
+            throw new IllegalArgumentException("Size must be equal or greater than zero: " + size);
+        }
+
+        if (size == 0) {
+            return EMPTY_BYTE_ARRAY;
+        }
+
+        final byte[] data = IOUtils.byteArray(size);
+        int offset = 0;
+        int read;
+
+        while (offset < size && (read = input.read(data, offset, size - offset)) != EOF) {
+            offset += read;
+        }
+
+        if (offset != size) {
+            throw new IOException("Unexpected read size, current: " + offset + ", expected: " + size);
+        }
+
+        return data;
+    }
+
+    /**
+     * Gets contents of an {@link InputStream} as a {@code byte[]}.
+     * Use this method instead of {@link #toByteArray(InputStream)}
+     * when {@link InputStream} size is known.
+     * <b>NOTE:</b> the method checks that the length can safely be cast to an int without truncation
+     * before using {@link IOUtils#toByteArray(InputStream, int)} to read into the byte array.
+     * (Arrays can have no more than Integer.MAX_VALUE entries anyway)
+     *
+     * @param input the {@link InputStream} to read from
+     * @param size the size of {@link InputStream} to read, where 0 &lt; {@code size} &lt;= min(Integer.MAX_VALUE, length of input stream).
+     * @return byte [] the requested byte array, of length {@code size}
+     * @throws IOException              if an I/O error occurs or {@link InputStream} length is less than {@code size}
+     * @throws IllegalArgumentException if size is less than zero or size is greater than Integer.MAX_VALUE
+     * @see IOUtils#toByteArray(InputStream, int)
+     * @since 2.1
+     */
+    public static byte[] toByteArray(final InputStream input, final long size) throws IOException {
+        if (size > Integer.MAX_VALUE) {
+            throw new IllegalArgumentException("Size cannot be greater than Integer max value: " + size);
+        }
+        return toByteArray(input, (int) size);
+    }
+
+    /**
+     * Gets the contents of a {@link Reader} as a {@code byte[]}
+     * using the default character encoding of the platform.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedReader}.
+     * </p>
+     *
+     * @param reader the {@link Reader} to read from
+     * @return the requested byte array
+     * @throws NullPointerException if the input is null
+     * @throws IOException          if an I/O error occurs
+     * @deprecated 2.5 use {@link #toByteArray(Reader, Charset)} instead
+     */
+    @Deprecated
+    public static byte[] toByteArray(final Reader reader) throws IOException {
+        return toByteArray(reader, Charset.defaultCharset());
+    }
+
+    /**
+     * Gets the contents of a {@link Reader} as a {@code byte[]}
+     * using the specified character encoding.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedReader}.
+     * </p>
+     *
+     * @param reader the {@link Reader} to read from
+     * @param charset the charset to use, null means platform default
+     * @return the requested byte array
+     * @throws NullPointerException if the input is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.3
+     */
+    public static byte[] toByteArray(final Reader reader, final Charset charset) throws IOException {
+        try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+            copy(reader, output, charset);
+            return output.toByteArray();
+        }
+    }
+
+    /**
+     * Gets the contents of a {@link Reader} as a {@code byte[]}
+     * using the specified character encoding.
+     * <p>
+     * Character encoding names can be found at
+     * <a href="http://www.iana.org/assignments/character-sets">IANA</a>.
+     * </p>
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedReader}.
+     * </p>
+     *
+     * @param reader the {@link Reader} to read from
+     * @param charsetName the name of the requested charset, null means platform default
+     * @return the requested byte array
+     * @throws NullPointerException                         if the input is null
+     * @throws IOException                                  if an I/O error occurs
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     *                                                      .UnsupportedEncodingException} in version 2.2 if the
+     *                                                      encoding is not supported.
+     * @since 1.1
+     */
+    public static byte[] toByteArray(final Reader reader, final String charsetName) throws IOException {
+        return toByteArray(reader, Charsets.toCharset(charsetName));
+    }
+
+    /**
+     * Gets the contents of a {@link String} as a {@code byte[]}
+     * using the default character encoding of the platform.
+     * <p>
+     * This is the same as {@link String#getBytes()}.
+     * </p>
+     *
+     * @param input the {@link String} to convert
+     * @return the requested byte array
+     * @throws NullPointerException if the input is null
+     * @deprecated 2.5 Use {@link String#getBytes()} instead
+     */
+    @Deprecated
+    public static byte[] toByteArray(final String input) {
+        // make explicit the use of the default charset
+        return input.getBytes(Charset.defaultCharset());
+    }
+
+    /**
+     * Gets the contents of a {@link URI} as a {@code byte[]}.
+     *
+     * @param uri the {@link URI} to read
+     * @return the requested byte array
+     * @throws NullPointerException if the uri is null
+     * @throws IOException          if an I/O exception occurs
+     * @since 2.4
+     */
+    public static byte[] toByteArray(final URI uri) throws IOException {
+        return IOUtils.toByteArray(uri.toURL());
+    }
+
+    /**
+     * Gets the contents of a {@link URL} as a {@code byte[]}.
+     *
+     * @param url the {@link URL} to read
+     * @return the requested byte array
+     * @throws NullPointerException if the input is null
+     * @throws IOException          if an I/O exception occurs
+     * @since 2.4
+     */
+    public static byte[] toByteArray(final URL url) throws IOException {
+        try (CloseableURLConnection urlConnection = CloseableURLConnection.open(url)) {
+            return IOUtils.toByteArray(urlConnection);
+        }
+    }
+
+    /**
+     * Gets the contents of a {@link URLConnection} as a {@code byte[]}.
+     *
+     * @param urlConnection the {@link URLConnection} to read.
+     * @return the requested byte array.
+     * @throws NullPointerException if the urlConn is null.
+     * @throws IOException if an I/O exception occurs.
+     * @since 2.4
+     */
+    public static byte[] toByteArray(final URLConnection urlConnection) throws IOException {
+        try (InputStream inputStream = urlConnection.getInputStream()) {
+            return IOUtils.toByteArray(inputStream);
+        }
+    }
+
+    /**
+     * Gets the contents of an {@link InputStream} as a character array
+     * using the default character encoding of the platform.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     *
+     * @param inputStream the {@link InputStream} to read from
+     * @return the requested character array
+     * @throws NullPointerException if the input is null
+     * @throws IOException          if an I/O error occurs
+     * @since 1.1
+     * @deprecated 2.5 use {@link #toCharArray(InputStream, Charset)} instead
+     */
+    @Deprecated
+    public static char[] toCharArray(final InputStream inputStream) throws IOException {
+        return toCharArray(inputStream, Charset.defaultCharset());
+    }
+
+    /**
+     * Gets the contents of an {@link InputStream} as a character array
+     * using the specified character encoding.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     *
+     * @param inputStream the {@link InputStream} to read from
+     * @param charset the charset to use, null means platform default
+     * @return the requested character array
+     * @throws NullPointerException if the input is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.3
+     */
+    public static char[] toCharArray(final InputStream inputStream, final Charset charset)
+            throws IOException {
+        final CharArrayWriter writer = new CharArrayWriter();
+        copy(inputStream, writer, charset);
+        return writer.toCharArray();
+    }
+
+    /**
+     * Gets the contents of an {@link InputStream} as a character array
+     * using the specified character encoding.
+     * <p>
+     * Character encoding names can be found at
+     * <a href="http://www.iana.org/assignments/character-sets">IANA</a>.
+     * </p>
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     *
+     * @param inputStream the {@link InputStream} to read from
+     * @param charsetName the name of the requested charset, null means platform default
+     * @return the requested character array
+     * @throws NullPointerException                         if the input is null
+     * @throws IOException                                  if an I/O error occurs
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     *                                                      .UnsupportedEncodingException} in version 2.2 if the
+     *                                                      encoding is not supported.
+     * @since 1.1
+     */
+    public static char[] toCharArray(final InputStream inputStream, final String charsetName) throws IOException {
+        return toCharArray(inputStream, Charsets.toCharset(charsetName));
+    }
+
+    /**
+     * Gets the contents of a {@link Reader} as a character array.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedReader}.
+     * </p>
+     *
+     * @param reader the {@link Reader} to read from
+     * @return the requested character array
+     * @throws NullPointerException if the input is null
+     * @throws IOException          if an I/O error occurs
+     * @since 1.1
+     */
+    public static char[] toCharArray(final Reader reader) throws IOException {
+        final CharArrayWriter sw = new CharArrayWriter();
+        copy(reader, sw);
+        return sw.toCharArray();
+    }
+
+    /**
+     * Converts the specified CharSequence to an input stream, encoded as bytes
+     * using the default character encoding of the platform.
+     *
+     * @param input the CharSequence to convert
+     * @return an input stream
+     * @since 2.0
+     * @deprecated 2.5 use {@link #toInputStream(CharSequence, Charset)} instead
+     */
+    @Deprecated
+    public static InputStream toInputStream(final CharSequence input) {
+        return toInputStream(input, Charset.defaultCharset());
+    }
+
+    /**
+     * Converts the specified CharSequence to an input stream, encoded as bytes
+     * using the specified character encoding.
+     *
+     * @param input the CharSequence to convert
+     * @param charset the charset to use, null means platform default
+     * @return an input stream
+     * @since 2.3
+     */
+    public static InputStream toInputStream(final CharSequence input, final Charset charset) {
+        return toInputStream(input.toString(), charset);
+    }
+
+    /**
+     * Converts the specified CharSequence to an input stream, encoded as bytes
+     * using the specified character encoding.
+     * <p>
+     * Character encoding names can be found at
+     * <a href="http://www.iana.org/assignments/character-sets">IANA</a>.
+     * </p>
+     *
+     * @param input the CharSequence to convert
+     * @param charsetName the name of the requested charset, null means platform default
+     * @return an input stream
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     *                                                      .UnsupportedEncodingException} in version 2.2 if the
+     *                                                      encoding is not supported.
+     * @since 2.0
+     */
+    public static InputStream toInputStream(final CharSequence input, final String charsetName) {
+        return toInputStream(input, Charsets.toCharset(charsetName));
+    }
+
+    /**
+     * Converts the specified string to an input stream, encoded as bytes
+     * using the default character encoding of the platform.
+     *
+     * @param input the string to convert
+     * @return an input stream
+     * @since 1.1
+     * @deprecated 2.5 use {@link #toInputStream(String, Charset)} instead
+     */
+    @Deprecated
+    public static InputStream toInputStream(final String input) {
+        return toInputStream(input, Charset.defaultCharset());
+    }
+
+    /**
+     * Converts the specified string to an input stream, encoded as bytes
+     * using the specified character encoding.
+     *
+     * @param input the string to convert
+     * @param charset the charset to use, null means platform default
+     * @return an input stream
+     * @since 2.3
+     */
+    public static InputStream toInputStream(final String input, final Charset charset) {
+        return new ByteArrayInputStream(input.getBytes(Charsets.toCharset(charset)));
+    }
+
+    /**
+     * Converts the specified string to an input stream, encoded as bytes
+     * using the specified character encoding.
+     * <p>
+     * Character encoding names can be found at
+     * <a href="http://www.iana.org/assignments/character-sets">IANA</a>.
+     * </p>
+     *
+     * @param input the string to convert
+     * @param charsetName the name of the requested charset, null means platform default
+     * @return an input stream
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     *                                                      .UnsupportedEncodingException} in version 2.2 if the
+     *                                                      encoding is not supported.
+     * @since 1.1
+     */
+    public static InputStream toInputStream(final String input, final String charsetName) {
+        return new ByteArrayInputStream(input.getBytes(Charsets.toCharset(charsetName)));
+    }
+
+    /**
+     * Gets the contents of a {@code byte[]} as a String
+     * using the default character encoding of the platform.
+     *
+     * @param input the byte array to read from
+     * @return the requested String
+     * @throws NullPointerException if the input is null
+     * @deprecated 2.5 Use {@link String#String(byte[])} instead
+     */
+    @Deprecated
+    public static String toString(final byte[] input) {
+        // make explicit the use of the default charset
+        return new String(input, Charset.defaultCharset());
+    }
+
+    /**
+     * Gets the contents of a {@code byte[]} as a String
+     * using the specified character encoding.
+     * <p>
+     * Character encoding names can be found at
+     * <a href="http://www.iana.org/assignments/character-sets">IANA</a>.
+     * </p>
+     *
+     * @param input the byte array to read from
+     * @param charsetName the name of the requested charset, null means platform default
+     * @return the requested String
+     * @throws NullPointerException if the input is null
+     */
+    public static String toString(final byte[] input, final String charsetName) {
+        return new String(input, Charsets.toCharset(charsetName));
+    }
+
+    /**
+     * Gets the contents of an {@link InputStream} as a String
+     * using the default character encoding of the platform.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     *
+     * @param input the {@link InputStream} to read from
+     * @return the requested String
+     * @throws NullPointerException if the input is null
+     * @throws IOException          if an I/O error occurs
+     * @deprecated 2.5 use {@link #toString(InputStream, Charset)} instead
+     */
+    @Deprecated
+    public static String toString(final InputStream input) throws IOException {
+        return toString(input, Charset.defaultCharset());
+    }
+
+    /**
+     * Gets the contents of an {@link InputStream} as a String
+     * using the specified character encoding.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     *
+     * @param input the {@link InputStream} to read from
+     * @param charset the charset to use, null means platform default
+     * @return the requested String
+     * @throws NullPointerException if the input is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.3
+     */
+    public static String toString(final InputStream input, final Charset charset) throws IOException {
+        try (StringBuilderWriter sw = new StringBuilderWriter()) {
+            copy(input, sw, charset);
+            return sw.toString();
+        }
+    }
+
+    /**
+     * Gets the contents of an {@link InputStream} as a String
+     * using the specified character encoding.
+     * <p>
+     * Character encoding names can be found at
+     * <a href="http://www.iana.org/assignments/character-sets">IANA</a>.
+     * </p>
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     * </p>
+     *
+     * @param input the {@link InputStream} to read from
+     * @param charsetName the name of the requested charset, null means platform default
+     * @return the requested String
+     * @throws NullPointerException                         if the input is null
+     * @throws IOException                                  if an I/O error occurs
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     *                                                      .UnsupportedEncodingException} in version 2.2 if the
+     *                                                      encoding is not supported.
+     */
+    public static String toString(final InputStream input, final String charsetName)
+            throws IOException {
+        return toString(input, Charsets.toCharset(charsetName));
+    }
+
+    /**
+     * Gets the contents of a {@link Reader} as a String.
+     * <p>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedReader}.
+     * </p>
+     *
+     * @param reader the {@link Reader} to read from
+     * @return the requested String
+     * @throws NullPointerException if the input is null
+     * @throws IOException          if an I/O error occurs
+     */
+    public static String toString(final Reader reader) throws IOException {
+        try (StringBuilderWriter sw = new StringBuilderWriter()) {
+            copy(reader, sw);
+            return sw.toString();
+        }
+    }
+
+    /**
+     * Gets the contents at the given URI.
+     *
+     * @param uri The URI source.
+     * @return The contents of the URL as a String.
+     * @throws IOException if an I/O exception occurs.
+     * @since 2.1
+     * @deprecated 2.5 use {@link #toString(URI, Charset)} instead
+     */
+    @Deprecated
+    public static String toString(final URI uri) throws IOException {
+        return toString(uri, Charset.defaultCharset());
+    }
+
+    /**
+     * Gets the contents at the given URI.
+     *
+     * @param uri The URI source.
+     * @param encoding The encoding name for the URL contents.
+     * @return The contents of the URL as a String.
+     * @throws IOException if an I/O exception occurs.
+     * @since 2.3.
+     */
+    public static String toString(final URI uri, final Charset encoding) throws IOException {
+        return toString(uri.toURL(), Charsets.toCharset(encoding));
+    }
+
+    /**
+     * Gets the contents at the given URI.
+     *
+     * @param uri The URI source.
+     * @param charsetName The encoding name for the URL contents.
+     * @return The contents of the URL as a String.
+     * @throws IOException                                  if an I/O exception occurs.
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     *                                                      .UnsupportedEncodingException} in version 2.2 if the
+     *                                                      encoding is not supported.
+     * @since 2.1
+     */
+    public static String toString(final URI uri, final String charsetName) throws IOException {
+        return toString(uri, Charsets.toCharset(charsetName));
+    }
+
+    /**
+     * Gets the contents at the given URL.
+     *
+     * @param url The URL source.
+     * @return The contents of the URL as a String.
+     * @throws IOException if an I/O exception occurs.
+     * @since 2.1
+     * @deprecated 2.5 use {@link #toString(URL, Charset)} instead
+     */
+    @Deprecated
+    public static String toString(final URL url) throws IOException {
+        return toString(url, Charset.defaultCharset());
+    }
+
+    /**
+     * Gets the contents at the given URL.
+     *
+     * @param url The URL source.
+     * @param encoding The encoding name for the URL contents.
+     * @return The contents of the URL as a String.
+     * @throws IOException if an I/O exception occurs.
+     * @since 2.3
+     */
+    public static String toString(final URL url, final Charset encoding) throws IOException {
+        try (InputStream inputStream = url.openStream()) {
+            return toString(inputStream, encoding);
+        }
+    }
+
+    /**
+     * Gets the contents at the given URL.
+     *
+     * @param url The URL source.
+     * @param charsetName The encoding name for the URL contents.
+     * @return The contents of the URL as a String.
+     * @throws IOException                                  if an I/O exception occurs.
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     *                                                      .UnsupportedEncodingException} in version 2.2 if the
+     *                                                      encoding is not supported.
+     * @since 2.1
+     */
+    public static String toString(final URL url, final String charsetName) throws IOException {
+        return toString(url, Charsets.toCharset(charsetName));
+    }
+
+    /**
+     * Writes bytes from a {@code byte[]} to an {@link OutputStream}.
+     *
+     * @param data the byte array to write, do not modify during output,
+     * null ignored
+     * @param output the {@link OutputStream} to write to
+     * @throws NullPointerException if output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 1.1
+     */
+    public static void write(final byte[] data, final OutputStream output)
+            throws IOException {
+        if (data != null) {
+            output.write(data);
+        }
+    }
+
+    /**
+     * Writes bytes from a {@code byte[]} to chars on a {@link Writer}
+     * using the default character encoding of the platform.
+     * <p>
+     * This method uses {@link String#String(byte[])}.
+     * </p>
+     *
+     * @param data the byte array to write, do not modify during output,
+     * null ignored
+     * @param writer the {@link Writer} to write to
+     * @throws NullPointerException if output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 1.1
+     * @deprecated 2.5 use {@link #write(byte[], Writer, Charset)} instead
+     */
+    @Deprecated
+    public static void write(final byte[] data, final Writer writer) throws IOException {
+        write(data, writer, Charset.defaultCharset());
+    }
+
+    /**
+     * Writes bytes from a {@code byte[]} to chars on a {@link Writer}
+     * using the specified character encoding.
+     * <p>
+     * This method uses {@link String#String(byte[], String)}.
+     * </p>
+     *
+     * @param data the byte array to write, do not modify during output,
+     * null ignored
+     * @param writer the {@link Writer} to write to
+     * @param charset the charset to use, null means platform default
+     * @throws NullPointerException if output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.3
+     */
+    public static void write(final byte[] data, final Writer writer, final Charset charset) throws IOException {
+        if (data != null) {
+            writer.write(new String(data, Charsets.toCharset(charset)));
+        }
+    }
+
+    /**
+     * Writes bytes from a {@code byte[]} to chars on a {@link Writer}
+     * using the specified character encoding.
+     * <p>
+     * Character encoding names can be found at
+     * <a href="http://www.iana.org/assignments/character-sets">IANA</a>.
+     * </p>
+     * <p>
+     * This method uses {@link String#String(byte[], String)}.
+     * </p>
+     *
+     * @param data the byte array to write, do not modify during output,
+     * null ignored
+     * @param writer the {@link Writer} to write to
+     * @param charsetName the name of the requested charset, null means platform default
+     * @throws NullPointerException                         if output is null
+     * @throws IOException                                  if an I/O error occurs
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     *                                                      .UnsupportedEncodingException} in version 2.2 if the
+     *                                                      encoding is not supported.
+     * @since 1.1
+     */
+    public static void write(final byte[] data, final Writer writer, final String charsetName) throws IOException {
+        write(data, writer, Charsets.toCharset(charsetName));
+    }
+
+    /**
+     * Writes chars from a {@code char[]} to bytes on an
+     * {@link OutputStream}.
+     * <p>
+     * This method uses {@link String#String(char[])} and
+     * {@link String#getBytes()}.
+     * </p>
+     *
+     * @param data the char array to write, do not modify during output,
+     * null ignored
+     * @param output the {@link OutputStream} to write to
+     * @throws NullPointerException if output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 1.1
+     * @deprecated 2.5 use {@link #write(char[], OutputStream, Charset)} instead
+     */
+    @Deprecated
+    public static void write(final char[] data, final OutputStream output)
+            throws IOException {
+        write(data, output, Charset.defaultCharset());
+    }
+
+    /**
+     * Writes chars from a {@code char[]} to bytes on an
+     * {@link OutputStream} using the specified character encoding.
+     * <p>
+     * This method uses {@link String#String(char[])} and
+     * {@link String#getBytes(String)}.
+     * </p>
+     *
+     * @param data the char array to write, do not modify during output,
+     * null ignored
+     * @param output the {@link OutputStream} to write to
+     * @param charset the charset to use, null means platform default
+     * @throws NullPointerException if output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.3
+     */
+    public static void write(final char[] data, final OutputStream output, final Charset charset) throws IOException {
+        if (data != null) {
+            write(new String(data), output, charset);
+        }
+    }
+
+    /**
+     * Writes chars from a {@code char[]} to bytes on an
+     * {@link OutputStream} using the specified character encoding.
+     * <p>
+     * Character encoding names can be found at
+     * <a href="http://www.iana.org/assignments/character-sets">IANA</a>.
+     * </p>
+     * <p>
+     * This method uses {@link String#String(char[])} and
+     * {@link String#getBytes(String)}.
+     * </p>
+     *
+     * @param data the char array to write, do not modify during output,
+     * null ignored
+     * @param output the {@link OutputStream} to write to
+     * @param charsetName the name of the requested charset, null means platform default
+     * @throws NullPointerException                         if output is null
+     * @throws IOException                                  if an I/O error occurs
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     * .UnsupportedEncodingException} in version 2.2 if the encoding is not supported.
+     * @since 1.1
+     */
+    public static void write(final char[] data, final OutputStream output, final String charsetName)
+            throws IOException {
+        write(data, output, Charsets.toCharset(charsetName));
+    }
+
+    /**
+     * Writes chars from a {@code char[]} to a {@link Writer}
+     *
+     * @param data the char array to write, do not modify during output,
+     * null ignored
+     * @param writer the {@link Writer} to write to
+     * @throws NullPointerException if output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 1.1
+     */
+    public static void write(final char[] data, final Writer writer) throws IOException {
+        if (data != null) {
+            writer.write(data);
+        }
+    }
+
+    /**
+     * Writes chars from a {@link CharSequence} to bytes on an
+     * {@link OutputStream} using the default character encoding of the
+     * platform.
+     * <p>
+     * This method uses {@link String#getBytes()}.
+     * </p>
+     *
+     * @param data the {@link CharSequence} to write, null ignored
+     * @param output the {@link OutputStream} to write to
+     * @throws NullPointerException if output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.0
+     * @deprecated 2.5 use {@link #write(CharSequence, OutputStream, Charset)} instead
+     */
+    @Deprecated
+    public static void write(final CharSequence data, final OutputStream output)
+            throws IOException {
+        write(data, output, Charset.defaultCharset());
+    }
+
+    /**
+     * Writes chars from a {@link CharSequence} to bytes on an
+     * {@link OutputStream} using the specified character encoding.
+     * <p>
+     * This method uses {@link String#getBytes(String)}.
+     * </p>
+     *
+     * @param data the {@link CharSequence} to write, null ignored
+     * @param output the {@link OutputStream} to write to
+     * @param charset the charset to use, null means platform default
+     * @throws NullPointerException if output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.3
+     */
+    public static void write(final CharSequence data, final OutputStream output, final Charset charset)
+            throws IOException {
+        if (data != null) {
+            write(data.toString(), output, charset);
+        }
+    }
+
+    /**
+     * Writes chars from a {@link CharSequence} to bytes on an
+     * {@link OutputStream} using the specified character encoding.
+     * <p>
+     * Character encoding names can be found at
+     * <a href="http://www.iana.org/assignments/character-sets">IANA</a>.
+     * </p>
+     * <p>
+     * This method uses {@link String#getBytes(String)}.
+     * </p>
+     *
+     * @param data the {@link CharSequence} to write, null ignored
+     * @param output the {@link OutputStream} to write to
+     * @param charsetName the name of the requested charset, null means platform default
+     * @throws NullPointerException        if output is null
+     * @throws IOException                 if an I/O error occurs
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     * .UnsupportedEncodingException} in version 2.2 if the encoding is not supported.
+     * @since 2.0
+     */
+    public static void write(final CharSequence data, final OutputStream output, final String charsetName)
+            throws IOException {
+        write(data, output, Charsets.toCharset(charsetName));
+    }
+
+
+    /**
+     * Writes chars from a {@link CharSequence} to a {@link Writer}.
+     *
+     * @param data the {@link CharSequence} to write, null ignored
+     * @param writer the {@link Writer} to write to
+     * @throws NullPointerException if output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.0
+     */
+    public static void write(final CharSequence data, final Writer writer) throws IOException {
+        if (data != null) {
+            write(data.toString(), writer);
+        }
+    }
+
+    /**
+     * Writes chars from a {@link String} to bytes on an
+     * {@link OutputStream} using the default character encoding of the
+     * platform.
+     * <p>
+     * This method uses {@link String#getBytes()}.
+     * </p>
+     *
+     * @param data the {@link String} to write, null ignored
+     * @param output the {@link OutputStream} to write to
+     * @throws NullPointerException if output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 1.1
+     * @deprecated 2.5 use {@link #write(String, OutputStream, Charset)} instead
+     */
+    @Deprecated
+    public static void write(final String data, final OutputStream output)
+            throws IOException {
+        write(data, output, Charset.defaultCharset());
+    }
+
+    /**
+     * Writes chars from a {@link String} to bytes on an
+     * {@link OutputStream} using the specified character encoding.
+     * <p>
+     * This method uses {@link String#getBytes(String)}.
+     * </p>
+     *
+     * @param data the {@link String} to write, null ignored
+     * @param output the {@link OutputStream} to write to
+     * @param charset the charset to use, null means platform default
+     * @throws NullPointerException if output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.3
+     */
+    @SuppressWarnings("resource")
+    public static void write(final String data, final OutputStream output, final Charset charset) throws IOException {
+        if (data != null) {
+            // Use Charset#encode(String), since calling String#getBytes(Charset) might result in
+            // NegativeArraySizeException or OutOfMemoryError.
+            // The underlying OutputStream should not be closed, so the channel is not closed.
+            Channels.newChannel(output).write(Charsets.toCharset(charset).encode(data));
+        }
+    }
+
+    /**
+     * Writes chars from a {@link String} to bytes on an
+     * {@link OutputStream} using the specified character encoding.
+     * <p>
+     * Character encoding names can be found at
+     * <a href="http://www.iana.org/assignments/character-sets">IANA</a>.
+     * </p>
+     * <p>
+     * This method uses {@link String#getBytes(String)}.
+     * </p>
+     *
+     * @param data the {@link String} to write, null ignored
+     * @param output the {@link OutputStream} to write to
+     * @param charsetName the name of the requested charset, null means platform default
+     * @throws NullPointerException        if output is null
+     * @throws IOException                 if an I/O error occurs
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     * .UnsupportedEncodingException} in version 2.2 if the encoding is not supported.
+     * @since 1.1
+     */
+    public static void write(final String data, final OutputStream output, final String charsetName)
+            throws IOException {
+        write(data, output, Charsets.toCharset(charsetName));
+    }
+
+    /**
+     * Writes chars from a {@link String} to a {@link Writer}.
+     *
+     * @param data the {@link String} to write, null ignored
+     * @param writer the {@link Writer} to write to
+     * @throws NullPointerException if output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 1.1
+     */
+    public static void write(final String data, final Writer writer) throws IOException {
+        if (data != null) {
+            writer.write(data);
+        }
+    }
+
+    /**
+     * Writes chars from a {@link StringBuffer} to bytes on an
+     * {@link OutputStream} using the default character encoding of the
+     * platform.
+     * <p>
+     * This method uses {@link String#getBytes()}.
+     * </p>
+     *
+     * @param data the {@link StringBuffer} to write, null ignored
+     * @param output the {@link OutputStream} to write to
+     * @throws NullPointerException if output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 1.1
+     * @deprecated Use {@link #write(CharSequence, OutputStream)}
+     */
+    @Deprecated
+    public static void write(final StringBuffer data, final OutputStream output) //NOSONAR
+            throws IOException {
+        write(data, output, (String) null);
+    }
+
+    /**
+     * Writes chars from a {@link StringBuffer} to bytes on an
+     * {@link OutputStream} using the specified character encoding.
+     * <p>
+     * Character encoding names can be found at
+     * <a href="http://www.iana.org/assignments/character-sets">IANA</a>.
+     * </p>
+     * <p>
+     * This method uses {@link String#getBytes(String)}.
+     * </p>
+     *
+     * @param data the {@link StringBuffer} to write, null ignored
+     * @param output the {@link OutputStream} to write to
+     * @param charsetName the name of the requested charset, null means platform default
+     * @throws NullPointerException        if output is null
+     * @throws IOException                 if an I/O error occurs
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     * .UnsupportedEncodingException} in version 2.2 if the encoding is not supported.
+     * @since 1.1
+     * @deprecated Use {@link #write(CharSequence, OutputStream, String)}.
+     */
+    @Deprecated
+    public static void write(final StringBuffer data, final OutputStream output, final String charsetName) //NOSONAR
+        throws IOException {
+        if (data != null) {
+            write(data.toString(), output, Charsets.toCharset(charsetName));
+        }
+    }
+
+    /**
+     * Writes chars from a {@link StringBuffer} to a {@link Writer}.
+     *
+     * @param data the {@link StringBuffer} to write, null ignored
+     * @param writer the {@link Writer} to write to
+     * @throws NullPointerException if output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 1.1
+     * @deprecated Use {@link #write(CharSequence, Writer)}
+     */
+    @Deprecated
+    public static void write(final StringBuffer data, final Writer writer) //NOSONAR
+            throws IOException {
+        if (data != null) {
+            writer.write(data.toString());
+        }
+    }
+
+    /**
+     * Writes bytes from a {@code byte[]} to an {@link OutputStream} using chunked writes.
+     * This is intended for writing very large byte arrays which might otherwise cause excessive
+     * memory usage if the native code has to allocate a copy.
+     *
+     * @param data the byte array to write, do not modify during output,
+     * null ignored
+     * @param output the {@link OutputStream} to write to
+     * @throws NullPointerException if output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.5
+     */
+    public static void writeChunked(final byte[] data, final OutputStream output)
+            throws IOException {
+        if (data != null) {
+            int bytes = data.length;
+            int offset = 0;
+            while (bytes > 0) {
+                final int chunk = Math.min(bytes, DEFAULT_BUFFER_SIZE);
+                output.write(data, offset, chunk);
+                bytes -= chunk;
+                offset += chunk;
+            }
+        }
+    }
+
+    /**
+     * Writes chars from a {@code char[]} to a {@link Writer} using chunked writes.
+     * This is intended for writing very large byte arrays which might otherwise cause excessive
+     * memory usage if the native code has to allocate a copy.
+     *
+     * @param data the char array to write, do not modify during output,
+     * null ignored
+     * @param writer the {@link Writer} to write to
+     * @throws NullPointerException if output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.5
+     */
+    public static void writeChunked(final char[] data, final Writer writer) throws IOException {
+        if (data != null) {
+            int bytes = data.length;
+            int offset = 0;
+            while (bytes > 0) {
+                final int chunk = Math.min(bytes, DEFAULT_BUFFER_SIZE);
+                writer.write(data, offset, chunk);
+                bytes -= chunk;
+                offset += chunk;
+            }
+        }
+    }
+
+    /**
+     * Writes the {@link #toString()} value of each item in a collection to
+     * an {@link OutputStream} line by line, using the default character
+     * encoding of the platform and the specified line ending.
+     *
+     * @param lines the lines to write, null entries produce blank lines
+     * @param lineEnding the line separator to use, null is system default
+     * @param output the {@link OutputStream} to write to, not null, not closed
+     * @throws NullPointerException if the output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 1.1
+     * @deprecated 2.5 use {@link #writeLines(Collection, String, OutputStream, Charset)} instead
+     */
+    @Deprecated
+    public static void writeLines(final Collection<?> lines, final String lineEnding,
+                                  final OutputStream output) throws IOException {
+        writeLines(lines, lineEnding, output, Charset.defaultCharset());
+    }
+
+    /**
+     * Writes the {@link #toString()} value of each item in a collection to
+     * an {@link OutputStream} line by line, using the specified character
+     * encoding and the specified line ending.
+     *
+     * @param lines the lines to write, null entries produce blank lines
+     * @param lineEnding the line separator to use, null is system default
+     * @param output the {@link OutputStream} to write to, not null, not closed
+     * @param charset the charset to use, null means platform default
+     * @throws NullPointerException if the output is null
+     * @throws IOException          if an I/O error occurs
+     * @since 2.3
+     */
+    public static void writeLines(final Collection<?> lines, String lineEnding, final OutputStream output,
+                                  final Charset charset) throws IOException {
+        if (lines == null) {
+            return;
+        }
+        if (lineEnding == null) {
+            lineEnding = System.lineSeparator();
+        }
+        final Charset cs = Charsets.toCharset(charset);
+        final byte[] eolBytes = lineEnding.getBytes(cs);
+        for (final Object line : lines) {
+            if (line != null) {
+                write(line.toString(), output, cs);
+            }
+            output.write(eolBytes);
+        }
+    }
+
+    /**
+     * Writes the {@link #toString()} value of each item in a collection to
+     * an {@link OutputStream} line by line, using the specified character
+     * encoding and the specified line ending.
+     * <p>
+     * Character encoding names can be found at
+     * <a href="http://www.iana.org/assignments/character-sets">IANA</a>.
+     * </p>
+     *
+     * @param lines the lines to write, null entries produce blank lines
+     * @param lineEnding the line separator to use, null is system default
+     * @param output the {@link OutputStream} to write to, not null, not closed
+     * @param charsetName the name of the requested charset, null means platform default
+     * @throws NullPointerException                         if the output is null
+     * @throws IOException                                  if an I/O error occurs
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io
+     *                                                      .UnsupportedEncodingException} in version 2.2 if the
+     *                                                      encoding is not supported.
+     * @since 1.1
+     */
+    public static void writeLines(final Collection<?> lines, final String lineEnding,
+                                  final OutputStream output, final String charsetName) throws IOException {
+        writeLines(lines, lineEnding, output, Charsets.toCharset(charsetName));
+    }
+
+    /**
+     * Writes the {@link #toString()} value of each item in a collection to
+     * a {@link Writer} line by line, using the specified line ending.
+     *
+     * @param lines the lines to write, null entries produce blank lines
+     * @param lineEnding the line separator to use, null is system default
+     * @param writer the {@link Writer} to write to, not null, not closed
+     * @throws NullPointerException if the input is null
+     * @throws IOException          if an I/O error occurs
+     * @since 1.1
+     */
+    public static void writeLines(final Collection<?> lines, String lineEnding,
+                                  final Writer writer) throws IOException {
+        if (lines == null) {
+            return;
+        }
+        if (lineEnding == null) {
+            lineEnding = System.lineSeparator();
+        }
+        for (final Object line : lines) {
+            if (line != null) {
+                writer.write(line.toString());
+            }
+            writer.write(lineEnding);
+        }
+    }
+
+    /**
+     * Returns the given Appendable if it is already a {@link Writer}, otherwise creates a Writer wrapper around the
+     * given Appendable.
+     *
+     * @param appendable the Appendable to wrap or return (not null)
+     * @return  the given Appendable or a Writer wrapper around the given Appendable
+     * @throws NullPointerException if the input parameter is null
+     * @since 2.7
+     */
+    public static Writer writer(final Appendable appendable) {
+        Objects.requireNonNull(appendable, "appendable");
+        if (appendable instanceof Writer) {
+            return (Writer) appendable;
+        }
+        if (appendable instanceof StringBuilder) {
+            return new StringBuilderWriter((StringBuilder) appendable);
+        }
+        return new AppendableWriter<>(appendable);
+    }
+
+    /**
+     * Instances should NOT be constructed in standard programming.
+     * @deprecated Will be private in 3.0.
+     */
+    @Deprecated
+    public IOUtils() { //NOSONAR
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/LineIterator.java b/src/main/java/org/apache/commons/io/LineIterator.java
new file mode 100644
index 0000000..6c67af1
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/LineIterator.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.BufferedReader;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.Reader;
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+
+/**
+ * An Iterator over the lines in a {@link Reader}.
+ * <p>
+ * {@link LineIterator} holds a reference to an open {@link Reader}.
+ * When you have finished with the iterator you should close the reader
+ * to free internal resources. This can be done by closing the reader directly,
+ * or by calling the {@link #close()} or {@link #closeQuietly(LineIterator)}
+ * method on the iterator.
+ * <p>
+ * The recommended usage pattern is:
+ * <pre>
+ * LineIterator it = FileUtils.lineIterator(file, StandardCharsets.UTF_8.name());
+ * try {
+ *   while (it.hasNext()) {
+ *     String line = it.nextLine();
+ *     // do something with line
+ *   }
+ * } finally {
+ *   it.close();
+ * }
+ * </pre>
+ *
+ * @since 1.2
+ */
+public class LineIterator implements Iterator<String>, Closeable {
+
+    // N.B. This class deliberately does not implement Iterable, see https://issues.apache.org/jira/browse/IO-181
+
+    /**
+     * Closes a {@link LineIterator} quietly.
+     *
+     * @param iterator The iterator to close, or {@code null}.
+     * @deprecated As of 2.6 deprecated without replacement. Please use the try-with-resources statement or handle
+     * suppressed exceptions manually.
+     * @see Throwable#addSuppressed(Throwable)
+     */
+    @Deprecated
+    public static void closeQuietly(final LineIterator iterator) {
+        IOUtils.closeQuietly(iterator);
+    }
+
+    /** The reader that is being read. */
+    private final BufferedReader bufferedReader;
+
+    /** The current line. */
+    private String cachedLine;
+
+    /** A flag indicating if the iterator has been fully read. */
+    private boolean finished;
+
+    /**
+     * Constructs an iterator of the lines for a {@link Reader}.
+     *
+     * @param reader the {@link Reader} to read from, not null
+     * @throws IllegalArgumentException if the reader is null
+     */
+    public LineIterator(final Reader reader) throws IllegalArgumentException {
+        Objects.requireNonNull(reader, "reader");
+        if (reader instanceof BufferedReader) {
+            bufferedReader = (BufferedReader) reader;
+        } else {
+            bufferedReader = new BufferedReader(reader);
+        }
+    }
+
+    /**
+     * Closes the underlying {@link Reader}.
+     * This method is useful if you only want to process the first few
+     * lines of a larger file. If you do not close the iterator
+     * then the {@link Reader} remains open.
+     * This method can safely be called multiple times.
+     *
+     * @throws IOException if closing the underlying {@link Reader} fails.
+     */
+    @Override
+    public void close() throws IOException {
+        finished = true;
+        cachedLine = null;
+        IOUtils.close(bufferedReader);
+    }
+
+    /**
+     * Indicates whether the {@link Reader} has more lines.
+     * If there is an {@link IOException} then {@link #close()} will
+     * be called on this instance.
+     *
+     * @return {@code true} if the Reader has more lines
+     * @throws IllegalStateException if an IO exception occurs
+     */
+    @Override
+    public boolean hasNext() {
+        if (cachedLine != null) {
+            return true;
+        }
+        if (finished) {
+            return false;
+        }
+        try {
+            while (true) {
+                final String line = bufferedReader.readLine();
+                if (line == null) {
+                    finished = true;
+                    return false;
+                }
+                if (isValidLine(line)) {
+                    cachedLine = line;
+                    return true;
+                }
+            }
+        } catch(final IOException ioe) {
+            IOUtils.closeQuietly(this, ioe::addSuppressed);
+            throw new IllegalStateException(ioe);
+        }
+    }
+
+    /**
+     * Overridable method to validate each line that is returned.
+     * This implementation always returns true.
+     * @param line  the line that is to be validated
+     * @return true if valid, false to remove from the iterator
+     */
+    protected boolean isValidLine(final String line) {
+        return true;
+    }
+
+    /**
+     * Returns the next line in the wrapped {@link Reader}.
+     *
+     * @return the next line from the input
+     * @throws NoSuchElementException if there is no line to return
+     */
+    @Override
+    public String next() {
+        return nextLine();
+    }
+
+    /**
+     * Returns the next line in the wrapped {@link Reader}.
+     *
+     * @return the next line from the input
+     * @throws NoSuchElementException if there is no line to return
+     */
+    public String nextLine() {
+        if (!hasNext()) {
+            throw new NoSuchElementException("No more lines");
+        }
+        final String currentLine = cachedLine;
+        cachedLine = null;
+        return currentLine;
+    }
+
+    /**
+     * Unsupported.
+     *
+     * @throws UnsupportedOperationException always
+     */
+    @Override
+    public void remove() {
+        throw new UnsupportedOperationException("remove not supported");
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/RandomAccessFileMode.java b/src/main/java/org/apache/commons/io/RandomAccessFileMode.java
new file mode 100644
index 0000000..f928e58
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/RandomAccessFileMode.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.RandomAccessFile;
+import java.nio.file.Path;
+
+/**
+ * Access modes and factory methods for {@link RandomAccessFile}.
+ *
+ * @since 2.12.0
+ */
+public enum RandomAccessFileMode {
+
+    /**
+     * Mode "r" opens for reading only.
+     */
+    READ_ONLY("r"),
+
+    /**
+     * Mode "rw" opens for reading and writing.
+     */
+    READ_WRITE("rw"),
+
+    /**
+     * Mode "rws" opens for reading and writing, as with "rw", and also require that every update to the file's content or
+     * metadata be written synchronously to the underlying storage device.
+     */
+    READ_WRITE_SYNC_ALL("rws"),
+
+    /**
+     * Mode "rwd" open for reading and writing, as with "rw", and also require that every update to the file's content be
+     * written synchronously to the underlying storage device.
+     */
+    READ_WRITE_SYNC_CONTENT("rwd");
+
+    private final String mode;
+
+    RandomAccessFileMode(final String mode) {
+        this.mode = mode;
+    }
+
+    /**
+     * Creates a random access file stream to read from, and optionally to write to, the file specified by the {@link File}
+     * argument.
+     *
+     * @param file the file object
+     * @return a random access file stream
+     * @throws FileNotFoundException See {@link RandomAccessFile#RandomAccessFile(File, String)}.
+     */
+    public RandomAccessFile create(final File file) throws FileNotFoundException {
+        return new RandomAccessFile(file, mode);
+    }
+
+    /**
+     * Creates a random access file stream to read from, and optionally to write to, the file specified by the {@link File}
+     * argument.
+     *
+     * @param file the file object
+     * @return a random access file stream
+     * @throws FileNotFoundException See {@link RandomAccessFile#RandomAccessFile(File, String)}.
+     */
+    public RandomAccessFile create(final Path file) throws FileNotFoundException {
+        return create(file.toFile());
+    }
+
+    /**
+     * Creates a random access file stream to read from, and optionally to write to, the file specified by the {@link File}
+     * argument.
+     *
+     * @param file the file object
+     * @return a random access file stream
+     * @throws FileNotFoundException See {@link RandomAccessFile#RandomAccessFile(File, String)}.
+     */
+    public RandomAccessFile create(final String file) throws FileNotFoundException {
+        return new RandomAccessFile(file, mode);
+    }
+
+    @Override
+    public String toString() {
+        return mode;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/StandardLineSeparator.java b/src/main/java/org/apache/commons/io/StandardLineSeparator.java
new file mode 100644
index 0000000..4d40ada
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/StandardLineSeparator.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io;
+
+import java.nio.charset.Charset;
+import java.util.Objects;
+
+/**
+ * Enumerates standard line separators: {@link #CR}, {@link #CRLF}, {@link #LF}.
+ *
+ * @since 2.9.0
+ */
+public enum StandardLineSeparator {
+
+    /**
+     * Carriage return. This is the line ending used on Mac OS 9 and earlier.
+     */
+    CR("\r"),
+
+    /**
+     * Carriage return followed by line feed. This is the line ending used on Windows.
+     */
+    CRLF("\r\n"),
+
+    /**
+     * Line feed. This is the line ending used on Linux and Mac OS X and later.
+     */
+    LF("\n");
+
+    private final String lineSeparator;
+
+    /**
+     * Constructs a new instance for a non-null line separator.
+     *
+     * @param lineSeparator a non-null line separator.
+     */
+    StandardLineSeparator(final String lineSeparator) {
+        this.lineSeparator = Objects.requireNonNull(lineSeparator, "lineSeparator");
+    }
+
+    /**
+     * Gets the bytes for this instance encoded using the given Charset.
+     *
+     * @param charset the encoding Charset.
+     * @return the bytes for this instance encoded using the given Charset.
+     */
+    public byte[] getBytes(final Charset charset) {
+        return lineSeparator.getBytes(charset);
+    }
+
+    /**
+     * Gets the String value of this instance.
+     *
+     * @return the String value of this instance.
+     */
+    public String getString() {
+        return lineSeparator;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/StreamIterator.java b/src/main/java/org/apache/commons/io/StreamIterator.java
new file mode 100644
index 0000000..535492e
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/StreamIterator.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io;
+
+import java.io.Closeable;
+import java.util.Iterator;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+/**
+ * Wraps and presents a stream as a closable iterator resource that automatically closes itself when reaching the end
+ * of stream.
+ *
+ * @param <E> The stream and iterator type.
+ * @since 2.9.0
+ */
+class StreamIterator<E> implements Iterator<E>, Closeable {
+
+    /**
+     * Wraps and presents a stream as a closable resource that automatically closes itself when reaching the end of
+     * stream.
+     * <h2>Warning</h2>
+     * <p>
+     * In order to close the stream, the call site MUST either close the stream it allocated OR call the iterator until
+     * the end.
+     * </p>
+     *
+     * @param <T> The stream and iterator type.
+     * @param stream The stream iterate.
+     * @return A new iterator.
+     */
+    @SuppressWarnings("resource") // Caller MUST close or iterate to the end.
+    public static <T> Iterator<T> iterator(final Stream<T> stream) {
+        return new StreamIterator<>(stream).iterator;
+    }
+
+    private final Iterator<E> iterator;
+
+    private final Stream<E> stream;
+
+    private StreamIterator(final Stream<E> stream) {
+        this.stream = Objects.requireNonNull(stream, "stream");
+        this.iterator = stream.iterator();
+    }
+
+    /**
+     * Closes the underlying stream.
+     */
+    @Override
+    public void close() {
+        stream.close();
+    }
+
+    @Override
+    public boolean hasNext() {
+        final boolean hasNext = iterator.hasNext();
+        if (!hasNext) {
+            close();
+        }
+        return hasNext;
+    }
+
+    @Override
+    public E next() {
+        final E next = iterator.next();
+        if (next == null) {
+            close();
+        }
+        return next;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/TaggedIOException.java b/src/main/java/org/apache/commons/io/TaggedIOException.java
new file mode 100644
index 0000000..43d7730
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/TaggedIOException.java
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.IOException;
+import java.io.Serializable;
+
+/**
+ * An {@link IOException} decorator that adds a serializable tag to the
+ * wrapped exception. Both the tag and the original exception can be used
+ * to determine further processing when this exception is caught.
+ *
+ * @since 2.0
+ */
+@SuppressWarnings("deprecation") // needs to extend deprecated IOExceptionWithCause to preserve binary compatibility
+public class TaggedIOException extends IOExceptionWithCause {
+
+    /**
+     * Generated serial version UID.
+     */
+    private static final long serialVersionUID = -6994123481142850163L;
+
+    /**
+     * Checks whether the given throwable is tagged with the given tag.
+     * <p>
+     * This check can only succeed if the throwable is a
+     * {@link TaggedIOException} and the tag is {@link Serializable}, but
+     * the argument types are intentionally more generic to make it easier
+     * to use this method without type casts.
+     * <p>
+     * A typical use for this method is in a {@code catch} block to
+     * determine how a caught exception should be handled:
+     * <pre>
+     * Serializable tag = ...;
+     * try {
+     *     ...;
+     * } catch (Throwable t) {
+     *     if (TaggedIOException.isTaggedWith(t, tag)) {
+     *         // special processing for tagged exception
+     *     } else {
+     *         // handling of other kinds of exceptions
+     *     }
+     * }
+     * </pre>
+     *
+     * @param throwable The Throwable object to check
+     * @param tag tag object
+     * @return {@code true} if the throwable has the specified tag,
+     * otherwise {@code false}
+     */
+    public static boolean isTaggedWith(final Throwable throwable, final Object tag) {
+        return tag != null
+            && throwable instanceof TaggedIOException
+            && tag.equals(((TaggedIOException) throwable).tag);
+    }
+
+    /**
+     * Throws the original {@link IOException} if the given throwable is
+     * a {@link TaggedIOException} decorator the given tag. Does nothing
+     * if the given throwable is of a different type or if it is tagged
+     * with some other tag.
+     * <p>
+     * This method is typically used in a {@code catch} block to
+     * selectively rethrow tagged exceptions.
+     * <pre>
+     * Serializable tag = ...;
+     * try {
+     *     ...;
+     * } catch (Throwable t) {
+     *     TaggedIOException.throwCauseIfTagged(t, tag);
+     *     // handle other kinds of exceptions
+     * }
+     * </pre>
+     *
+     * @param throwable an exception
+     * @param tag tag object
+     * @throws IOException original exception from the tagged decorator, if any
+     */
+    public static void throwCauseIfTaggedWith(final Throwable throwable, final Object tag)
+            throws IOException {
+        if (isTaggedWith(throwable, tag)) {
+            throw ((TaggedIOException) throwable).getCause();
+        }
+    }
+
+    /**
+     * The tag of this exception.
+     */
+    private final Serializable tag;
+
+    /**
+     * Creates a tagged wrapper for the given exception.
+     *
+     * @param original the exception to be tagged
+     * @param tag tag of this exception
+     */
+    public TaggedIOException(final IOException original, final Serializable tag) {
+        super(original.getMessage(), original);
+        this.tag = tag;
+    }
+
+    /**
+     * Returns the wrapped exception. The only difference to the overridden
+     * {@link Throwable#getCause()} method is the narrower return type.
+     *
+     * @return wrapped exception
+     */
+    @Override
+    public synchronized IOException getCause() {
+        return (IOException) super.getCause();
+    }
+
+    /**
+     * Returns the serializable tag object.
+     *
+     * @return tag object
+     */
+    public Serializable getTag() {
+        return tag;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/ThreadMonitor.java b/src/main/java/org/apache/commons/io/ThreadMonitor.java
new file mode 100644
index 0000000..a97036b
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/ThreadMonitor.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.time.Duration;
+
+/**
+ * Monitors a thread, interrupting it if it reaches the specified timeout.
+ * <p>
+ * This works by sleeping until the specified timeout amount and then interrupting the thread being monitored. If the
+ * thread being monitored completes its work before being interrupted, it should {@code interrupt()} the <i>monitor</i>
+ * thread.
+ * </p>
+ *
+ * <pre>
+ * Duration timeout = Duration.ofSeconds(1);
+ * try {
+ *     Thread monitor = ThreadMonitor.start(timeout);
+ *     // do some work here
+ *     ThreadMonitor.stop(monitor);
+ * } catch (InterruptedException e) {
+ *     // timed amount was reached
+ * }
+ * </pre>
+ *
+ */
+class ThreadMonitor implements Runnable {
+
+    /**
+     * Starts monitoring the current thread.
+     *
+     * @param timeout The timeout amount. or no timeout if the value is zero or less.
+     * @return The monitor thread or {@code null} if the timeout amount is not greater than zero.
+     */
+    static Thread start(final Duration timeout) {
+        return start(Thread.currentThread(), timeout);
+    }
+
+    /**
+     * Starts monitoring the specified thread.
+     *
+     * @param thread The thread to monitor
+     * @param timeout The timeout amount. or no timeout if the value is zero or less.
+     * @return The monitor thread or {@code null} if the timeout amount is not greater than zero.
+     */
+    static Thread start(final Thread thread, final Duration timeout) {
+        if (timeout.isZero() || timeout.isNegative()) {
+            return null;
+        }
+        final Thread monitor = new Thread(new ThreadMonitor(thread, timeout), ThreadMonitor.class.getSimpleName());
+        monitor.setDaemon(true);
+        monitor.start();
+        return monitor;
+    }
+
+    /**
+     * Stops monitoring the specified thread.
+     *
+     * @param thread The monitor thread, may be {@code null}.
+     */
+    static void stop(final Thread thread) {
+        if (thread != null) {
+            thread.interrupt();
+        }
+    }
+
+    private final Thread thread;
+
+    private final Duration timeout;
+
+    /**
+     * Constructs a new monitor.
+     *
+     * @param thread The thread to monitor.
+     * @param timeout The timeout amount.
+     */
+    private ThreadMonitor(final Thread thread, final Duration timeout) {
+        this.thread = thread;
+        this.timeout = timeout;
+    }
+
+    /**
+     * Sleeps until the specified timeout amount and then interrupt the thread being monitored.
+     *
+     * @see Runnable#run()
+     */
+    @Override
+    public void run() {
+        try {
+            ThreadUtils.sleep(timeout);
+            thread.interrupt();
+        } catch (final InterruptedException e) {
+            // timeout not reached
+        }
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/ThreadUtils.java b/src/main/java/org/apache/commons/io/ThreadUtils.java
new file mode 100644
index 0000000..2fd661c
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/ThreadUtils.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.time.Duration;
+import java.time.Instant;
+
+/**
+ * Helps work with threads.
+ *
+ * @since 2.12.0
+ */
+public class ThreadUtils {
+
+    static int getNanosOfMilli(final Duration duration) {
+        return duration.getNano() % 1_000_000;
+    }
+
+    /**
+     * Sleeps for a guaranteed minimum duration unless interrupted.
+     *
+     * This method exists because Thread.sleep(100) can sleep for 0, 70, 100 or 200ms or anything else it deems appropriate.
+     * Read {@link Thread#sleep(long, int)}} for further interesting details.
+     *
+     * TODO The above needs confirmation now that we've been on Java 8 for a while.
+     *
+     * @param duration the sleep duration.
+     * @throws InterruptedException if interrupted.
+     */
+    public static void sleep(final Duration duration) throws InterruptedException {
+        final Instant finishInstant = Instant.now().plus(duration);
+        Duration remainingDuration = duration;
+        do {
+            Thread.sleep(remainingDuration.toMillis(), getNanosOfMilli(remainingDuration));
+            remainingDuration = Duration.between(Instant.now(), finishInstant);
+        } while (!remainingDuration.isNegative());
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/UncheckedIOExceptions.java b/src/main/java/org/apache/commons/io/UncheckedIOExceptions.java
new file mode 100644
index 0000000..4b1056e
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/UncheckedIOExceptions.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Objects;
+
+/**
+ * Helps use lambdas that throw {@link IOException} rethrow as {@link UncheckedIOException}.
+ *
+ * @since 2.12.0
+ */
+class UncheckedIOExceptions {
+
+    /**
+     * Creates a new UncheckedIOException for the given detail message.
+     * <p>
+     * This method exists because there is no String constructor in {@link UncheckedIOException}.
+     * </p>
+     *
+     * @param message the detail message.
+     * @return a new {@link UncheckedIOException}.
+     */
+    public static UncheckedIOException create(final Object message) {
+        final String string = Objects.toString(message);
+        return new UncheckedIOException(string, new IOException(string));
+    }
+
+    /**
+     * Creates a new UncheckedIOException for the given detail message.
+     * <p>
+     * This method exists because there is no String constructor in {@link UncheckedIOException}.
+     * </p>
+     * @param e cause the {@link IOException}.
+     * @param message the detail message.
+     *
+     * @return a new {@link UncheckedIOException}.
+     */
+    public static UncheckedIOException wrap(final IOException e, final Object message) {
+        return new UncheckedIOException(Objects.toString(message), e);
+    }
+
+    private UncheckedIOExceptions() {
+        // no instance
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/charset/CharsetDecoders.java b/src/main/java/org/apache/commons/io/charset/CharsetDecoders.java
new file mode 100644
index 0000000..51c24ec
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/charset/CharsetDecoders.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.charset;
+
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetDecoder;
+
+/**
+ * Works with {@link CharsetDecoder}.
+ *
+ * @since 2.12.0
+ */
+public class CharsetDecoders {
+
+    /**
+     * Returns the given non-null CharsetDecoder or a new default CharsetDecoder.
+     *
+     * @param charsetDecoder The CharsetDecoder to test.
+     * @return the given non-null CharsetDecoder or a new default CharsetDecoder.
+     */
+    public static CharsetDecoder toCharsetDecoder(final CharsetDecoder charsetDecoder) {
+        return charsetDecoder != null ? charsetDecoder : Charset.defaultCharset().newDecoder();
+    }
+
+    /** No instances. */
+    private CharsetDecoders() {
+        // No instances.
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/charset/CharsetEncoders.java b/src/main/java/org/apache/commons/io/charset/CharsetEncoders.java
new file mode 100644
index 0000000..b346395
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/charset/CharsetEncoders.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.charset;
+
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetEncoder;
+
+/**
+ * Works with {@link CharsetEncoder}.
+ *
+ * @since 2.12.0
+ */
+public class CharsetEncoders {
+
+    /**
+     * Returns the given non-null CharsetEncoder or a new default CharsetEncoder.
+     *
+     * @param charsetEncoder The CharsetEncoder to test.
+     * @return the given non-null CharsetEncoder or a new default CharsetEncoder.
+     */
+    public static CharsetEncoder toCharsetEncoder(final CharsetEncoder charsetEncoder) {
+        return charsetEncoder != null ? charsetEncoder : Charset.defaultCharset().newEncoder();
+    }
+
+    /** No instances. */
+    private CharsetEncoders() {
+        // No instances.
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/charset/package-info.java b/src/main/java/org/apache/commons/io/charset/package-info.java
new file mode 100644
index 0000000..56ee6c0
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/charset/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Provides classes to work with code from {@link java.nio.charset}.
+ * @since 2.12.0
+ */
+package org.apache.commons.io.charset;
diff --git a/src/main/java/org/apache/commons/io/comparator/AbstractFileComparator.java b/src/main/java/org/apache/commons/io/comparator/AbstractFileComparator.java
new file mode 100644
index 0000000..64148a5
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/comparator/AbstractFileComparator.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * Abstract file {@link Comparator} which provides sorting for file arrays and lists.
+ *
+ * @since 2.0
+ */
+abstract class AbstractFileComparator implements Comparator<File> {
+
+    /**
+     * Sorts an array of files.
+     * <p>
+     * This method uses {@link Arrays#sort(Object[], Comparator)} and returns the original array.
+     * </p>
+     *
+     * @param files The files to sort, may be null.
+     * @return The sorted array.
+     * @since 2.0
+     */
+    public File[] sort(final File... files) {
+        if (files != null) {
+            Arrays.sort(files, this);
+        }
+        return files;
+    }
+
+    /**
+     * Sorts a List of files.
+     * <p>
+     * This method uses {@link List#sort(Comparator)} and returns the original list.
+     * </p>
+     *
+     * @param files The files to sort, may be null.
+     * @return The sorted list.
+     * @since 2.0
+     */
+    public List<File> sort(final List<File> files) {
+        if (files != null) {
+            files.sort(this);
+        }
+        return files;
+    }
+
+    /**
+     * String representation of this file comparator.
+     *
+     * @return String representation of this file comparator.
+     */
+    @Override
+    public String toString() {
+        return getClass().getSimpleName();
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/comparator/CompositeFileComparator.java b/src/main/java/org/apache/commons/io/comparator/CompositeFileComparator.java
new file mode 100644
index 0000000..f29b274
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/comparator/CompositeFileComparator.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import java.io.File;
+import java.io.Serializable;
+import java.util.Comparator;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+/**
+ * Compare two files using a set of delegate file {@link Comparator}.
+ * <p>
+ * This comparator can be used to sort lists or arrays of files by combining a number of other comparators.
+ * <p>
+ * Example of sorting a list of files by type (i.e. directory or file) and then by name:
+ *
+ * <pre>
+ *       CompositeFileComparator comparator = new CompositeFileComparator(
+ *           DirectoryFileComparator.DIRECTORY_COMPARATOR,
+ *           NameFileComparator.NAME_COMPARATOR);
+ *       List&lt;File&gt; list = ...
+ *       comparator.sort(list);
+ * </pre>
+ *
+ * @since 2.0
+ */
+public class CompositeFileComparator extends AbstractFileComparator implements Serializable {
+
+    private static final Comparator<?>[] EMPTY_COMPARATOR_ARRAY = {};
+    private static final long serialVersionUID = -2224170307287243428L;
+
+    private final Comparator<File>[] delegates;
+
+    /**
+     * Constructs a composite comparator for the set of delegate comparators.
+     *
+     * @param delegates The delegate file comparators
+     */
+    public CompositeFileComparator(@SuppressWarnings("unchecked") final Comparator<File>... delegates) {
+        this.delegates = delegates == null ? emptyArray() : delegates.clone();
+    }
+
+    /**
+     * Constructs a composite comparator for the set of delegate comparators.
+     *
+     * @param delegates The delegate file comparators
+     */
+    public CompositeFileComparator(final Iterable<Comparator<File>> delegates) {
+        this.delegates = delegates == null ? emptyArray() : StreamSupport.stream(delegates.spliterator(), false).toArray(Comparator[]::new);
+    }
+
+    /**
+     * Compares the two files using delegate comparators.
+     *
+     * @param file1 The first file to compare
+     * @param file2 The second file to compare
+     * @return the first non-zero result returned from the delegate comparators or zero.
+     */
+    @Override
+    public int compare(final File file1, final File file2) {
+        return Stream.of(delegates).map(delegate -> delegate.compare(file1, file2)).filter(r -> r != 0).findFirst().orElse(0);
+    }
+
+    @SuppressWarnings("unchecked") // types are already correct
+    private Comparator<File>[] emptyArray() {
+        return (Comparator<File>[]) EMPTY_COMPARATOR_ARRAY;
+    }
+
+    /**
+     * String representation of this file comparator.
+     *
+     * @return String representation of this file comparator
+     */
+    @Override
+    public String toString() {
+        final StringBuilder builder = new StringBuilder();
+        builder.append(super.toString());
+        builder.append('{');
+        for (int i = 0; i < delegates.length; i++) {
+            if (i > 0) {
+                builder.append(',');
+            }
+            builder.append(delegates[i]);
+        }
+        builder.append('}');
+        return builder.toString();
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/comparator/DefaultFileComparator.java b/src/main/java/org/apache/commons/io/comparator/DefaultFileComparator.java
new file mode 100644
index 0000000..88be4e0
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/comparator/DefaultFileComparator.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import java.io.File;
+import java.io.Serializable;
+import java.util.Comparator;
+
+/**
+ * s two files using the <b>default</b> {@link File#compareTo(File)} method.
+ * <p>
+ * This comparator can be used to sort lists or arrays of files
+ * by using the default file comparison.
+ * </p>
+ * <p>
+ * Example of sorting a list of files using the
+ * {@link #DEFAULT_COMPARATOR} singleton instance:
+ * </p>
+ * <pre>
+ *       List&lt;File&gt; list = ...
+ *       ((AbstractFileComparator) DefaultFileComparator.DEFAULT_COMPARATOR).sort(list);
+ * </pre>
+ * <p>
+ * Example of doing a <i>reverse</i> sort of an array of files using the
+ * {@link #DEFAULT_REVERSE} singleton instance:
+ * </p>
+ * <pre>
+ *       File[] array = ...
+ *       ((AbstractFileComparator) DefaultFileComparator.DEFAULT_REVERSE).sort(array);
+ * </pre>
+ *
+ * @since 1.4
+ */
+public class DefaultFileComparator extends AbstractFileComparator implements Serializable {
+
+    private static final long serialVersionUID = 3260141861365313518L;
+
+    /** Singleton default comparator instance */
+    public static final Comparator<File> DEFAULT_COMPARATOR = new DefaultFileComparator();
+
+    /** Singleton reverse default comparator instance */
+    public static final Comparator<File> DEFAULT_REVERSE = new ReverseFileComparator(DEFAULT_COMPARATOR);
+
+    /**
+     * Compares the two files using the {@link File#compareTo(File)} method.
+     *
+     * @param file1 The first file to compare
+     * @param file2 The second file to compare
+     * @return the result of calling file1's
+     * {@link File#compareTo(File)} with file2 as the parameter.
+     */
+    @Override
+    public int compare(final File file1, final File file2) {
+        return file1.compareTo(file2);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/comparator/DirectoryFileComparator.java b/src/main/java/org/apache/commons/io/comparator/DirectoryFileComparator.java
new file mode 100644
index 0000000..34ea58d
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/comparator/DirectoryFileComparator.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import java.io.File;
+import java.io.Serializable;
+import java.util.Comparator;
+
+/**
+ * Compare two files using the {@link File#isDirectory()} method.
+ * <p>
+ * This comparator can be used to sort lists or arrays by directories and files.
+ * </p>
+ * <p>
+ * Example of sorting a list of files/directories using the {@link #DIRECTORY_COMPARATOR} singleton instance:
+ * </p>
+ *
+ * <pre>
+ *       List&lt;File&gt; list = ...
+ *       ((AbstractFileComparator) DirectoryFileComparator.DIRECTORY_COMPARATOR).sort(list);
+ * </pre>
+ * <p>
+ * Example of doing a <i>reverse</i> sort of an array of files/directories using the {@link #DIRECTORY_REVERSE}
+ * singleton instance:
+ * </p>
+ *
+ * <pre>
+ *       File[] array = ...
+ *       ((AbstractFileComparator) DirectoryFileComparator.DIRECTORY_REVERSE).sort(array);
+ * </pre>
+ *
+ * @since 2.0
+ */
+public class DirectoryFileComparator extends AbstractFileComparator implements Serializable {
+
+    private static final int TYPE_FILE = 2;
+
+    private static final int TYPE_DIRECTORY = 1;
+
+    private static final long serialVersionUID = 296132640160964395L;
+
+    /** Singleton default comparator instance */
+    public static final Comparator<File> DIRECTORY_COMPARATOR = new DirectoryFileComparator();
+
+    /** Singleton reverse default comparator instance */
+    public static final Comparator<File> DIRECTORY_REVERSE = new ReverseFileComparator(DIRECTORY_COMPARATOR);
+
+    /**
+     * Compares the two files using the {@link File#isDirectory()} method.
+     *
+     * @param file1 The first file to compare.
+     * @param file2 The second file to compare.
+     * @return the result of calling file1's {@link File#compareTo(File)} with file2 as the parameter.
+     */
+    @Override
+    public int compare(final File file1, final File file2) {
+        return getType(file1) - getType(file2);
+    }
+
+    /**
+     * Converts type to numeric value.
+     *
+     * @param file The file.
+     * @return 1 for directories and 2 for files.
+     */
+    private int getType(final File file) {
+        return file.isDirectory() ? TYPE_DIRECTORY : TYPE_FILE;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/comparator/ExtensionFileComparator.java b/src/main/java/org/apache/commons/io/comparator/ExtensionFileComparator.java
new file mode 100644
index 0000000..e855fbf
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/comparator/ExtensionFileComparator.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import java.io.File;
+import java.io.Serializable;
+import java.util.Comparator;
+
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.io.IOCase;
+
+/**
+ * Compare the file name <b>extensions</b> for order
+ * (see {@link FilenameUtils#getExtension(String)}).
+ * <p>
+ * This comparator can be used to sort lists or arrays of files
+ * by their file extension either in a case-sensitive, case-insensitive or
+ * system dependent case-sensitive way. A number of singleton instances
+ * are provided for the various case sensitivity options (using {@link IOCase})
+ * and the reverse of those options.
+ * </p>
+ * <p>
+ * Example of a <i>case-sensitive</i> file extension sort using the
+ * {@link #EXTENSION_COMPARATOR} singleton instance:
+ * </p>
+ * <pre>
+ *       List&lt;File&gt; list = ...
+ *       ((AbstractFileComparator) ExtensionFileComparator.EXTENSION_COMPARATOR).sort(list);
+ * </pre>
+ * <p>
+ * Example of a <i>reverse case-insensitive</i> file extension sort using the
+ * {@link #EXTENSION_INSENSITIVE_REVERSE} singleton instance:
+ * </p>
+ * <pre>
+ *       File[] array = ...
+ *       ((AbstractFileComparator) ExtensionFileComparator.EXTENSION_INSENSITIVE_REVERSE).sort(array);
+ * </pre>
+ *
+ * @since 1.4
+ */
+public class ExtensionFileComparator extends AbstractFileComparator implements Serializable {
+
+    private static final long serialVersionUID = 1928235200184222815L;
+
+    /** Case-sensitive extension comparator instance (see {@link IOCase#SENSITIVE}) */
+    public static final Comparator<File> EXTENSION_COMPARATOR = new ExtensionFileComparator();
+
+    /** Reverse case-sensitive extension comparator instance (see {@link IOCase#SENSITIVE}) */
+    public static final Comparator<File> EXTENSION_REVERSE = new ReverseFileComparator(EXTENSION_COMPARATOR);
+
+    /** Case-insensitive extension comparator instance (see {@link IOCase#INSENSITIVE}) */
+    public static final Comparator<File> EXTENSION_INSENSITIVE_COMPARATOR
+                                                = new ExtensionFileComparator(IOCase.INSENSITIVE);
+
+    /** Reverse case-insensitive extension comparator instance (see {@link IOCase#INSENSITIVE}) */
+    public static final Comparator<File> EXTENSION_INSENSITIVE_REVERSE
+                                                = new ReverseFileComparator(EXTENSION_INSENSITIVE_COMPARATOR);
+
+    /** System sensitive extension comparator instance (see {@link IOCase#SYSTEM}) */
+    public static final Comparator<File> EXTENSION_SYSTEM_COMPARATOR = new ExtensionFileComparator(IOCase.SYSTEM);
+
+    /** Reverse system sensitive path comparator instance (see {@link IOCase#SYSTEM}) */
+    public static final Comparator<File> EXTENSION_SYSTEM_REVERSE = new ReverseFileComparator(EXTENSION_SYSTEM_COMPARATOR);
+
+    /** Whether the comparison is case-sensitive. */
+    private final IOCase ioCase;
+
+    /**
+     * Constructs a case-sensitive file extension comparator instance.
+     */
+    public ExtensionFileComparator() {
+        this.ioCase = IOCase.SENSITIVE;
+    }
+
+    /**
+     * Constructs a file extension comparator instance with the specified case-sensitivity.
+     *
+     * @param ioCase how to handle case sensitivity, null means case-sensitive
+     */
+    public ExtensionFileComparator(final IOCase ioCase) {
+        this.ioCase = IOCase.value(ioCase, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Compares the extensions of two files the specified case sensitivity.
+     *
+     * @param file1 The first file to compare
+     * @param file2 The second file to compare
+     * @return a negative value if the first file's extension
+     * is less than the second, zero if the extensions are the
+     * same and a positive value if the first files extension
+     * is greater than the second file.
+     *
+     */
+    @Override
+    public int compare(final File file1, final File file2) {
+        final String suffix1 = FilenameUtils.getExtension(file1.getName());
+        final String suffix2 = FilenameUtils.getExtension(file2.getName());
+        return ioCase.checkCompareTo(suffix1, suffix2);
+    }
+
+    /**
+     * String representation of this file comparator.
+     *
+     * @return String representation of this file comparator
+     */
+    @Override
+    public String toString() {
+        return super.toString() + "[ioCase=" + ioCase + "]";
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/comparator/LastModifiedFileComparator.java b/src/main/java/org/apache/commons/io/comparator/LastModifiedFileComparator.java
new file mode 100644
index 0000000..fdfadb7
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/comparator/LastModifiedFileComparator.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import java.io.File;
+import java.io.Serializable;
+import java.util.Comparator;
+
+import org.apache.commons.io.FileUtils;
+
+/**
+ * Compare the <b>last modified date/time</b> of two files for order
+ * (see {@link FileUtils#lastModifiedUnchecked(File)}).
+ * <p>
+ * This comparator can be used to sort lists or arrays of files
+ * by their last modified date/time.
+ * </p>
+ * <p>
+ * Example of sorting a list of files using the
+ * {@link #LASTMODIFIED_COMPARATOR} singleton instance:
+ * </p>
+ * <pre>
+ *       List&lt;File&gt; list = ...
+ *       ((AbstractFileComparator) LastModifiedFileComparator.LASTMODIFIED_COMPARATOR).sort(list);
+ * </pre>
+ * <p>
+ * Example of doing a <i>reverse</i> sort of an array of files using the
+ * {@link #LASTMODIFIED_REVERSE} singleton instance:
+ * </p>
+ * <pre>
+ *       File[] array = ...
+ *       ((AbstractFileComparator) LastModifiedFileComparator.LASTMODIFIED_REVERSE).sort(array);
+ * </pre>
+ *
+ * @since 1.4
+ */
+public class LastModifiedFileComparator extends AbstractFileComparator implements Serializable {
+
+    private static final long serialVersionUID = 7372168004395734046L;
+
+    /** Last modified comparator instance. */
+    public static final Comparator<File> LASTMODIFIED_COMPARATOR = new LastModifiedFileComparator();
+
+    /** Reverse last modified comparator instance. */
+    public static final Comparator<File> LASTMODIFIED_REVERSE = new ReverseFileComparator(LASTMODIFIED_COMPARATOR);
+
+    /**
+     * Compares the last modified date/time of two files.
+     *
+     * @param file1 The first file to compare.
+     * @param file2 The second file to compare.
+     * @return a negative value if the first file's last modified date/time is less than the second, zero if the last
+     *         modified date/time are the same and a positive value if the first files last modified date/time is
+     *         greater than the second file.
+     */
+    @Override
+    public int compare(final File file1, final File file2) {
+        final long result = FileUtils.lastModifiedUnchecked(file1) - FileUtils.lastModifiedUnchecked(file2);
+        if (result < 0) {
+            return -1;
+        }
+        if (result > 0) {
+            return 1;
+        }
+        return 0;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/comparator/NameFileComparator.java b/src/main/java/org/apache/commons/io/comparator/NameFileComparator.java
new file mode 100644
index 0000000..db3e8b2
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/comparator/NameFileComparator.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import java.io.File;
+import java.io.Serializable;
+import java.util.Comparator;
+
+import org.apache.commons.io.IOCase;
+
+/**
+ * Compare the <b>names</b> of two files for order (see {@link File#getName()}).
+ * <p>
+ * This comparator can be used to sort lists or arrays of files
+ * by their name either in a case-sensitive, case-insensitive or
+ * system dependent case-sensitive way. A number of singleton instances
+ * are provided for the various case sensitivity options (using {@link IOCase})
+ * and the reverse of those options.
+ * </p>
+ * <p>
+ * Example of a <i>case-sensitive</i> file name sort using the
+ * {@link #NAME_COMPARATOR} singleton instance:
+ * </p>
+ * <pre>
+ *       List&lt;File&gt; list = ...
+ *       ((AbstractFileComparator) NameFileComparator.NAME_COMPARATOR).sort(list);
+ * </pre>
+ * <p>
+ * Example of a <i>reverse case-insensitive</i> file name sort using the
+ * {@link #NAME_INSENSITIVE_REVERSE} singleton instance:
+ * </p>
+ * <pre>
+ *       File[] array = ...
+ *       ((AbstractFileComparator) NameFileComparator.NAME_INSENSITIVE_REVERSE).sort(array);
+ * </pre>
+ *
+ * @since 1.4
+ */
+public class NameFileComparator extends AbstractFileComparator implements Serializable {
+
+    private static final long serialVersionUID = 8397947749814525798L;
+
+    /** Case-sensitive name comparator instance (see {@link IOCase#SENSITIVE}) */
+    public static final Comparator<File> NAME_COMPARATOR = new NameFileComparator();
+
+    /** Reverse case-sensitive name comparator instance (see {@link IOCase#SENSITIVE}) */
+    public static final Comparator<File> NAME_REVERSE = new ReverseFileComparator(NAME_COMPARATOR);
+
+    /** Case-insensitive name comparator instance (see {@link IOCase#INSENSITIVE}) */
+    public static final Comparator<File> NAME_INSENSITIVE_COMPARATOR = new NameFileComparator(IOCase.INSENSITIVE);
+
+    /** Reverse case-insensitive name comparator instance (see {@link IOCase#INSENSITIVE}) */
+    public static final Comparator<File> NAME_INSENSITIVE_REVERSE = new ReverseFileComparator(NAME_INSENSITIVE_COMPARATOR);
+
+    /** System sensitive name comparator instance (see {@link IOCase#SYSTEM}) */
+    public static final Comparator<File> NAME_SYSTEM_COMPARATOR = new NameFileComparator(IOCase.SYSTEM);
+
+    /** Reverse system sensitive name comparator instance (see {@link IOCase#SYSTEM}) */
+    public static final Comparator<File> NAME_SYSTEM_REVERSE = new ReverseFileComparator(NAME_SYSTEM_COMPARATOR);
+
+    /** Whether the comparison is case-sensitive. */
+    private final IOCase ioCase;
+
+    /**
+     * Constructs a case-sensitive file name comparator instance.
+     */
+    public NameFileComparator() {
+        this.ioCase = IOCase.SENSITIVE;
+    }
+
+    /**
+     * Constructs a file name comparator instance with the specified case-sensitivity.
+     *
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     */
+    public NameFileComparator(final IOCase ioCase) {
+        this.ioCase = IOCase.value(ioCase, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Compares the names of two files with the specified case sensitivity.
+     *
+     * @param file1 The first file to compare
+     * @param file2 The second file to compare
+     * @return a negative value if the first file's name
+     * is less than the second, zero if the names are the
+     * same and a positive value if the first files name
+     * is greater than the second file.
+     */
+    @Override
+    public int compare(final File file1, final File file2) {
+        return ioCase.checkCompareTo(file1.getName(), file2.getName());
+    }
+
+    /**
+     * String representation of this file comparator.
+     *
+     * @return String representation of this file comparator
+     */
+    @Override
+    public String toString() {
+        return super.toString() + "[ioCase=" + ioCase + "]";
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/comparator/PathFileComparator.java b/src/main/java/org/apache/commons/io/comparator/PathFileComparator.java
new file mode 100644
index 0000000..4386c1a
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/comparator/PathFileComparator.java
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import java.io.File;
+import java.io.Serializable;
+import java.util.Comparator;
+
+import org.apache.commons.io.IOCase;
+
+/**
+ * Compare the <b>path</b> of two files for order (see {@link File#getPath()}).
+ * <p>
+ * This comparator can be used to sort lists or arrays of files
+ * by their path either in a case-sensitive, case-insensitive or
+ * system dependent case-sensitive way. A number of singleton instances
+ * are provided for the various case sensitivity options (using {@link IOCase})
+ * and the reverse of those options.
+ * </p>
+ * <p>
+ * Example of a <i>case-sensitive</i> file path sort using the
+ * {@link #PATH_COMPARATOR} singleton instance:
+ * </p>
+ * <pre>
+ *       List&lt;File&gt; list = ...
+ *       ((AbstractFileComparator) PathFileComparator.PATH_COMPARATOR).sort(list);
+ * </pre>
+ * <p>
+ * Example of a <i>reverse case-insensitive</i> file path sort using the
+ * {@link #PATH_INSENSITIVE_REVERSE} singleton instance:
+ * </p>
+ * <pre>
+ *       File[] array = ...
+ *       ((AbstractFileComparator) PathFileComparator.PATH_INSENSITIVE_REVERSE).sort(array);
+ * </pre>
+  *
+ * @since 1.4
+ */
+public class PathFileComparator extends AbstractFileComparator implements Serializable {
+
+    private static final long serialVersionUID = 6527501707585768673L;
+
+    /** Case-sensitive path comparator instance (see {@link IOCase#SENSITIVE}) */
+    public static final Comparator<File> PATH_COMPARATOR = new PathFileComparator();
+
+    /** Reverse case-sensitive path comparator instance (see {@link IOCase#SENSITIVE}) */
+    public static final Comparator<File> PATH_REVERSE = new ReverseFileComparator(PATH_COMPARATOR);
+
+    /** Case-insensitive path comparator instance (see {@link IOCase#INSENSITIVE}) */
+    public static final Comparator<File> PATH_INSENSITIVE_COMPARATOR = new PathFileComparator(IOCase.INSENSITIVE);
+
+    /** Reverse case-insensitive path comparator instance (see {@link IOCase#INSENSITIVE}) */
+    public static final Comparator<File> PATH_INSENSITIVE_REVERSE = new ReverseFileComparator(PATH_INSENSITIVE_COMPARATOR);
+
+    /** System sensitive path comparator instance (see {@link IOCase#SYSTEM}) */
+    public static final Comparator<File> PATH_SYSTEM_COMPARATOR = new PathFileComparator(IOCase.SYSTEM);
+
+    /** Reverse system sensitive path comparator instance (see {@link IOCase#SYSTEM}) */
+    public static final Comparator<File> PATH_SYSTEM_REVERSE = new ReverseFileComparator(PATH_SYSTEM_COMPARATOR);
+
+    /** Whether the comparison is case-sensitive. */
+    private final IOCase ioCase;
+
+    /**
+     * Constructs a case-sensitive file path comparator instance.
+     */
+    public PathFileComparator() {
+        this.ioCase = IOCase.SENSITIVE;
+    }
+
+    /**
+     * Constructs a file path comparator instance with the specified case-sensitivity.
+     *
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     */
+    public PathFileComparator(final IOCase ioCase) {
+        this.ioCase = IOCase.value(ioCase, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Compares the paths of two files the specified case sensitivity.
+     *
+     * @param file1 The first file to compare
+     * @param file2 The second file to compare
+     * @return a negative value if the first file's path
+     * is less than the second, zero if the paths are the
+     * same and a positive value if the first files path
+     * is greater than the second file.
+     *
+     */
+    @Override
+    public int compare(final File file1, final File file2) {
+        return ioCase.checkCompareTo(file1.getPath(), file2.getPath());
+    }
+
+    /**
+     * String representation of this file comparator.
+     *
+     * @return String representation of this file comparator
+     */
+    @Override
+    public String toString() {
+        return super.toString() + "[ioCase=" + ioCase + "]";
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/comparator/ReverseFileComparator.java b/src/main/java/org/apache/commons/io/comparator/ReverseFileComparator.java
new file mode 100644
index 0000000..eed5bfe
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/comparator/ReverseFileComparator.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import java.io.File;
+import java.io.Serializable;
+import java.util.Comparator;
+import java.util.Objects;
+
+/**
+ * Reverses the result of comparing two {@link File} objects using the delegate {@link Comparator}.
+ *
+ * @since 1.4
+ */
+class ReverseFileComparator extends AbstractFileComparator implements Serializable {
+
+    private static final long serialVersionUID = -4808255005272229056L;
+    private final Comparator<File> delegate;
+
+    /**
+     * Constructs an instance with the specified delegate {@link Comparator}.
+     *
+     * @param delegate The comparator to delegate to.
+     */
+    public ReverseFileComparator(final Comparator<File> delegate) {
+        this.delegate = Objects.requireNonNull(delegate, "delegate");
+    }
+
+    /**
+     * Compares using the delegate Comparator, reversing the result.
+     *
+     * @param file1 The first file to compare.
+     * @param file2 The second file to compare.
+     * @return the result from the delegate {@link Comparator#compare(Object, Object)} reversing the value (i.e.
+     *         positive becomes negative and vice versa).
+     */
+    @Override
+    public int compare(final File file1, final File file2) {
+        return delegate.compare(file2, file1); // parameters switched round
+    }
+
+    /**
+     * Returns the String representation of this file comparator.
+     *
+     * @return String representation of this file comparator.
+     */
+    @Override
+    public String toString() {
+        return super.toString() + "[" + delegate.toString() + "]";
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/comparator/SizeFileComparator.java b/src/main/java/org/apache/commons/io/comparator/SizeFileComparator.java
new file mode 100644
index 0000000..0ee5e4a
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/comparator/SizeFileComparator.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import java.io.File;
+import java.io.Serializable;
+import java.util.Comparator;
+
+import org.apache.commons.io.FileUtils;
+
+/**
+ * Compare the <b>length/size</b> of two files for order (see
+ * {@link File#length()} and {@link FileUtils#sizeOfDirectory(File)}).
+ * <p>
+ * This comparator can be used to sort lists or arrays of files
+ * by their length/size.
+ * <p>
+ * Example of sorting a list of files using the
+ * {@link #SIZE_COMPARATOR} singleton instance:
+ * <pre>
+ *       List&lt;File&gt; list = ...
+ *       ((AbstractFileComparator) SizeFileComparator.SIZE_COMPARATOR).sort(list);
+ * </pre>
+ * <p>
+ * Example of doing a <i>reverse</i> sort of an array of files using the
+ * {@link #SIZE_REVERSE} singleton instance:
+ * <pre>
+ *       File[] array = ...
+ *       ((AbstractFileComparator) SizeFileComparator.SIZE_REVERSE).sort(array);
+ * </pre>
+ * <p>
+ * <strong>N.B.</strong> Directories are treated as <b>zero size</b> unless
+ * {@code sumDirectoryContents} is {@code true}.
+ *
+ * @since 1.4
+ */
+public class SizeFileComparator extends AbstractFileComparator implements Serializable {
+
+    private static final long serialVersionUID = -1201561106411416190L;
+
+    /** Size comparator instance - directories are treated as zero size */
+    public static final Comparator<File> SIZE_COMPARATOR = new SizeFileComparator();
+
+    /** Reverse size comparator instance - directories are treated as zero size */
+    public static final Comparator<File> SIZE_REVERSE = new ReverseFileComparator(SIZE_COMPARATOR);
+
+    /**
+     * Size comparator instance which sums the size of a directory's contents
+     * using {@link FileUtils#sizeOfDirectory(File)}
+     */
+    public static final Comparator<File> SIZE_SUMDIR_COMPARATOR = new SizeFileComparator(true);
+
+    /**
+     * Reverse size comparator instance which sums the size of a directory's contents
+     * using {@link FileUtils#sizeOfDirectory(File)}
+     */
+    public static final Comparator<File> SIZE_SUMDIR_REVERSE = new ReverseFileComparator(SIZE_SUMDIR_COMPARATOR);
+
+    /** Whether the sum of the directory's contents should be calculated. */
+    private final boolean sumDirectoryContents;
+
+    /**
+     * Constructs a file size comparator instance (directories treated as zero size).
+     */
+    public SizeFileComparator() {
+        this.sumDirectoryContents = false;
+    }
+
+    /**
+     * Constructs a file size comparator instance specifying whether the size of
+     * the directory contents should be aggregated.
+     * <p>
+     * If the {@code sumDirectoryContents} is {@code true} The size of
+     * directories is calculated using  {@link FileUtils#sizeOfDirectory(File)}.
+     * </p>
+     *
+     * @param sumDirectoryContents {@code true} if the sum of the directories' contents
+     *  should be calculated, otherwise {@code false} if directories should be treated
+     *  as size zero (see {@link FileUtils#sizeOfDirectory(File)}).
+     */
+    public SizeFileComparator(final boolean sumDirectoryContents) {
+        this.sumDirectoryContents = sumDirectoryContents;
+    }
+
+    /**
+     * Compares the length of two files.
+     *
+     * @param file1 The first file to compare
+     * @param file2 The second file to compare
+     * @return a negative value if the first file's length
+     * is less than the second, zero if the lengths are the
+     * same and a positive value if the first files length
+     * is greater than the second file.
+     *
+     */
+    @Override
+    public int compare(final File file1, final File file2) {
+        final long size1;
+        if (file1.isDirectory()) {
+            size1 = sumDirectoryContents && file1.exists() ? FileUtils.sizeOfDirectory(file1) : 0;
+        } else {
+            size1 = file1.length();
+        }
+        final long size2;
+        if (file2.isDirectory()) {
+            size2 = sumDirectoryContents && file2.exists() ? FileUtils.sizeOfDirectory(file2) : 0;
+        } else {
+            size2 = file2.length();
+        }
+        final long result = size1 - size2;
+        if (result < 0) {
+            return -1;
+        }
+        if (result > 0) {
+            return 1;
+        }
+        return 0;
+    }
+
+    /**
+     * String representation of this file comparator.
+     *
+     * @return String representation of this file comparator
+     */
+    @Override
+    public String toString() {
+        return super.toString() + "[sumDirectoryContents=" + sumDirectoryContents + "]";
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/comparator/package.html b/src/main/java/org/apache/commons/io/comparator/package.html
new file mode 100644
index 0000000..433420a
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/comparator/package.html
@@ -0,0 +1,184 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<html>
+<body>
+<p>This package provides various {@link java.util.Comparator} implementations
+for {@link java.io.File}s.
+</p>
+<h2>Sorting</h2>
+<p>
+  All the comparators include <i>convenience</i> utility <code>sort(File...)</code> and
+  <code>sort(List)</code> methods.
+</p>
+<p>
+  For example, to sort the files in a directory by name:
+</p>
+  <pre>
+        File[] files = dir.listFiles();
+        NameFileComparator.NAME_COMPARATOR.sort(files);
+  </pre>
+<p>
+  ...alternatively you can do this in one line:
+</p>
+<pre>
+      File[] files = NameFileComparator.NAME_COMPARATOR.sort(dir.listFiles());
+</pre>
+
+<h2>Composite Comparator</h2>
+<p>
+  The <a href="CompositeFileComparator.html">CompositeFileComparator</a> can be used
+  to compare (and sort lists or arrays of files) by combining a number of other comparators.
+</p>
+<p>
+  For example, to sort an array of files by type (i.e. directory or file)
+  and then by name:
+</p>
+<pre>
+      CompositeFileComparator comparator =
+                      new CompositeFileComparator(
+                                  DirectoryFileComparator.DIRECTORY_COMPARATOR,
+                                  NameFileComparator.NAME_COMPARATOR);
+      File[] files = dir.listFiles();
+      comparator.sort(files);
+</pre>
+
+<h2>Singleton Instances (thread-safe)</h2>
+<p>
+ The {@link java.util.Comparator} implementations have some <i>convenience</i>
+ singleton(<i>thread-safe</i>) instances ready to use:
+</p>
+<ul>
+   <li><a href="DefaultFileComparator.html">DefaultFileComparator</a> - default file compare:
+       <ul>
+          <li><a href="DefaultFileComparator.html#DEFAULT_COMPARATOR">DEFAULT_COMPARATOR</a>
+              - Compare using <code>File.compareTo(File)</code> method.
+          </li>
+          <li><a href="DefaultFileComparator.html#DEFAULT_REVERSE">DEFAULT_REVERSE</a>
+              - Reverse compare of <code>File.compareTo(File)</code> method.
+          </li>
+       </ul>
+   </li>
+   <li><a href="DirectoryFileComparator.html">DirectoryFileComparator</a> - compare by type (directory or file):
+       <ul>
+          <li><a href="DirectoryFileComparator.html#DIRECTORY_COMPARATOR">DIRECTORY_COMPARATOR</a>
+              - Compare using <code>File.isDirectory()</code> method (directories &lt; files).
+          </li>
+          <li><a href="DirectoryFileComparator.html#DIRECTORY_REVERSE">DIRECTORY_REVERSE</a>
+              - Reverse compare of <code>File.isDirectory()</code> method  (directories &gt;files).
+          </li>
+       </ul>
+   </li>
+   <li><a href="ExtensionFileComparator.html">ExtensionFileComparator</a> - compare file extensions:
+       <ul>
+          <li><a href="ExtensionFileComparator.html#EXTENSION_COMPARATOR">EXTENSION_COMPARATOR</a>
+              - Compare using <code>FilenameUtils.getExtension(String)</code> method.
+          </li>
+          <li><a href="ExtensionFileComparator.html#EXTENSION_REVERSE">EXTENSION_REVERSE</a>
+              - Reverse compare of <code>FilenameUtils.getExtension(String)</code> method.
+          </li>
+          <li><a href="ExtensionFileComparator.html#EXTENSION_INSENSITIVE_COMPARATOR">EXTENSION_INSENSITIVE_COMPARATOR</a>
+              - Case-insensitive compare using <code>FilenameUtils.getExtension(String)</code> method.
+          </li>
+          <li><a href="ExtensionFileComparator.html#EXTENSION_INSENSITIVE_REVERSE">EXTENSION_INSENSITIVE_REVERSE</a>
+              - Reverse case-insensitive compare of <code>FilenameUtils.getExtension(String)</code> method.
+          </li>
+          <li><a href="ExtensionFileComparator.html#EXTENSION_SYSTEM_COMPARATOR">EXTENSION_SYSTEM_COMPARATOR</a>
+              -  System sensitive compare using <code>FilenameUtils.getExtension(String)</code> method.
+          </li>
+          <li><a href="ExtensionFileComparator.html#EXTENSION_SYSTEM_REVERSE">EXTENSION_SYSTEM_REVERSE</a>
+              - Reverse system sensitive compare of <code>FilenameUtils.getExtension(String)</code> method.
+          </li>
+       </ul>
+   </li>
+   <li><a href="LastModifiedFileComparator.html">LastModifiedFileComparator</a>
+       - compare the file's last modified date/time:
+       <ul>
+          <li><a href="LastModifiedFileComparator.html#LASTMODIFIED_COMPARATOR">LASTMODIFIED_COMPARATOR</a>
+              - Compare using <code>File.lastModified()</code> method.
+          </li>
+          <li><a href="LastModifiedFileComparator.html#LASTMODIFIED_REVERSE">LASTMODIFIED_REVERSE</a>
+              - Reverse compare of <code>File.lastModified()</code> method.
+          </li>
+       </ul>
+   </li>
+    <li><a href="NameFileComparator.html">NameFileComparator</a> - compare file names:
+       <ul>
+          <li><a href="NameFileComparator.html#NAME_COMPARATOR">NAME_COMPARATOR</a>
+              - Compare using <code>File.getName()</code> method.
+          </li>
+          <li><a href="NameFileComparator.html#NAME_REVERSE">NAME_REVERSE</a>
+              - Reverse compare of <code>File.getName()</code> method.
+          </li>
+          <li><a href="NameFileComparator.html#NAME_INSENSITIVE_COMPARATOR">NAME_INSENSITIVE_COMPARATOR</a>
+              - Case-insensitive compare using <code>File.getName()</code> method.
+          </li>
+          <li><a href="NameFileComparator.html#NAME_INSENSITIVE_REVERSE">NAME_INSENSITIVE_REVERSE</a>
+              - Reverse case-insensitive compare of <code>File.getName()</code> method.
+          </li>
+          <li><a href="NameFileComparator.html#NAME_SYSTEM_COMPARATOR">NAME_SYSTEM_COMPARATOR</a>
+              -  System sensitive compare using <code>File.getName()</code> method.
+          </li>
+          <li><a href="NameFileComparator.html#NAME_SYSTEM_REVERSE">NAME_SYSTEM_REVERSE</a>
+              - Reverse system sensitive compare of <code>File.getName()</code> method.
+          </li>
+       </ul>
+   </li>
+    <li><a href="PathFileComparator.html">PathFileComparator</a> - compare file paths:
+       <ul>
+          <li><a href="PathFileComparator.html#PATH_COMPARATOR">PATH_COMPARATOR</a>
+              - Compare using <code>File.getPath()</code> method.
+          </li>
+          <li><a href="PathFileComparator.html#PATH_REVERSE">PATH_REVERSE</a>
+              - Reverse compare of <code>File.getPath()</code> method.
+          </li>
+          <li><a href="PathFileComparator.html#PATH_INSENSITIVE_COMPARATOR">PATH_INSENSITIVE_COMPARATOR</a>
+              - Case-insensitive compare using <code>File.getPath()</code> method.
+          </li>
+          <li><a href="PathFileComparator.html#PATH_INSENSITIVE_REVERSE">PATH_INSENSITIVE_REVERSE</a>
+              - Reverse case-insensitive compare of <code>File.getPath()</code> method.
+          </li>
+          <li><a href="PathFileComparator.html#PATH_SYSTEM_COMPARATOR">PATH_SYSTEM_COMPARATOR</a>
+              -  System sensitive compare using <code>File.getPath()</code> method.
+          </li>
+          <li><a href="PathFileComparator.html#PATH_SYSTEM_REVERSE">PATH_SYSTEM_REVERSE</a>
+              - Reverse system sensitive compare of <code>File.getPath()</code> method.
+          </li>
+       </ul>
+   </li>
+   <li><a href="SizeFileComparator.html">SizeFileComparator</a> - compare the file's size:
+       <ul>
+          <li><a href="SizeFileComparator.html#SIZE_COMPARATOR">SIZE_COMPARATOR</a>
+              - Compare using <code>File.length()</code> method (directories treated as zero length).
+          </li>
+          <li><a href="SizeFileComparator.html#SIZE_REVERSE">LASTMODIFIED_REVERSE</a>
+              - Reverse compare of <code>File.length()</code> method (directories treated as zero length).
+          </li>
+          <li><a href="SizeFileComparator.html#SIZE_SUMDIR_COMPARATOR">SIZE_SUMDIR_COMPARATOR</a>
+              - Compare using <code>FileUtils.sizeOfDirectory(File)</code> method
+               (sums the size of a directory's contents).
+          </li>
+          <li><a href="SizeFileComparator.html#SIZE_SUMDIR_REVERSE">SIZE_SUMDIR_REVERSE</a>
+              - Reverse compare of <code>FileUtils.sizeOfDirectory(File)</code> method
+               (sums the size of a directory's contents).
+          </li>
+       </ul>
+   </li>
+</ul>
+
+</body>
+</html>
diff --git a/src/main/java/org/apache/commons/io/file/AccumulatorPathVisitor.java b/src/main/java/org/apache/commons/io/file/AccumulatorPathVisitor.java
new file mode 100644
index 0000000..0574ddd
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/AccumulatorPathVisitor.java
@@ -0,0 +1,239 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+
+import org.apache.commons.io.file.Counters.PathCounters;
+import org.apache.commons.io.function.IOBiFunction;
+
+/**
+ * Accumulates normalized paths during visitation.
+ * <p>
+ * Use with care on large file trees as each visited Path element is remembered.
+ * </p>
+ * <h2>Example</h2>
+ *
+ * <pre>
+ * Path dir = PathUtils.current();
+ * // We are interested in files older than one day
+ * Instant cutoff = Instant.now().minus(Duration.ofDays(1));
+ * AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(new AgeFileFilter(cutoff));
+ * //
+ * // Walk one dir
+ * Files.walkFileTree(dir, Collections.emptySet(), 1, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getFileList());
+ * //
+ * visitor.getPathCounters().reset();
+ * //
+ * // Walk dir tree
+ * Files.walkFileTree(dir, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getDirList());
+ * System.out.println(visitor.getFileList());
+ * </pre>
+ *
+ * @since 2.7
+ */
+public class AccumulatorPathVisitor extends CountingPathVisitor {
+
+    /**
+     * Creates a new instance configured with a BigInteger {@link PathCounters}.
+     *
+     * @return a new instance configured with a BigInteger {@link PathCounters}.
+     */
+    public static AccumulatorPathVisitor withBigIntegerCounters() {
+        return new AccumulatorPathVisitor(Counters.bigIntegerPathCounters());
+    }
+
+    /**
+     * Creates a new instance configured with a BigInteger {@link PathCounters}.
+     *
+     * @param fileFilter Filters files to accumulate and count.
+     * @param dirFilter Filters directories to accumulate and count.
+     * @return a new instance configured with a long {@link PathCounters}.
+     * @since 2.9.0
+     */
+    public static AccumulatorPathVisitor withBigIntegerCounters(final PathFilter fileFilter,
+        final PathFilter dirFilter) {
+        return new AccumulatorPathVisitor(Counters.bigIntegerPathCounters(), fileFilter, dirFilter);
+    }
+
+    /**
+     * Creates a new instance configured with a long {@link PathCounters}.
+     *
+     * @return a new instance configured with a long {@link PathCounters}.
+     */
+    public static AccumulatorPathVisitor withLongCounters() {
+        return new AccumulatorPathVisitor(Counters.longPathCounters());
+    }
+
+    /**
+     * Creates a new instance configured with a long {@link PathCounters}.
+     *
+     * @param fileFilter Filters files to accumulate and count.
+     * @param dirFilter Filters directories to accumulate and count.
+     * @return a new instance configured with a long {@link PathCounters}.
+     * @since 2.9.0
+     */
+    public static AccumulatorPathVisitor withLongCounters(final PathFilter fileFilter, final PathFilter dirFilter) {
+        return new AccumulatorPathVisitor(Counters.longPathCounters(), fileFilter, dirFilter);
+    }
+
+    private final List<Path> dirList = new ArrayList<>();
+
+    private final List<Path> fileList = new ArrayList<>();
+
+    /**
+     * Constructs a new instance.
+     *
+     * @since 2.9.0
+     */
+    public AccumulatorPathVisitor() {
+        super(Counters.noopPathCounters());
+    }
+
+    /**
+     * Constructs a new instance that counts file system elements.
+     *
+     * @param pathCounter How to count path visits.
+     */
+    public AccumulatorPathVisitor(final PathCounters pathCounter) {
+        super(pathCounter);
+    }
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param pathCounter How to count path visits.
+     * @param fileFilter Filters which files to count.
+     * @param dirFilter Filters which directories to count.
+     * @since 2.9.0
+     */
+    public AccumulatorPathVisitor(final PathCounters pathCounter, final PathFilter fileFilter, final PathFilter dirFilter) {
+        super(pathCounter, fileFilter, dirFilter);
+    }
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param pathCounter How to count path visits.
+     * @param fileFilter Filters which files to count.
+     * @param dirFilter Filters which directories to count.
+     * @param visitFileFailed Called on {@link #visitFileFailed(Path, IOException)}.
+     * @since 2.12.0
+     */
+    public AccumulatorPathVisitor(final PathCounters pathCounter, final PathFilter fileFilter, final PathFilter dirFilter,
+        final IOBiFunction<Path, IOException, FileVisitResult> visitFileFailed) {
+        super(pathCounter, fileFilter, dirFilter, visitFileFailed);
+    }
+
+    private void add(final List<Path> list, final Path dir) {
+        list.add(dir.normalize());
+    }
+
+    @Override
+    public boolean equals(final Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!super.equals(obj)) {
+            return false;
+        }
+        if (!(obj instanceof AccumulatorPathVisitor)) {
+            return false;
+        }
+        final AccumulatorPathVisitor other = (AccumulatorPathVisitor) obj;
+        return Objects.equals(dirList, other.dirList) && Objects.equals(fileList, other.fileList);
+    }
+
+    /**
+     * Gets the list of visited directories.
+     *
+     * @return the list of visited directories.
+     */
+    public List<Path> getDirList() {
+        return dirList;
+    }
+
+    /**
+     * Gets the list of visited files.
+     *
+     * @return the list of visited files.
+     */
+    public List<Path> getFileList() {
+        return fileList;
+    }
+
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = super.hashCode();
+        result = prime * result + Objects.hash(dirList, fileList);
+        return result;
+    }
+
+    /**
+     * Relativizes each directory path with {@link Path#relativize(Path)} against the given {@code parent}, optionally
+     * sorting the result.
+     *
+     * @param parent A parent path
+     * @param sort Whether to sort
+     * @param comparator How to sort, null uses default sorting.
+     * @return A new list
+     */
+    public List<Path> relativizeDirectories(final Path parent, final boolean sort,
+        final Comparator<? super Path> comparator) {
+        return PathUtils.relativize(getDirList(), parent, sort, comparator);
+    }
+
+    /**
+     * Relativizes each file path with {@link Path#relativize(Path)} against the given {@code parent}, optionally
+     * sorting the result.
+     *
+     * @param parent A parent path
+     * @param sort Whether to sort
+     * @param comparator How to sort, null uses default sorting.
+     * @return A new list
+     */
+    public List<Path> relativizeFiles(final Path parent, final boolean sort,
+        final Comparator<? super Path> comparator) {
+        return PathUtils.relativize(getFileList(), parent, sort, comparator);
+    }
+
+    @Override
+    protected void updateDirCounter(final Path dir, final IOException exc) {
+        super.updateDirCounter(dir, exc);
+        add(dirList, dir);
+    }
+
+    @Override
+    protected void updateFileCounters(final Path file, final BasicFileAttributes attributes) {
+        super.updateFileCounters(file, attributes);
+        add(fileList, file);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/file/CleaningPathVisitor.java b/src/main/java/org/apache/commons/io/file/CleaningPathVisitor.java
new file mode 100644
index 0000000..a94cf63
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/CleaningPathVisitor.java
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Arrays;
+import java.util.Objects;
+
+import org.apache.commons.io.file.Counters.PathCounters;
+
+/**
+ * Deletes files but not directories as a visit proceeds.
+ *
+ * @since 2.7
+ */
+public class CleaningPathVisitor extends CountingPathVisitor {
+
+    /**
+     * Creates a new instance configured with a BigInteger {@link PathCounters}.
+     *
+     * @return a new instance configured with a BigInteger {@link PathCounters}.
+     */
+    public static CountingPathVisitor withBigIntegerCounters() {
+        return new CleaningPathVisitor(Counters.bigIntegerPathCounters());
+    }
+
+    /**
+     * Creates a new instance configured with a long {@link PathCounters}.
+     *
+     * @return a new instance configured with a long {@link PathCounters}.
+     */
+    public static CountingPathVisitor withLongCounters() {
+        return new CleaningPathVisitor(Counters.longPathCounters());
+    }
+
+    private final String[] skip;
+    private final boolean overrideReadOnly;
+
+    /**
+     * Constructs a new visitor that deletes files except for the files and directories explicitly given.
+     *
+     * @param pathCounter How to count visits.
+     * @param deleteOption How deletion is handled.
+     * @param skip The files to skip deleting.
+     * @since 2.8.0
+     */
+    public CleaningPathVisitor(final PathCounters pathCounter, final DeleteOption[] deleteOption, final String... skip) {
+        super(pathCounter);
+        final String[] temp = skip != null ? skip.clone() : EMPTY_STRING_ARRAY;
+        Arrays.sort(temp);
+        this.skip = temp;
+        this.overrideReadOnly = StandardDeleteOption.overrideReadOnly(deleteOption);
+    }
+
+    /**
+     * Constructs a new visitor that deletes files except for the files and directories explicitly given.
+     *
+     * @param pathCounter How to count visits.
+     * @param skip The files to skip deleting.
+     */
+    public CleaningPathVisitor(final PathCounters pathCounter, final String... skip) {
+        this(pathCounter, PathUtils.EMPTY_DELETE_OPTION_ARRAY, skip);
+    }
+
+    /**
+     * Returns true to process the given path, false if not.
+     *
+     * @param path the path to test.
+     * @return true to process the given path, false if not.
+     */
+    private boolean accept(final Path path) {
+        return Arrays.binarySearch(skip, Objects.toString(path.getFileName(), null)) < 0;
+    }
+
+    @Override
+    public boolean equals(final Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!super.equals(obj)) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        final CleaningPathVisitor other = (CleaningPathVisitor) obj;
+        return overrideReadOnly == other.overrideReadOnly && Arrays.equals(skip, other.skip);
+    }
+
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = super.hashCode();
+        result = prime * result + Arrays.hashCode(skip);
+        result = prime * result + Objects.hash(overrideReadOnly);
+        return result;
+    }
+
+    @Override
+    public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attributes) throws IOException {
+        super.preVisitDirectory(dir, attributes);
+        return accept(dir) ? FileVisitResult.CONTINUE : FileVisitResult.SKIP_SUBTREE;
+    }
+
+    @Override
+    public FileVisitResult visitFile(final Path file, final BasicFileAttributes attributes) throws IOException {
+        // Files.deleteIfExists() never follows links, so use LinkOption.NOFOLLOW_LINKS in other calls to Files.
+        if (accept(file) && Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
+            if (overrideReadOnly) {
+                PathUtils.setReadOnly(file, false, LinkOption.NOFOLLOW_LINKS);
+            }
+            Files.deleteIfExists(file);
+        }
+        updateFileCounters(file, attributes);
+        return FileVisitResult.CONTINUE;
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/commons/io/file/CopyDirectoryVisitor.java b/src/main/java/org/apache/commons/io/file/CopyDirectoryVisitor.java
new file mode 100644
index 0000000..57896fd
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/CopyDirectoryVisitor.java
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.io.IOException;
+import java.nio.file.CopyOption;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.ProviderMismatchException;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Arrays;
+import java.util.Objects;
+
+import org.apache.commons.io.file.Counters.PathCounters;
+
+/**
+ * Copies a source directory to a target directory.
+ *
+ * @since 2.7
+ */
+public class CopyDirectoryVisitor extends CountingPathVisitor {
+
+    private static CopyOption[] toCopyOption(final CopyOption... copyOptions) {
+        return copyOptions == null ? PathUtils.EMPTY_COPY_OPTIONS : copyOptions.clone();
+    }
+
+    private final CopyOption[] copyOptions;
+    private final Path sourceDirectory;
+    private final Path targetDirectory;
+
+    /**
+     * Constructs a new visitor that deletes files except for the files and directories explicitly given.
+     *
+     * @param pathCounter How to count visits.
+     * @param sourceDirectory The source directory
+     * @param targetDirectory The target directory
+     * @param copyOptions Specifies how the copying should be done.
+     */
+    public CopyDirectoryVisitor(final PathCounters pathCounter, final Path sourceDirectory, final Path targetDirectory, final CopyOption... copyOptions) {
+        super(pathCounter);
+        this.sourceDirectory = sourceDirectory;
+        this.targetDirectory = targetDirectory;
+        this.copyOptions = toCopyOption(copyOptions);
+    }
+
+    /**
+     * Constructs a new visitor that deletes files except for the files and directories explicitly given.
+     *
+     * @param pathCounter How to count visits.
+     * @param fileFilter How to filter file paths.
+     * @param dirFilter How to filter directory paths.
+     * @param sourceDirectory The source directory
+     * @param targetDirectory The target directory
+     * @param copyOptions Specifies how the copying should be done.
+     * @since 2.9.0
+     */
+    public CopyDirectoryVisitor(final PathCounters pathCounter, final PathFilter fileFilter, final PathFilter dirFilter, final Path sourceDirectory,
+        final Path targetDirectory, final CopyOption... copyOptions) {
+        super(pathCounter, fileFilter, dirFilter);
+        this.sourceDirectory = sourceDirectory;
+        this.targetDirectory = targetDirectory;
+        this.copyOptions = toCopyOption(copyOptions);
+    }
+
+    /**
+     * Copies the sourceFile to the targetFile.
+     *
+     * @param sourceFile the source file.
+     * @param targetFile the target file.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.8.0
+     */
+    protected void copy(final Path sourceFile, final Path targetFile) throws IOException {
+        Files.copy(sourceFile, targetFile, copyOptions);
+    }
+
+    @Override
+    public boolean equals(final Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!super.equals(obj)) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        final CopyDirectoryVisitor other = (CopyDirectoryVisitor) obj;
+        return Arrays.equals(copyOptions, other.copyOptions) && Objects.equals(sourceDirectory, other.sourceDirectory)
+            && Objects.equals(targetDirectory, other.targetDirectory);
+    }
+
+    /**
+     * Gets the copy options.
+     *
+     * @return the copy options.
+     * @since 2.8.0
+     */
+    public CopyOption[] getCopyOptions() {
+        return copyOptions.clone();
+    }
+
+    /**
+     * Gets the source directory.
+     *
+     * @return the source directory.
+     * @since 2.8.0
+     */
+    public Path getSourceDirectory() {
+        return sourceDirectory;
+    }
+
+    /**
+     * Gets the target directory.
+     *
+     * @return the target directory.
+     * @since 2.8.0
+     */
+    public Path getTargetDirectory() {
+        return targetDirectory;
+    }
+
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = super.hashCode();
+        result = prime * result + Arrays.hashCode(copyOptions);
+        result = prime * result + Objects.hash(sourceDirectory, targetDirectory);
+        return result;
+    }
+
+    @Override
+    public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAttributes attributes)
+        throws IOException {
+        final Path newTargetDir = resolveRelativeAsString(directory);
+        if (Files.notExists(newTargetDir)) {
+            Files.createDirectory(newTargetDir);
+        }
+        return super.preVisitDirectory(directory, attributes);
+    }
+
+    /**
+     * Relativizes against {@code sourceDirectory}, then resolves against {@code targetDirectory}.
+     *
+     * We have to call {@link Path#toString()} relative value because we cannot use paths belonging to different
+     * FileSystems in the Path methods, usually this leads to {@link ProviderMismatchException}.
+     *
+     * @param directory the directory to relativize.
+     * @return a new path, relativized against sourceDirectory, then resolved against targetDirectory.
+     */
+    private Path resolveRelativeAsString(final Path directory) {
+        return targetDirectory.resolve(sourceDirectory.relativize(directory).toString());
+    }
+
+    @Override
+    public FileVisitResult visitFile(final Path sourceFile, final BasicFileAttributes attributes) throws IOException {
+        final Path targetFile = resolveRelativeAsString(sourceFile);
+        copy(sourceFile, targetFile);
+        return super.visitFile(targetFile, attributes);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/file/Counters.java b/src/main/java/org/apache/commons/io/file/Counters.java
new file mode 100644
index 0000000..2b6736c
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/Counters.java
@@ -0,0 +1,454 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.math.BigInteger;
+import java.util.Objects;
+
+/**
+ * Provides counters for files, directories, and sizes, as a visit proceeds.
+ *
+ * @since 2.7
+ */
+public class Counters {
+
+    /**
+     * Counts files, directories, and sizes, as a visit proceeds.
+     */
+    private static class AbstractPathCounters implements PathCounters {
+
+        private final Counter byteCounter;
+        private final Counter directoryCounter;
+        private final Counter fileCounter;
+
+        /**
+         * Constructs a new instance.
+         *
+         * @param byteCounter the byte counter.
+         * @param directoryCounter the directory counter.
+         * @param fileCounter the file counter.
+         */
+        protected AbstractPathCounters(final Counter byteCounter, final Counter directoryCounter, final Counter fileCounter) {
+            this.byteCounter = byteCounter;
+            this.directoryCounter = directoryCounter;
+            this.fileCounter = fileCounter;
+        }
+
+        @Override
+        public boolean equals(final Object obj) {
+            if (this == obj) {
+                return true;
+            }
+            if (!(obj instanceof AbstractPathCounters)) {
+                return false;
+            }
+            final AbstractPathCounters other = (AbstractPathCounters) obj;
+            return Objects.equals(byteCounter, other.byteCounter)
+                && Objects.equals(directoryCounter, other.directoryCounter)
+                && Objects.equals(fileCounter, other.fileCounter);
+        }
+
+        @Override
+        public Counter getByteCounter() {
+            return byteCounter;
+        }
+
+        @Override
+        public Counter getDirectoryCounter() {
+            return directoryCounter;
+        }
+
+        /**
+         * Gets the count of visited files.
+         *
+         * @return the byte count of visited files.
+         */
+        @Override
+        public Counter getFileCounter() {
+            return this.fileCounter;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(byteCounter, directoryCounter, fileCounter);
+        }
+
+        @Override
+        public void reset() {
+            byteCounter.reset();
+            directoryCounter.reset();
+            fileCounter.reset();
+        }
+
+        @Override
+        public String toString() {
+            return String.format("%,d files, %,d directories, %,d bytes", Long.valueOf(fileCounter.get()),
+                Long.valueOf(directoryCounter.get()), Long.valueOf(byteCounter.get()));
+        }
+
+    }
+
+    /**
+     * Counts using a {@link BigInteger} number.
+     */
+    private static final class BigIntegerCounter implements Counter {
+
+        private BigInteger value = BigInteger.ZERO;
+
+        @Override
+        public void add(final long val) {
+            value = value.add(BigInteger.valueOf(val));
+
+        }
+
+        @Override
+        public boolean equals(final Object obj) {
+            if (this == obj) {
+                return true;
+            }
+            if (!(obj instanceof Counter)) {
+                return false;
+            }
+            final Counter other = (Counter) obj;
+            return Objects.equals(value, other.getBigInteger());
+        }
+
+        @Override
+        public long get() {
+            return value.longValueExact();
+        }
+
+        @Override
+        public BigInteger getBigInteger() {
+            return value;
+        }
+
+        @Override
+        public Long getLong() {
+            return Long.valueOf(value.longValueExact());
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(value);
+        }
+
+        @Override
+        public void increment() {
+            value = value.add(BigInteger.ONE);
+        }
+
+        @Override
+        public void reset() {
+            value = BigInteger.ZERO;
+        }
+
+        @Override
+        public String toString() {
+            return value.toString();
+        }
+    }
+
+    /**
+     * Counts files, directories, and sizes, as a visit proceeds, using BigInteger numbers.
+     */
+    private final static class BigIntegerPathCounters extends AbstractPathCounters {
+
+        /**
+         * Constructs a new initialized instance.
+         */
+        protected BigIntegerPathCounters() {
+            super(Counters.bigIntegerCounter(), Counters.bigIntegerCounter(), Counters.bigIntegerCounter());
+        }
+
+    }
+
+    /**
+     * Counts using a number.
+     */
+    public interface Counter {
+
+        /**
+         * Adds the given number to this counter.
+         *
+         * @param val the value to add.
+         */
+        void add(long val);
+
+        /**
+         * Gets the counter as a long.
+         *
+         * @return the counter as a long.
+         */
+        long get();
+
+        /**
+         * Gets the counter as a BigInteger.
+         *
+         * @return the counter as a BigInteger.
+         */
+        BigInteger getBigInteger();
+
+        /**
+         * Gets the counter as a Long.
+         *
+         * @return the counter as a Long.
+         */
+        Long getLong();
+
+        /**
+         * Adds one to this counter.
+         */
+        void increment();
+
+        /**
+         * Resets this count to 0.
+         */
+        default void reset() {
+            // binary compat, do nothing
+        }
+
+    }
+
+    /**
+     * Counts using a {@code long} number.
+     */
+    private final static class LongCounter implements Counter {
+
+        private long value;
+
+        @Override
+        public void add(final long add) {
+            value += add;
+
+        }
+
+        @Override
+        public boolean equals(final Object obj) {
+            if (this == obj) {
+                return true;
+            }
+            if (!(obj instanceof Counter)) {
+                return false;
+            }
+            final Counter other = (Counter) obj;
+            return value == other.get();
+        }
+
+        @Override
+        public long get() {
+            return value;
+        }
+
+        @Override
+        public BigInteger getBigInteger() {
+            return BigInteger.valueOf(value);
+        }
+
+        @Override
+        public Long getLong() {
+            return Long.valueOf(value);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(value);
+        }
+
+        @Override
+        public void increment() {
+            value++;
+        }
+
+        @Override
+        public void reset() {
+            value = 0L;
+        }
+
+        @Override
+        public String toString() {
+            return Long.toString(value);
+        }
+    }
+
+    /**
+     * Counts files, directories, and sizes, as a visit proceeds, using long numbers.
+     */
+    private final static class LongPathCounters extends AbstractPathCounters {
+
+        /**
+         * Constructs a new initialized instance.
+         */
+        protected LongPathCounters() {
+            super(Counters.longCounter(), Counters.longCounter(), Counters.longCounter());
+        }
+
+    }
+
+    /**
+     * Counts nothing.
+     */
+    private final static class NoopCounter implements Counter {
+
+        static final NoopCounter INSTANCE = new NoopCounter();
+
+        @Override
+        public void add(final long add) {
+            // noop
+        }
+
+        @Override
+        public long get() {
+            return 0;
+        }
+
+        @Override
+        public BigInteger getBigInteger() {
+            return BigInteger.ZERO;
+        }
+
+        @Override
+        public Long getLong() {
+            return 0L;
+        }
+
+        @Override
+        public void increment() {
+            // noop
+        }
+
+        /**
+         * Returns {@code "0"}, always.
+         *
+         * @return {@code "0"}, always.
+         * @since 2.12.0
+         */
+        @Override
+        public String toString() {
+            return "0";
+        }
+
+    }
+
+    /**
+     * Counts nothing.
+     */
+    private static final class NoopPathCounters extends AbstractPathCounters {
+
+        static final NoopPathCounters INSTANCE = new NoopPathCounters();
+
+        /**
+         * Constructs a new initialized instance.
+         */
+        private NoopPathCounters() {
+            super(Counters.noopCounter(), Counters.noopCounter(), Counters.noopCounter());
+        }
+
+    }
+
+    /**
+     * Counts files, directories, and sizes, as a visit proceeds.
+     */
+    public interface PathCounters {
+
+        /**
+         * Gets the byte counter.
+         *
+         * @return the byte counter.
+         */
+        Counter getByteCounter();
+
+        /**
+         * Gets the directory counter.
+         *
+         * @return the directory counter.
+         */
+        Counter getDirectoryCounter();
+
+        /**
+         * Gets the file counter.
+         *
+         * @return the file counter.
+         */
+        Counter getFileCounter();
+
+        /**
+         * Resets the counts to 0.
+         */
+        default void reset() {
+            // binary compat, do nothing
+        }
+
+    }
+
+    /**
+     * Returns a new BigInteger Counter.
+     *
+     * @return a new BigInteger Counter.
+     */
+    public static Counter bigIntegerCounter() {
+        return new BigIntegerCounter();
+    }
+
+    /**
+     * Returns a new BigInteger PathCounters.
+     *
+     * @return a new BigInteger PathCounters.
+     */
+    public static PathCounters bigIntegerPathCounters() {
+        return new BigIntegerPathCounters();
+    }
+
+    /**
+     * Returns a new long Counter.
+     *
+     * @return a new long Counter.
+     */
+    public static Counter longCounter() {
+        return new LongCounter();
+    }
+
+    /**
+     * Returns a new BigInteger PathCounters.
+     *
+     * @return a new BigInteger PathCounters.
+     */
+    public static PathCounters longPathCounters() {
+        return new LongPathCounters();
+    }
+
+    /**
+     * Returns the no-op Counter.
+     *
+     * @return the no-op Counter.
+     * @since 2.9.0
+     */
+    public static Counter noopCounter() {
+        return NoopCounter.INSTANCE;
+    }
+
+    /**
+     * Returns the no-op PathCounters.
+     *
+     * @return the no-op PathCounters.
+     * @since 2.9.0
+     */
+    public static PathCounters noopPathCounters() {
+        return NoopPathCounters.INSTANCE;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/file/CountingPathVisitor.java b/src/main/java/org/apache/commons/io/file/CountingPathVisitor.java
new file mode 100644
index 0000000..b841019
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/CountingPathVisitor.java
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.io.IOException;
+import java.math.BigInteger;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Objects;
+
+import org.apache.commons.io.file.Counters.PathCounters;
+import org.apache.commons.io.filefilter.IOFileFilter;
+import org.apache.commons.io.filefilter.SymbolicLinkFileFilter;
+import org.apache.commons.io.filefilter.TrueFileFilter;
+import org.apache.commons.io.function.IOBiFunction;
+
+/**
+ * Counts files, directories, and sizes, as a visit proceeds.
+ *
+ * @since 2.7
+ */
+public class CountingPathVisitor extends SimplePathVisitor {
+
+    static final String[] EMPTY_STRING_ARRAY = {};
+
+    static IOFileFilter defaultDirFilter() {
+        return TrueFileFilter.INSTANCE;
+    }
+
+    static IOFileFilter defaultFileFilter() {
+        return new SymbolicLinkFileFilter(FileVisitResult.TERMINATE, FileVisitResult.CONTINUE);
+    }
+
+    /**
+     * Creates a new instance configured with a {@link BigInteger} {@link PathCounters}.
+     *
+     * @return a new instance configured with a {@link BigInteger} {@link PathCounters}.
+     */
+    public static CountingPathVisitor withBigIntegerCounters() {
+        return new CountingPathVisitor(Counters.bigIntegerPathCounters());
+    }
+
+    /**
+     * Creates a new instance configured with a {@code long} {@link PathCounters}.
+     *
+     * @return a new instance configured with a {@code long} {@link PathCounters}.
+     */
+    public static CountingPathVisitor withLongCounters() {
+        return new CountingPathVisitor(Counters.longPathCounters());
+    }
+
+    private final PathCounters pathCounters;
+    private final PathFilter fileFilter;
+    private final PathFilter dirFilter;
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param pathCounter How to count path visits.
+     */
+    public CountingPathVisitor(final PathCounters pathCounter) {
+        this(pathCounter, defaultFileFilter(), defaultDirFilter());
+    }
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param pathCounter How to count path visits.
+     * @param fileFilter Filters which files to count.
+     * @param dirFilter Filters which directories to count.
+     * @since 2.9.0
+     */
+    public CountingPathVisitor(final PathCounters pathCounter, final PathFilter fileFilter, final PathFilter dirFilter) {
+        this.pathCounters = Objects.requireNonNull(pathCounter, "pathCounter");
+        this.fileFilter = Objects.requireNonNull(fileFilter, "fileFilter");
+        this.dirFilter = Objects.requireNonNull(dirFilter, "dirFilter");
+    }
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param pathCounter How to count path visits.
+     * @param fileFilter Filters which files to count.
+     * @param dirFilter Filters which directories to count.
+     * @param visitFileFailed Called on {@link #visitFileFailed(Path, IOException)}.
+     * @since 2.12.0
+     */
+    public CountingPathVisitor(final PathCounters pathCounter, final PathFilter fileFilter, final PathFilter dirFilter,
+        final IOBiFunction<Path, IOException, FileVisitResult> visitFileFailed) {
+        super(visitFileFailed);
+        this.pathCounters = Objects.requireNonNull(pathCounter, "pathCounter");
+        this.fileFilter = Objects.requireNonNull(fileFilter, "fileFilter");
+        this.dirFilter = Objects.requireNonNull(dirFilter, "dirFilter");
+    }
+
+    @Override
+    public boolean equals(final Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!(obj instanceof CountingPathVisitor)) {
+            return false;
+        }
+        final CountingPathVisitor other = (CountingPathVisitor) obj;
+        return Objects.equals(pathCounters, other.pathCounters);
+    }
+
+    /**
+     * Gets the visitation counts.
+     *
+     * @return the visitation counts.
+     */
+    public PathCounters getPathCounters() {
+        return pathCounters;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(pathCounters);
+    }
+
+    @Override
+    public FileVisitResult postVisitDirectory(final Path dir, final IOException exc) throws IOException {
+        updateDirCounter(dir, exc);
+        return FileVisitResult.CONTINUE;
+    }
+
+    @Override
+    public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attributes) throws IOException {
+        final FileVisitResult accept = dirFilter.accept(dir, attributes);
+        return accept != FileVisitResult.CONTINUE ? FileVisitResult.SKIP_SUBTREE : FileVisitResult.CONTINUE;
+    }
+
+    @Override
+    public String toString() {
+        return pathCounters.toString();
+    }
+
+    /**
+     * Updates the counter for visiting the given directory.
+     *
+     * @param dir the visited directory.
+     * @param exc Encountered exception.
+     * @since 2.9.0
+     */
+    protected void updateDirCounter(final Path dir, final IOException exc) {
+        pathCounters.getDirectoryCounter().increment();
+    }
+
+    /**
+     * Updates the counters for visiting the given file.
+     *
+     * @param file the visited file.
+     * @param attributes the visited file attributes.
+     */
+    protected void updateFileCounters(final Path file, final BasicFileAttributes attributes) {
+        pathCounters.getFileCounter().increment();
+        pathCounters.getByteCounter().add(attributes.size());
+    }
+
+    @Override
+    public FileVisitResult visitFile(final Path file, final BasicFileAttributes attributes) throws IOException {
+        // Note: A file can be a symbolic link to a directory.
+        if (Files.exists(file) && fileFilter.accept(file, attributes) == FileVisitResult.CONTINUE) {
+            updateFileCounters(file, attributes);
+        }
+        return FileVisitResult.CONTINUE;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/file/DeleteOption.java b/src/main/java/org/apache/commons/io/file/DeleteOption.java
new file mode 100644
index 0000000..3fe21ad
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/DeleteOption.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+/**
+ * An object that configures how to delete a file.
+ *
+ * <p>
+ * The {@link StandardDeleteOption} enumeration type defines our standard options.
+ * </p>
+ *
+ * @see StandardDeleteOption
+ * @since 2.8.0
+ */
+public interface DeleteOption {
+    // empty
+}
diff --git a/src/main/java/org/apache/commons/io/file/DeletingPathVisitor.java b/src/main/java/org/apache/commons/io/file/DeletingPathVisitor.java
new file mode 100644
index 0000000..0d3b533
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/DeletingPathVisitor.java
@@ -0,0 +1,174 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Arrays;
+import java.util.Objects;
+
+import org.apache.commons.io.file.Counters.PathCounters;
+
+/**
+ * Deletes files and directories as a visit proceeds.
+ *
+ * @since 2.7
+ */
+public class DeletingPathVisitor extends CountingPathVisitor {
+
+    /**
+     * Creates a new instance configured with a BigInteger {@link PathCounters}.
+     *
+     * @return a new instance configured with a BigInteger {@link PathCounters}.
+     */
+    public static DeletingPathVisitor withBigIntegerCounters() {
+        return new DeletingPathVisitor(Counters.bigIntegerPathCounters());
+    }
+
+    /**
+     * Creates a new instance configured with a long {@link PathCounters}.
+     *
+     * @return a new instance configured with a long {@link PathCounters}.
+     */
+    public static DeletingPathVisitor withLongCounters() {
+        return new DeletingPathVisitor(Counters.longPathCounters());
+    }
+
+    private final String[] skip;
+    private final boolean overrideReadOnly;
+    private final LinkOption[] linkOptions;
+
+    /**
+     * Constructs a new visitor that deletes files except for the files and directories explicitly given.
+     *
+     * @param pathCounter How to count visits.
+     * @param deleteOption How deletion is handled.
+     * @param skip The files to skip deleting.
+     * @since 2.8.0
+     */
+    public DeletingPathVisitor(final PathCounters pathCounter, final DeleteOption[] deleteOption, final String... skip) {
+        this(pathCounter, PathUtils.noFollowLinkOptionArray(), deleteOption, skip);
+    }
+
+    /**
+     * Constructs a new visitor that deletes files except for the files and directories explicitly given.
+     *
+     * @param pathCounter How to count visits.
+     * @param linkOptions How symbolic links are handled.
+     * @param deleteOption How deletion is handled.
+     * @param skip The files to skip deleting.
+     * @since 2.9.0
+     */
+    public DeletingPathVisitor(final PathCounters pathCounter, final LinkOption[] linkOptions, final DeleteOption[] deleteOption, final String... skip) {
+        super(pathCounter);
+        final String[] temp = skip != null ? skip.clone() : EMPTY_STRING_ARRAY;
+        Arrays.sort(temp);
+        this.skip = temp;
+        this.overrideReadOnly = StandardDeleteOption.overrideReadOnly(deleteOption);
+        // TODO Files.deleteIfExists() never follows links, so use LinkOption.NOFOLLOW_LINKS in other calls to Files.
+        this.linkOptions = linkOptions == null ? PathUtils.noFollowLinkOptionArray() : linkOptions.clone();
+    }
+
+    /**
+     * Constructs a new visitor that deletes files except for the files and directories explicitly given.
+     *
+     * @param pathCounter How to count visits.
+     *
+     * @param skip The files to skip deleting.
+     */
+    public DeletingPathVisitor(final PathCounters pathCounter, final String... skip) {
+        this(pathCounter, PathUtils.EMPTY_DELETE_OPTION_ARRAY, skip);
+    }
+
+    /**
+     * Returns true to process the given path, false if not.
+     *
+     * @param path the path to test.
+     * @return true to process the given path, false if not.
+     */
+    private boolean accept(final Path path) {
+        return Arrays.binarySearch(skip, Objects.toString(path.getFileName(), null)) < 0;
+    }
+
+    @Override
+    public boolean equals(final Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!super.equals(obj)) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        final DeletingPathVisitor other = (DeletingPathVisitor) obj;
+        return overrideReadOnly == other.overrideReadOnly && Arrays.equals(skip, other.skip);
+    }
+
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = super.hashCode();
+        result = prime * result + Arrays.hashCode(skip);
+        result = prime * result + Objects.hash(overrideReadOnly);
+        return result;
+    }
+
+    @Override
+    public FileVisitResult postVisitDirectory(final Path dir, final IOException exc) throws IOException {
+        if (PathUtils.isEmptyDirectory(dir)) {
+            Files.deleteIfExists(dir);
+        }
+        return super.postVisitDirectory(dir, exc);
+    }
+
+    @Override
+    public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attrs) throws IOException {
+        super.preVisitDirectory(dir, attrs);
+        return accept(dir) ? FileVisitResult.CONTINUE : FileVisitResult.SKIP_SUBTREE;
+    }
+
+    @Override
+    public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException {
+        if (accept(file)) {
+            // delete files and valid links, respecting linkOptions
+            if (Files.exists(file, linkOptions)) {
+                if (overrideReadOnly) {
+                    PathUtils.setReadOnly(file, false, linkOptions);
+                }
+                Files.deleteIfExists(file);
+            }
+            // invalid links will survive previous delete, different approach needed:
+            if (Files.isSymbolicLink(file)) {
+                try {
+                    // deleteIfExists does not work for this case
+                    Files.delete(file);
+                } catch (final NoSuchFileException ignored) {
+                    // ignore
+                }
+            }
+        }
+        updateFileCounters(file, attrs);
+        return FileVisitResult.CONTINUE;
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/commons/io/file/DirectoryStreamFilter.java b/src/main/java/org/apache/commons/io/file/DirectoryStreamFilter.java
new file mode 100644
index 0000000..7ca3d2b
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/DirectoryStreamFilter.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.io.IOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.util.Objects;
+
+/**
+ * A {@link java.nio.file.DirectoryStream.Filter DirectoryStream.Filter} that delegates to a {@link PathFilter}.
+ * <p>
+ * You pass this filter to {@link java.nio.file.Files#newDirectoryStream(Path, DirectoryStream.Filter)
+ * Files#newDirectoryStream(Path, DirectoryStream.Filter)}.
+ * </p>
+ *
+ * @since 2.9.0
+ */
+public class DirectoryStreamFilter implements DirectoryStream.Filter<Path> {
+
+    private final PathFilter pathFilter;
+
+    /**
+     * Constructs a new instance for the given path filter.
+     *
+     * @param pathFilter How to filter paths.
+     */
+    public DirectoryStreamFilter(final PathFilter pathFilter) {
+        // TODO Instead of NPE, we could map null to FalseFileFilter.
+        this.pathFilter = Objects.requireNonNull(pathFilter, "pathFilter");
+    }
+
+    @Override
+    public boolean accept(final Path path) throws IOException {
+        return pathFilter.accept(path, PathUtils.readBasicFileAttributes(path, PathUtils.EMPTY_LINK_OPTION_ARRAY)) == FileVisitResult.CONTINUE;
+    }
+
+    /**
+     * Gets the path filter.
+     *
+     * @return the path filter.
+     */
+    public PathFilter getPathFilter() {
+        return pathFilter;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/file/FilesUncheck.java b/src/main/java/org/apache/commons/io/file/FilesUncheck.java
new file mode 100644
index 0000000..5de2a4b
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/FilesUncheck.java
@@ -0,0 +1,756 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.charset.Charset;
+import java.nio.file.CopyOption;
+import java.nio.file.DirectoryStream;
+import java.nio.file.FileStore;
+import java.nio.file.FileVisitOption;
+import java.nio.file.FileVisitor;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileAttribute;
+import java.nio.file.attribute.FileTime;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.UserPrincipal;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.function.Uncheck;
+
+/**
+ * Delegates to {@link Files} to uncheck calls by throwing {@link UncheckedIOException} instead of {@link IOException}.
+ *
+ * @see Files
+ * @see IOException
+ * @see UncheckedIOException
+ * @since 2.12.0
+ */
+public class FilesUncheck {
+
+    /**
+     * Delegates to {@link Files#copy(InputStream, Path,CopyOption...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param in See delegate.
+     * @param target See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     * @see Files#copy(InputStream, Path,CopyOption...)
+     */
+    public static long copy(final InputStream in, final Path target, final CopyOption... options) {
+        return Uncheck.apply(Files::copy, in, target, options);
+    }
+
+    /**
+     * Delegates to {@link Files#copy(Path, OutputStream)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param source See delegate. See delegate.
+     * @param out See delegate. See delegate.
+     * @return See delegate. See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static long copy(final Path source, final OutputStream out) {
+        return Uncheck.apply(Files::copy, source, out);
+    }
+
+    /**
+     * Delegates to {@link Files#copy(Path, Path, CopyOption...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param source See delegate.
+     * @param target See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static Path copy(final Path source, final Path target, final CopyOption... options) {
+        return Uncheck.apply(Files::copy, source, target, options);
+    }
+
+    /**
+     * Delegates to {@link Files#createDirectories(Path, FileAttribute...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param dir See delegate.
+     * @param attrs See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static Path createDirectories(final Path dir, final FileAttribute<?>... attrs) {
+        return Uncheck.apply(Files::createDirectories, dir, attrs);
+    }
+
+    /**
+     * Delegates to {@link Files#createDirectory(Path, FileAttribute...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param dir See delegate.
+     * @param attrs See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static Path createDirectory(final Path dir, final FileAttribute<?>... attrs) {
+        return Uncheck.apply(Files::createDirectory, dir, attrs);
+    }
+
+    /**
+     * Delegates to {@link Files#createFile(Path, FileAttribute...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param attrs See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static Path createFile(final Path path, final FileAttribute<?>... attrs) {
+        return Uncheck.apply(Files::createFile, path, attrs);
+    }
+
+    /**
+     * Delegates to {@link Files#createLink(Path, Path)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param link See delegate.
+     * @param existing See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static Path createLink(final Path link, final Path existing) {
+        return Uncheck.apply(Files::createLink, link, existing);
+    }
+
+    /**
+     * Delegates to {@link Files#createSymbolicLink(Path, Path, FileAttribute...)} throwing {@link UncheckedIOException}
+     * instead of {@link IOException}.
+     *
+     * @param link See delegate.
+     * @param target See delegate.
+     * @param attrs See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static Path createSymbolicLink(final Path link, final Path target, final FileAttribute<?>... attrs) {
+        return Uncheck.apply(Files::createSymbolicLink, link, target, attrs);
+    }
+
+    /**
+     * Delegates to {@link Files#createTempDirectory(Path, String, FileAttribute...)} throwing {@link UncheckedIOException}
+     * instead of {@link IOException}.
+     *
+     * @param dir See delegate.
+     * @param prefix See delegate.
+     * @param attrs See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static Path createTempDirectory(final Path dir, final String prefix, final FileAttribute<?>... attrs) {
+        return Uncheck.apply(Files::createTempDirectory, dir, prefix, attrs);
+    }
+
+    /**
+     * Delegates to {@link Files#createTempDirectory(String, FileAttribute...)} throwing {@link UncheckedIOException}
+     * instead of {@link IOException}.
+     *
+     * @param prefix See delegate.
+     * @param attrs See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static Path createTempDirectory(final String prefix, final FileAttribute<?>... attrs) {
+        return Uncheck.apply(Files::createTempDirectory, prefix, attrs);
+    }
+
+    /**
+     * Delegates to {@link Files#createTempFile(Path, String, String, FileAttribute...)} throwing
+     * {@link UncheckedIOException} instead of {@link IOException}.
+     *
+     * @param dir See delegate.
+     * @param prefix See delegate.
+     * @param suffix See delegate.
+     * @param attrs See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static Path createTempFile(final Path dir, final String prefix, final String suffix, final FileAttribute<?>... attrs) {
+        return Uncheck.apply(Files::createTempFile, dir, prefix, suffix, attrs);
+    }
+
+    /**
+     * Delegates to {@link Files#createTempFile(String, String, FileAttribute...)} throwing {@link UncheckedIOException}
+     * instead of {@link IOException}.
+     *
+     * @param prefix See delegate.
+     * @param suffix See delegate.
+     * @param attrs See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static Path createTempFile(final String prefix, final String suffix, final FileAttribute<?>... attrs) {
+        return Uncheck.apply(Files::createTempFile, prefix, suffix, attrs);
+    }
+
+    /**
+     * Delegates to {@link Files#delete(Path)} throwing {@link UncheckedIOException} instead of {@link IOException}.
+     *
+     * @param path See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static void delete(final Path path) {
+        Uncheck.accept(Files::delete, path);
+    }
+
+    /**
+     * Delegates to {@link Files#deleteIfExists(Path)} throwing {@link UncheckedIOException} instead of {@link IOException}.
+     *
+     * @param path See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static boolean deleteIfExists(final Path path) {
+        return Uncheck.apply(Files::deleteIfExists, path);
+    }
+
+    /**
+     * Delegates to {@link Files#getAttribute(Path, String, LinkOption...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param attribute See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static Object getAttribute(final Path path, final String attribute, final LinkOption... options) {
+        return Uncheck.apply(Files::getAttribute, path, attribute, options);
+    }
+
+    /**
+     * Delegates to {@link Files#getFileStore(Path)} throwing {@link UncheckedIOException} instead of {@link IOException}.
+     *
+     * @param path See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static FileStore getFileStore(final Path path) {
+        return Uncheck.apply(Files::getFileStore, path);
+    }
+
+    /**
+     * Delegates to {@link Files#getLastModifiedTime(Path, LinkOption...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static FileTime getLastModifiedTime(final Path path, final LinkOption... options) {
+        return Uncheck.apply(Files::getLastModifiedTime, path, options);
+    }
+
+    /**
+     * Delegates to {@link Files#getOwner(Path, LinkOption...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static UserPrincipal getOwner(final Path path, final LinkOption... options) {
+        return Uncheck.apply(Files::getOwner, path, options);
+    }
+
+    /**
+     * Delegates to {@link Files#getPosixFilePermissions(Path, LinkOption...)} throwing {@link UncheckedIOException} instead
+     * of {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static Set<PosixFilePermission> getPosixFilePermissions(final Path path, final LinkOption... options) {
+        return Uncheck.apply(Files::getPosixFilePermissions, path, options);
+    }
+
+    /**
+     * Delegates to {@link Files#isHidden(Path)} throwing {@link UncheckedIOException} instead of {@link IOException}.
+     *
+     * @param path See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static boolean isHidden(final Path path) {
+        return Uncheck.apply(Files::isHidden, path);
+    }
+
+    /**
+     * Delegates to {@link Files#isSameFile(Path, Path)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param path2 See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static boolean isSameFile(final Path path, final Path path2) {
+        return Uncheck.apply(Files::isSameFile, path, path2);
+    }
+
+    /**
+     * Delegates to {@link Files#lines(Path)} throwing {@link UncheckedIOException} instead of {@link IOException}.
+     *
+     * @param path See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static Stream<String> lines(final Path path) {
+        return Uncheck.apply(Files::lines, path);
+    }
+
+    /**
+     * Delegates to {@link Files#lines(Path, Charset)} throwing {@link UncheckedIOException} instead of {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param cs See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static Stream<String> lines(final Path path, final Charset cs) {
+        return Uncheck.apply(Files::lines, path, cs);
+    }
+
+    /**
+     * Delegates to {@link Files#list(Path)} throwing {@link UncheckedIOException} instead of {@link IOException}.
+     *
+     * @param dir See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static Stream<Path> list(final Path dir) {
+        return Uncheck.apply(Files::list, dir);
+    }
+
+    /**
+     * Delegates to {@link Files#move(Path, Path, CopyOption...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param source See delegate.
+     * @param target See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static Path move(final Path source, final Path target, final CopyOption... options) {
+        return Uncheck.apply(Files::move, source, target, options);
+    }
+
+    /**
+     * Delegates to {@link Files#newBufferedReader(Path)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static BufferedReader newBufferedReader(final Path path) {
+        return Uncheck.apply(Files::newBufferedReader, path);
+    }
+
+    /**
+     * Delegates to {@link Files#newBufferedReader(Path, Charset)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param cs See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static BufferedReader newBufferedReader(final Path path, final Charset cs) {
+        return Uncheck.apply(Files::newBufferedReader, path, cs);
+    }
+
+    /**
+     * Delegates to {@link Files#newBufferedWriter(Path, Charset, OpenOption...)} throwing {@link UncheckedIOException}
+     * instead of {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param cs See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static BufferedWriter newBufferedWriter(final Path path, final Charset cs, final OpenOption... options) {
+        return Uncheck.apply(Files::newBufferedWriter, path, cs, options);
+    }
+
+    /**
+     * Delegates to {@link Files#newBufferedWriter(Path, OpenOption...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static BufferedWriter newBufferedWriter(final Path path, final OpenOption... options) {
+        return Uncheck.apply(Files::newBufferedWriter, path, options);
+    }
+
+    /**
+     * Delegates to {@link Files#newByteChannel(Path, OpenOption...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static SeekableByteChannel newByteChannel(final Path path, final OpenOption... options) {
+        return Uncheck.apply(Files::newByteChannel, path, options);
+    }
+
+    /**
+     * Delegates to {@link Files#newByteChannel(Path, Set, FileAttribute...)} throwing {@link UncheckedIOException} instead
+     * of {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param options See delegate.
+     * @param attrs See delegate.
+     * @return See delegate.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     */
+    public static SeekableByteChannel newByteChannel(final Path path, final Set<? extends OpenOption> options, final FileAttribute<?>... attrs) {
+        return Uncheck.apply(Files::newByteChannel, path, options, attrs);
+    }
+
+    /**
+     * Delegates to {@link Files#newDirectoryStream(Path)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param dir See delegate.
+     * @return See delegate.
+     */
+    public static DirectoryStream<Path> newDirectoryStream(final Path dir) {
+        return Uncheck.apply(Files::newDirectoryStream, dir);
+    }
+
+    /**
+     * Delegates to {@link Files#newDirectoryStream(Path, java.nio.file.DirectoryStream.Filter)} throwing
+     * {@link UncheckedIOException} instead of {@link IOException}.
+     *
+     * @param dir See delegate.
+     * @param filter See delegate.
+     * @return See delegate.
+     */
+    public static DirectoryStream<Path> newDirectoryStream(final Path dir, final DirectoryStream.Filter<? super Path> filter) {
+        return Uncheck.apply(Files::newDirectoryStream, dir, filter);
+    }
+
+    /**
+     * Delegates to {@link Files#newDirectoryStream(Path, String)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param dir See delegate.
+     * @param glob See delegate.
+     * @return See delegate.
+     */
+    public static DirectoryStream<Path> newDirectoryStream(final Path dir, final String glob) {
+        return Uncheck.apply(Files::newDirectoryStream, dir, glob);
+    }
+
+    /**
+     * Delegates to {@link Files#newInputStream(Path, OpenOption...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     */
+    public static InputStream newInputStream(final Path path, final OpenOption... options) {
+        return Uncheck.apply(Files::newInputStream, path, options);
+    }
+
+    /**
+     * Delegates to {@link Files#newOutputStream(Path, OpenOption...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     */
+    public static OutputStream newOutputStream(final Path path, final OpenOption... options) {
+        return Uncheck.apply(Files::newOutputStream, path, options);
+    }
+
+    /**
+     * Delegates to {@link Files#probeContentType(Path)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @return See delegate.
+     */
+    public static String probeContentType(final Path path) {
+        return Uncheck.apply(Files::probeContentType, path);
+    }
+
+    /**
+     * Delegates to {@link Files#readAllBytes(Path)} throwing {@link UncheckedIOException} instead of {@link IOException}.
+     *
+     * @param path See delegate.
+     * @return See delegate.
+     */
+    public static byte[] readAllBytes(final Path path) {
+        return Uncheck.apply(Files::readAllBytes, path);
+    }
+
+    /**
+     * Delegates to {@link Files#readAllLines(Path)} throwing {@link UncheckedIOException} instead of {@link IOException}.
+     *
+     * @param path See delegate.
+     * @return See delegate.
+     */
+    public static List<String> readAllLines(final Path path) {
+        return Uncheck.apply(Files::readAllLines, path);
+    }
+
+    /**
+     * Delegates to {@link Files#readAllLines(Path, Charset)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param cs See delegate.
+     * @return See delegate.
+     */
+    public static List<String> readAllLines(final Path path, final Charset cs) {
+        return Uncheck.apply(Files::readAllLines, path, cs);
+    }
+
+    /**
+     * Delegates to {@link Files#readAttributes(Path, Class, LinkOption...)} throwing {@link UncheckedIOException} instead
+     * of {@link IOException}.
+     *
+     * @param <A> See delegate.
+     * @param path See delegate.
+     * @param type See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     */
+    public static <A extends BasicFileAttributes> A readAttributes(final Path path, final Class<A> type, final LinkOption... options) {
+        return Uncheck.apply(Files::readAttributes, path, type, options);
+    }
+
+    /**
+     * Delegates to {@link Files#readAttributes(Path, String, LinkOption...)} throwing {@link UncheckedIOException} instead
+     * of {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param attributes See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     */
+    public static Map<String, Object> readAttributes(final Path path, final String attributes, final LinkOption... options) {
+        return Uncheck.apply(Files::readAttributes, path, attributes, options);
+    }
+
+    /**
+     * Delegates to {@link Files#readSymbolicLink(Path)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param link See delegate.
+     * @return See delegate.
+     */
+    public static Path readSymbolicLink(final Path link) {
+        return Uncheck.apply(Files::readSymbolicLink, link);
+    }
+
+    /**
+     * Delegates to {@link Files#setAttribute(Path, String, Object, LinkOption...)} throwing {@link UncheckedIOException}
+     * instead of {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param attribute See delegate.
+     * @param value See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     */
+    public static Path setAttribute(final Path path, final String attribute, final Object value, final LinkOption... options) {
+        return Uncheck.apply(Files::setAttribute, path, attribute, value, options);
+    }
+
+    /**
+     * Delegates to {@link Files#setLastModifiedTime(Path, FileTime)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param time See delegate.
+     * @return See delegate.
+     */
+    public static Path setLastModifiedTime(final Path path, final FileTime time) {
+        return Uncheck.apply(Files::setLastModifiedTime, path, time);
+    }
+
+    /**
+     * Delegates to {@link Files#setOwner(Path, UserPrincipal)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param owner See delegate.
+     * @return See delegate.
+     */
+    public static Path setOwner(final Path path, final UserPrincipal owner) {
+        return Uncheck.apply(Files::setOwner, path, owner);
+    }
+
+    /**
+     * Delegates to {@link Files#setPosixFilePermissions(Path, Set)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param perms See delegate.
+     * @return See delegate.
+     */
+    public static Path setPosixFilePermissions(final Path path, final Set<PosixFilePermission> perms) {
+        return Uncheck.apply(Files::setPosixFilePermissions, path, perms);
+    }
+
+    /**
+     * Delegates to {@link Files#size(Path)} throwing {@link UncheckedIOException} instead of {@link IOException}.
+     *
+     * @param path See delegate.
+     * @return See delegate.
+     */
+    public static long size(final Path path) {
+        return Uncheck.apply(Files::size, path);
+    }
+
+    /**
+     * Delegates to {@link Files#walk(Path, FileVisitOption...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param start See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     */
+    public static Stream<Path> walk(final Path start, final FileVisitOption... options) {
+        return Uncheck.apply(Files::walk, start, options);
+    }
+
+    /**
+     * Delegates to {@link Files#walk(Path, int, FileVisitOption...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param start See delegate.
+     * @param maxDepth See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     */
+    public static Stream<Path> walk(final Path start, final int maxDepth, final FileVisitOption... options) {
+        return Uncheck.apply(Files::walk, start, maxDepth, options);
+    }
+
+    /**
+     * Delegates to {@link Files#walkFileTree(Path, FileVisitor)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param start See delegate.
+     * @param visitor See delegate.
+     * @return See delegate.
+     */
+    public static Path walkFileTree(final Path start, final FileVisitor<? super Path> visitor) {
+        return Uncheck.apply(Files::walkFileTree, start, visitor);
+    }
+
+    /**
+     * Delegates to {@link Files#walkFileTree(Path, Set, int, FileVisitor)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param start See delegate.
+     * @param options See delegate.
+     * @param maxDepth See delegate.
+     * @param visitor See delegate.
+     * @return See delegate.
+     */
+    public static Path walkFileTree(final Path start, final Set<FileVisitOption> options, final int maxDepth, final FileVisitor<? super Path> visitor) {
+        return Uncheck.apply(Files::walkFileTree, start, options, maxDepth, visitor);
+    }
+
+    /**
+     * Delegates to {@link Files#write(Path, byte[], OpenOption...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param bytes See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     */
+    public static Path write(final Path path, final byte[] bytes, final OpenOption... options) {
+        return Uncheck.apply(Files::write, path, bytes, options);
+    }
+
+    /**
+     * Delegates to {@link Files#write(Path, Iterable, Charset, OpenOption...)} throwing {@link UncheckedIOException}
+     * instead of {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param lines See delegate.
+     * @param cs See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     */
+    public static Path write(final Path path, final Iterable<? extends CharSequence> lines, final Charset cs, final OpenOption... options) {
+        return Uncheck.apply(Files::write, path, lines, cs, options);
+    }
+
+    /**
+     * Delegates to {@link Files#write(Path, Iterable, OpenOption...)} throwing {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @param path See delegate.
+     * @param lines See delegate.
+     * @param options See delegate.
+     * @return See delegate.
+     */
+    public static Path write(final Path path, final Iterable<? extends CharSequence> lines, final OpenOption... options) {
+        return Uncheck.apply(Files::write, path, lines, options);
+    }
+
+    /**
+     * No instances.
+     */
+    private FilesUncheck() {
+        // No instances
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/file/NoopPathVisitor.java b/src/main/java/org/apache/commons/io/file/NoopPathVisitor.java
new file mode 100644
index 0000000..20393a4
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/NoopPathVisitor.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+
+import org.apache.commons.io.function.IOBiFunction;
+
+/**
+ * A noop path visitor.
+ *
+ * @since 2.9.0
+ */
+public class NoopPathVisitor extends SimplePathVisitor {
+
+    /**
+     * The singleton instance.
+     */
+    public static final NoopPathVisitor INSTANCE = new NoopPathVisitor();
+
+    /**
+     * Constructs a new instance.
+     */
+    public NoopPathVisitor() {
+    }
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param visitFileFailed Called on {@link #visitFileFailed(Path, IOException)}.
+     * @since 2.12.0
+     */
+    public NoopPathVisitor(final IOBiFunction<Path, IOException, FileVisitResult> visitFileFailed) {
+        super(visitFileFailed);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/file/PathFilter.java b/src/main/java/org/apache/commons/io/file/PathFilter.java
new file mode 100644
index 0000000..837ddc0
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/PathFilter.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+
+/**
+ * A filter for {@link Path}s.
+ *
+ * @since 2.9.0
+ */
+@FunctionalInterface
+public interface PathFilter {
+
+    /**
+     * Tests whether or not to include the specified Path in a result.
+     *
+     * @param path The Path to test.
+     * @param attributes the file's basic attributes (TODO may be null).
+     * @return a FileVisitResult
+     */
+    FileVisitResult accept(Path path, BasicFileAttributes attributes);
+}
diff --git a/src/main/java/org/apache/commons/io/file/PathUtils.java b/src/main/java/org/apache/commons/io/file/PathUtils.java
new file mode 100644
index 0000000..fd6cccb
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/PathUtils.java
@@ -0,0 +1,1778 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.math.BigInteger;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.charset.Charset;
+import java.nio.file.AccessDeniedException;
+import java.nio.file.CopyOption;
+import java.nio.file.DirectoryStream;
+import java.nio.file.FileVisitOption;
+import java.nio.file.FileVisitResult;
+import java.nio.file.FileVisitor;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.NotDirectoryException;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.AclEntry;
+import java.nio.file.attribute.AclFileAttributeView;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.DosFileAttributeView;
+import java.nio.file.attribute.DosFileAttributes;
+import java.nio.file.attribute.FileAttribute;
+import java.nio.file.attribute.FileTime;
+import java.nio.file.attribute.PosixFileAttributeView;
+import java.nio.file.attribute.PosixFileAttributes;
+import java.nio.file.attribute.PosixFilePermission;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.chrono.ChronoZonedDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collector;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.Charsets;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.ThreadUtils;
+import org.apache.commons.io.file.Counters.PathCounters;
+import org.apache.commons.io.file.attribute.FileTimes;
+import org.apache.commons.io.filefilter.IOFileFilter;
+import org.apache.commons.io.function.IOFunction;
+import org.apache.commons.io.function.Uncheck;
+
+/**
+ * NIO Path utilities.
+ *
+ * @since 2.7
+ */
+public final class PathUtils {
+
+    /**
+     * Private worker/holder that computes and tracks relative path names and their equality. We reuse the sorted relative
+     * lists when comparing directories.
+     */
+    private static class RelativeSortedPaths {
+
+        final boolean equals;
+        // final List<Path> relativeDirList1; // might need later?
+        // final List<Path> relativeDirList2; // might need later?
+        final List<Path> relativeFileList1;
+        final List<Path> relativeFileList2;
+
+        /**
+         * Constructs and initializes a new instance by accumulating directory and file info.
+         *
+         * @param dir1 First directory to compare.
+         * @param dir2 Seconds directory to compare.
+         * @param maxDepth See {@link Files#walkFileTree(Path,Set,int,FileVisitor)}.
+         * @param linkOptions Options indicating how symbolic links are handled.
+         * @param fileVisitOptions See {@link Files#walkFileTree(Path,Set,int,FileVisitor)}.
+         * @throws IOException if an I/O error is thrown by a visitor method.
+         */
+        private RelativeSortedPaths(final Path dir1, final Path dir2, final int maxDepth, final LinkOption[] linkOptions,
+            final FileVisitOption[] fileVisitOptions) throws IOException {
+            final List<Path> tmpRelativeDirList1;
+            final List<Path> tmpRelativeDirList2;
+            List<Path> tmpRelativeFileList1 = null;
+            List<Path> tmpRelativeFileList2 = null;
+            if (dir1 == null && dir2 == null) {
+                equals = true;
+            } else if (dir1 == null ^ dir2 == null) {
+                equals = false;
+            } else {
+                final boolean parentDirNotExists1 = Files.notExists(dir1, linkOptions);
+                final boolean parentDirNotExists2 = Files.notExists(dir2, linkOptions);
+                if (parentDirNotExists1 || parentDirNotExists2) {
+                    equals = parentDirNotExists1 && parentDirNotExists2;
+                } else {
+                    final AccumulatorPathVisitor visitor1 = accumulate(dir1, maxDepth, fileVisitOptions);
+                    final AccumulatorPathVisitor visitor2 = accumulate(dir2, maxDepth, fileVisitOptions);
+                    if (visitor1.getDirList().size() != visitor2.getDirList().size() || visitor1.getFileList().size() != visitor2.getFileList().size()) {
+                        equals = false;
+                    } else {
+                        tmpRelativeDirList1 = visitor1.relativizeDirectories(dir1, true, null);
+                        tmpRelativeDirList2 = visitor2.relativizeDirectories(dir2, true, null);
+                        if (!tmpRelativeDirList1.equals(tmpRelativeDirList2)) {
+                            equals = false;
+                        } else {
+                            tmpRelativeFileList1 = visitor1.relativizeFiles(dir1, true, null);
+                            tmpRelativeFileList2 = visitor2.relativizeFiles(dir2, true, null);
+                            equals = tmpRelativeFileList1.equals(tmpRelativeFileList2);
+                        }
+                    }
+                }
+            }
+            // relativeDirList1 = tmpRelativeDirList1;
+            // relativeDirList2 = tmpRelativeDirList2;
+            relativeFileList1 = tmpRelativeFileList1;
+            relativeFileList2 = tmpRelativeFileList2;
+        }
+    }
+
+    private static final OpenOption[] OPEN_OPTIONS_TRUNCATE = {StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING};
+
+    private static final OpenOption[] OPEN_OPTIONS_APPEND = {StandardOpenOption.CREATE, StandardOpenOption.APPEND};
+
+    /**
+     * Empty {@link CopyOption} array.
+     *
+     * @since 2.8.0
+     */
+    public static final CopyOption[] EMPTY_COPY_OPTIONS = {};
+
+    /**
+     * Empty {@link DeleteOption} array.
+     *
+     * @since 2.8.0
+     */
+    public static final DeleteOption[] EMPTY_DELETE_OPTION_ARRAY = {};
+
+    /**
+     * Empty {@link FileVisitOption} array.
+     */
+    public static final FileVisitOption[] EMPTY_FILE_VISIT_OPTION_ARRAY = {};
+
+    /**
+     * Empty {@link LinkOption} array.
+     */
+    public static final LinkOption[] EMPTY_LINK_OPTION_ARRAY = {};
+
+    /**
+     * {@link LinkOption} array for {@link LinkOption#NOFOLLOW_LINKS}.
+     *
+     * @since 2.9.0
+     * @deprecated Use {@link #noFollowLinkOptionArray()}.
+     */
+    @Deprecated
+    public static final LinkOption[] NOFOLLOW_LINK_OPTION_ARRAY = {LinkOption.NOFOLLOW_LINKS};
+
+    /**
+     * A LinkOption used to follow link in this class, the inverse of {@link LinkOption#NOFOLLOW_LINKS}.
+     *
+     * @since 2.12.0
+     */
+    static final LinkOption NULL_LINK_OPTION = null;
+
+    /**
+     * Empty {@link OpenOption} array.
+     */
+    public static final OpenOption[] EMPTY_OPEN_OPTION_ARRAY = {};
+
+    /**
+     * Empty {@link Path} array.
+     *
+     * @since 2.9.0
+     */
+    public static final Path[] EMPTY_PATH_ARRAY = {};
+
+    /**
+     * Accumulates file tree information in a {@link AccumulatorPathVisitor}.
+     *
+     * @param directory The directory to accumulate information.
+     * @param maxDepth See {@link Files#walkFileTree(Path,Set,int,FileVisitor)}.
+     * @param fileVisitOptions See {@link Files#walkFileTree(Path,Set,int,FileVisitor)}.
+     * @throws IOException if an I/O error is thrown by a visitor method.
+     * @return file tree information.
+     */
+    private static AccumulatorPathVisitor accumulate(final Path directory, final int maxDepth, final FileVisitOption[] fileVisitOptions) throws IOException {
+        return visitFileTree(AccumulatorPathVisitor.withLongCounters(), directory, toFileVisitOptionSet(fileVisitOptions), maxDepth);
+    }
+
+    /**
+     * Cleans a directory including subdirectories without deleting directories.
+     *
+     * @param directory directory to clean.
+     * @return The visitation path counters.
+     * @throws IOException if an I/O error is thrown by a visitor method.
+     */
+    public static PathCounters cleanDirectory(final Path directory) throws IOException {
+        return cleanDirectory(directory, EMPTY_DELETE_OPTION_ARRAY);
+    }
+
+    /**
+     * Cleans a directory including subdirectories without deleting directories.
+     *
+     * @param directory directory to clean.
+     * @param deleteOptions How to handle deletion.
+     * @return The visitation path counters.
+     * @throws IOException if an I/O error is thrown by a visitor method.
+     * @since 2.8.0
+     */
+    public static PathCounters cleanDirectory(final Path directory, final DeleteOption... deleteOptions) throws IOException {
+        return visitFileTree(new CleaningPathVisitor(Counters.longPathCounters(), deleteOptions), directory).getPathCounters();
+    }
+
+    /**
+     * Compares the given {@link Path}'s last modified time to the given file time.
+     *
+     * @param file the {@link Path} to test.
+     * @param fileTime the time reference.
+     * @param options options indicating how to handle symbolic links.
+     * @return See {@link FileTime#compareTo(FileTime)}
+     * @throws IOException if an I/O error occurs.
+     * @throws NullPointerException if the file is {@code null}.
+     */
+    private static int compareLastModifiedTimeTo(final Path file, final FileTime fileTime, final LinkOption... options) throws IOException {
+        return getLastModifiedTime(file, options).compareTo(fileTime);
+    }
+
+    /**
+     * Copies a directory to another directory.
+     *
+     * @param sourceDirectory The source directory.
+     * @param targetDirectory The target directory.
+     * @param copyOptions Specifies how the copying should be done.
+     * @return The visitation path counters.
+     * @throws IOException if an I/O error is thrown by a visitor method.
+     */
+    public static PathCounters copyDirectory(final Path sourceDirectory, final Path targetDirectory, final CopyOption... copyOptions) throws IOException {
+        final Path absoluteSource = sourceDirectory.toAbsolutePath();
+        return visitFileTree(new CopyDirectoryVisitor(Counters.longPathCounters(), absoluteSource, targetDirectory, copyOptions), absoluteSource)
+            .getPathCounters();
+    }
+
+    /**
+     * Copies a URL to a directory.
+     *
+     * @param sourceFile The source URL.
+     * @param targetFile The target file.
+     * @param copyOptions Specifies how the copying should be done.
+     * @return The target file
+     * @throws IOException if an I/O error occurs.
+     * @see Files#copy(InputStream, Path, CopyOption...)
+     */
+    public static Path copyFile(final URL sourceFile, final Path targetFile, final CopyOption... copyOptions) throws IOException {
+        try (InputStream inputStream = sourceFile.openStream()) {
+            Files.copy(inputStream, targetFile, copyOptions);
+            return targetFile;
+        }
+    }
+
+    /**
+     * Copies a file to a directory.
+     *
+     * @param sourceFile The source file.
+     * @param targetDirectory The target directory.
+     * @param copyOptions Specifies how the copying should be done.
+     * @return The target file
+     * @throws IOException if an I/O error occurs.
+     * @see Files#copy(Path, Path, CopyOption...)
+     */
+    public static Path copyFileToDirectory(final Path sourceFile, final Path targetDirectory, final CopyOption... copyOptions) throws IOException {
+        return Files.copy(sourceFile, targetDirectory.resolve(sourceFile.getFileName()), copyOptions);
+    }
+
+    /**
+     * Copies a URL to a directory.
+     *
+     * @param sourceFile The source URL.
+     * @param targetDirectory The target directory.
+     * @param copyOptions Specifies how the copying should be done.
+     * @return The target file
+     * @throws IOException if an I/O error occurs.
+     * @see Files#copy(InputStream, Path, CopyOption...)
+     */
+    public static Path copyFileToDirectory(final URL sourceFile, final Path targetDirectory, final CopyOption... copyOptions) throws IOException {
+        try (InputStream inputStream = sourceFile.openStream()) {
+            final Path resolve = targetDirectory.resolve(FilenameUtils.getName(sourceFile.getFile()));
+            Files.copy(inputStream, resolve, copyOptions);
+            return resolve;
+        }
+    }
+
+    /**
+     * Counts aspects of a directory including subdirectories.
+     *
+     * @param directory directory to delete.
+     * @return The visitor used to count the given directory.
+     * @throws IOException if an I/O error is thrown by a visitor method.
+     */
+    public static PathCounters countDirectory(final Path directory) throws IOException {
+        return visitFileTree(CountingPathVisitor.withLongCounters(), directory).getPathCounters();
+    }
+
+    /**
+     * Counts aspects of a directory including subdirectories.
+     *
+     * @param directory directory to count.
+     * @return The visitor used to count the given directory.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.12.0
+     */
+    public static PathCounters countDirectoryAsBigInteger(final Path directory) throws IOException {
+        return visitFileTree(CountingPathVisitor.withBigIntegerCounters(), directory).getPathCounters();
+    }
+
+    /**
+     * Creates the parent directories for the given {@code path}.
+     *
+     * @param path The path to a file (or directory).
+     * @param attrs An optional list of file attributes to set atomically when creating the directories.
+     * @return The Path for the {@code path}'s parent directory or null if the given path has no parent.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.9.0
+     */
+    public static Path createParentDirectories(final Path path, final FileAttribute<?>... attrs) throws IOException {
+        return createParentDirectories(path, LinkOption.NOFOLLOW_LINKS, attrs);
+    }
+
+    /**
+     * Creates the parent directories for the given {@code path}.
+     *
+     * @param path The path to a file (or directory).
+     * @param linkOption A {@link LinkOption} or null.
+     * @param attrs An optional list of file attributes to set atomically when creating the directories.
+     * @return The Path for the {@code path}'s parent directory or null if the given path has no parent.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.12.0
+     */
+    public static Path createParentDirectories(final Path path, final LinkOption linkOption, final FileAttribute<?>... attrs) throws IOException {
+        Path parent = getParent(path);
+        parent = linkOption == LinkOption.NOFOLLOW_LINKS ? parent : readIfSymbolicLink(parent);
+        return parent == null ? null : Files.createDirectories(parent, attrs);
+    }
+
+    /**
+     * Gets the current directory.
+     *
+     * @return the current directory.
+     *
+     * @since 2.9.0
+     */
+    public static Path current() {
+        return Paths.get(".");
+    }
+
+    /**
+     * Deletes a file or directory. If the path is a directory, delete it and all subdirectories.
+     * <p>
+     * The difference between File.delete() and this method are:
+     * </p>
+     * <ul>
+     * <li>A directory to delete does not have to be empty.</li>
+     * <li>You get exceptions when a file or directory cannot be deleted; {@link java.io.File#delete()} returns a boolean.
+     * </ul>
+     *
+     * @param path file or directory to delete, must not be {@code null}
+     * @return The visitor used to delete the given directory.
+     * @throws NullPointerException if the directory is {@code null}
+     * @throws IOException if an I/O error is thrown by a visitor method or if an I/O error occurs.
+     */
+    public static PathCounters delete(final Path path) throws IOException {
+        return delete(path, EMPTY_DELETE_OPTION_ARRAY);
+    }
+
+    /**
+     * Deletes a file or directory. If the path is a directory, delete it and all subdirectories.
+     * <p>
+     * The difference between File.delete() and this method are:
+     * </p>
+     * <ul>
+     * <li>A directory to delete does not have to be empty.</li>
+     * <li>You get exceptions when a file or directory cannot be deleted; {@link java.io.File#delete()} returns a boolean.
+     * </ul>
+     *
+     * @param path file or directory to delete, must not be {@code null}
+     * @param deleteOptions How to handle deletion.
+     * @return The visitor used to delete the given directory.
+     * @throws NullPointerException if the directory is {@code null}
+     * @throws IOException if an I/O error is thrown by a visitor method or if an I/O error occurs.
+     * @since 2.8.0
+     */
+    public static PathCounters delete(final Path path, final DeleteOption... deleteOptions) throws IOException {
+        // File deletion through Files deletes links, not targets, so use LinkOption.NOFOLLOW_LINKS.
+        return Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) ? deleteDirectory(path, deleteOptions) : deleteFile(path, deleteOptions);
+    }
+
+    /**
+     * Deletes a file or directory. If the path is a directory, delete it and all subdirectories.
+     * <p>
+     * The difference between File.delete() and this method are:
+     * </p>
+     * <ul>
+     * <li>A directory to delete does not have to be empty.</li>
+     * <li>You get exceptions when a file or directory cannot be deleted; {@link java.io.File#delete()} returns a boolean.
+     * </ul>
+     *
+     * @param path file or directory to delete, must not be {@code null}
+     * @param linkOptions How to handle symbolic links.
+     * @param deleteOptions How to handle deletion.
+     * @return The visitor used to delete the given directory.
+     * @throws NullPointerException if the directory is {@code null}
+     * @throws IOException if an I/O error is thrown by a visitor method or if an I/O error occurs.
+     * @since 2.9.0
+     */
+    public static PathCounters delete(final Path path, final LinkOption[] linkOptions, final DeleteOption... deleteOptions) throws IOException {
+        // File deletion through Files deletes links, not targets, so use LinkOption.NOFOLLOW_LINKS.
+        return Files.isDirectory(path, linkOptions) ? deleteDirectory(path, linkOptions, deleteOptions) : deleteFile(path, linkOptions, deleteOptions);
+    }
+
+    /**
+     * Deletes a directory including subdirectories.
+     *
+     * @param directory directory to delete.
+     * @return The visitor used to delete the given directory.
+     * @throws IOException if an I/O error is thrown by a visitor method.
+     */
+    public static PathCounters deleteDirectory(final Path directory) throws IOException {
+        return deleteDirectory(directory, EMPTY_DELETE_OPTION_ARRAY);
+    }
+
+    /**
+     * Deletes a directory including subdirectories.
+     *
+     * @param directory directory to delete.
+     * @param deleteOptions How to handle deletion.
+     * @return The visitor used to delete the given directory.
+     * @throws IOException if an I/O error is thrown by a visitor method.
+     * @since 2.8.0
+     */
+    public static PathCounters deleteDirectory(final Path directory, final DeleteOption... deleteOptions) throws IOException {
+        final LinkOption[] linkOptions = PathUtils.noFollowLinkOptionArray();
+        // POSIX ops will noop on non-POSIX.
+        return withPosixFileAttributes(getParent(directory), linkOptions, overrideReadOnly(deleteOptions),
+            pfa -> visitFileTree(new DeletingPathVisitor(Counters.longPathCounters(), linkOptions, deleteOptions), directory).getPathCounters());
+    }
+
+    /**
+     * Deletes a directory including subdirectories.
+     *
+     * @param directory directory to delete.
+     * @param linkOptions How to handle symbolic links.
+     * @param deleteOptions How to handle deletion.
+     * @return The visitor used to delete the given directory.
+     * @throws IOException if an I/O error is thrown by a visitor method.
+     * @since 2.9.0
+     */
+    public static PathCounters deleteDirectory(final Path directory, final LinkOption[] linkOptions, final DeleteOption... deleteOptions) throws IOException {
+        return visitFileTree(new DeletingPathVisitor(Counters.longPathCounters(), linkOptions, deleteOptions), directory).getPathCounters();
+    }
+
+    /**
+     * Deletes the given file.
+     *
+     * @param file The file to delete.
+     * @return A visitor with path counts set to 1 file, 0 directories, and the size of the deleted file.
+     * @throws IOException if an I/O error occurs.
+     * @throws NoSuchFileException if the file is a directory.
+     */
+    public static PathCounters deleteFile(final Path file) throws IOException {
+        return deleteFile(file, EMPTY_DELETE_OPTION_ARRAY);
+    }
+
+    /**
+     * Deletes the given file.
+     *
+     * @param file The file to delete.
+     * @param deleteOptions How to handle deletion.
+     * @return A visitor with path counts set to 1 file, 0 directories, and the size of the deleted file.
+     * @throws IOException if an I/O error occurs.
+     * @throws NoSuchFileException if the file is a directory.
+     * @since 2.8.0
+     */
+    public static PathCounters deleteFile(final Path file, final DeleteOption... deleteOptions) throws IOException {
+        // Files.deleteIfExists() never follows links, so use LinkOption.NOFOLLOW_LINKS in other calls to Files.
+        return deleteFile(file, noFollowLinkOptionArray(), deleteOptions);
+    }
+
+    /**
+     * Deletes the given file.
+     *
+     * @param file The file to delete.
+     * @param linkOptions How to handle symbolic links.
+     * @param deleteOptions How to handle deletion.
+     * @return A visitor with path counts set to 1 file, 0 directories, and the size of the deleted file.
+     * @throws IOException if an I/O error occurs.
+     * @throws NoSuchFileException if the file is a directory.
+     * @since 2.9.0
+     */
+    public static PathCounters deleteFile(final Path file, final LinkOption[] linkOptions, final DeleteOption... deleteOptions)
+        throws NoSuchFileException, IOException {
+        //
+        // TODO Needs clean up
+        //
+        if (Files.isDirectory(file, linkOptions)) {
+            throw new NoSuchFileException(file.toString());
+        }
+        final PathCounters pathCounts = Counters.longPathCounters();
+        boolean exists = exists(file, linkOptions);
+        long size = exists && !Files.isSymbolicLink(file) ? Files.size(file) : 0;
+        try {
+            if (Files.deleteIfExists(file)) {
+                pathCounts.getFileCounter().increment();
+                pathCounts.getByteCounter().add(size);
+                return pathCounts;
+            }
+        } catch (final AccessDeniedException ignored) {
+            // Ignore and try again below.
+        }
+        final Path parent = getParent(file);
+        PosixFileAttributes posixFileAttributes = null;
+        try {
+            if (overrideReadOnly(deleteOptions)) {
+                posixFileAttributes = readPosixFileAttributes(parent, linkOptions);
+                setReadOnly(file, false, linkOptions);
+            }
+            // Read size _after_ having read/execute access on POSIX.
+            exists = exists(file, linkOptions);
+            size = exists && !Files.isSymbolicLink(file) ? Files.size(file) : 0;
+            if (Files.deleteIfExists(file)) {
+                pathCounts.getFileCounter().increment();
+                pathCounts.getByteCounter().add(size);
+            }
+        } finally {
+            if (posixFileAttributes != null) {
+                Files.setPosixFilePermissions(parent, posixFileAttributes.permissions());
+            }
+        }
+        return pathCounts;
+    }
+
+    /**
+     * Delegates to {@link File#deleteOnExit()}.
+     *
+     * @param path the path to delete.
+     * @since 3.13.0
+     */
+    public static void deleteOnExit(Path path) {
+        Objects.requireNonNull(path.toFile()).deleteOnExit();
+    }
+
+    /**
+     * Compares the file sets of two Paths to determine if they are equal or not while considering file contents. The
+     * comparison includes all files in all subdirectories.
+     *
+     * @param path1 The first directory.
+     * @param path2 The second directory.
+     * @return Whether the two directories contain the same files while considering file contents.
+     * @throws IOException if an I/O error is thrown by a visitor method.
+     */
+    public static boolean directoryAndFileContentEquals(final Path path1, final Path path2) throws IOException {
+        return directoryAndFileContentEquals(path1, path2, EMPTY_LINK_OPTION_ARRAY, EMPTY_OPEN_OPTION_ARRAY, EMPTY_FILE_VISIT_OPTION_ARRAY);
+    }
+
+    /**
+     * Compares the file sets of two Paths to determine if they are equal or not while considering file contents. The
+     * comparison includes all files in all subdirectories.
+     *
+     * @param path1 The first directory.
+     * @param path2 The second directory.
+     * @param linkOptions options to follow links.
+     * @param openOptions options to open files.
+     * @param fileVisitOption options to configure traversal.
+     * @return Whether the two directories contain the same files while considering file contents.
+     * @throws IOException if an I/O error is thrown by a visitor method.
+     */
+    public static boolean directoryAndFileContentEquals(final Path path1, final Path path2, final LinkOption[] linkOptions, final OpenOption[] openOptions,
+        final FileVisitOption[] fileVisitOption) throws IOException {
+        // First walk both file trees and gather normalized paths.
+        if (path1 == null && path2 == null) {
+            return true;
+        }
+        if (path1 == null || path2 == null) {
+            return false;
+        }
+        if (notExists(path1) && notExists(path2)) {
+            return true;
+        }
+        final RelativeSortedPaths relativeSortedPaths = new RelativeSortedPaths(path1, path2, Integer.MAX_VALUE, linkOptions, fileVisitOption);
+        // If the normalized path names and counts are not the same, no need to compare contents.
+        if (!relativeSortedPaths.equals) {
+            return false;
+        }
+        // Both visitors contain the same normalized paths, we can compare file contents.
+        final List<Path> fileList1 = relativeSortedPaths.relativeFileList1;
+        final List<Path> fileList2 = relativeSortedPaths.relativeFileList2;
+        for (final Path path : fileList1) {
+            final int binarySearch = Collections.binarySearch(fileList2, path);
+            if (binarySearch <= -1) {
+                throw new IllegalStateException("Unexpected mismatch.");
+            }
+            if (!fileContentEquals(path1.resolve(path), path2.resolve(path), linkOptions, openOptions)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Compares the file sets of two Paths to determine if they are equal or not without considering file contents. The
+     * comparison includes all files in all subdirectories.
+     *
+     * @param path1 The first directory.
+     * @param path2 The second directory.
+     * @return Whether the two directories contain the same files without considering file contents.
+     * @throws IOException if an I/O error is thrown by a visitor method.
+     */
+    public static boolean directoryContentEquals(final Path path1, final Path path2) throws IOException {
+        return directoryContentEquals(path1, path2, Integer.MAX_VALUE, EMPTY_LINK_OPTION_ARRAY, EMPTY_FILE_VISIT_OPTION_ARRAY);
+    }
+
+    /**
+     * Compares the file sets of two Paths to determine if they are equal or not without considering file contents. The
+     * comparison includes all files in all subdirectories.
+     *
+     * @param path1 The first directory.
+     * @param path2 The second directory.
+     * @param maxDepth See {@link Files#walkFileTree(Path,Set,int,FileVisitor)}.
+     * @param linkOptions options to follow links.
+     * @param fileVisitOptions options to configure the traversal
+     * @return Whether the two directories contain the same files without considering file contents.
+     * @throws IOException if an I/O error is thrown by a visitor method.
+     */
+    public static boolean directoryContentEquals(final Path path1, final Path path2, final int maxDepth, final LinkOption[] linkOptions,
+        final FileVisitOption[] fileVisitOptions) throws IOException {
+        return new RelativeSortedPaths(path1, path2, maxDepth, linkOptions, fileVisitOptions).equals;
+    }
+
+    private static boolean exists(final Path path, final LinkOption... options) {
+        Objects.requireNonNull(path, "path");
+        return options != null ? Files.exists(path, options) : Files.exists(path);
+    }
+
+    /**
+     * Compares the file contents of two Paths to determine if they are equal or not.
+     * <p>
+     * File content is accessed through {@link Files#newInputStream(Path,OpenOption...)}.
+     * </p>
+     *
+     * @param path1 the first stream.
+     * @param path2 the second stream.
+     * @return true if the content of the streams are equal or they both don't exist, false otherwise.
+     * @throws NullPointerException if either input is null.
+     * @throws IOException if an I/O error occurs.
+     * @see org.apache.commons.io.FileUtils#contentEquals(java.io.File, java.io.File)
+     */
+    public static boolean fileContentEquals(final Path path1, final Path path2) throws IOException {
+        return fileContentEquals(path1, path2, EMPTY_LINK_OPTION_ARRAY, EMPTY_OPEN_OPTION_ARRAY);
+    }
+
+    /**
+     * Compares the file contents of two Paths to determine if they are equal or not.
+     * <p>
+     * File content is accessed through {@link Files#newInputStream(Path,OpenOption...)}.
+     * </p>
+     *
+     * @param path1 the first stream.
+     * @param path2 the second stream.
+     * @param linkOptions options specifying how files are followed.
+     * @param openOptions options specifying how files are opened.
+     * @return true if the content of the streams are equal or they both don't exist, false otherwise.
+     * @throws NullPointerException if either input is null.
+     * @throws IOException if an I/O error occurs.
+     * @see org.apache.commons.io.FileUtils#contentEquals(java.io.File, java.io.File)
+     */
+    public static boolean fileContentEquals(final Path path1, final Path path2, final LinkOption[] linkOptions, final OpenOption[] openOptions)
+        throws IOException {
+        if (path1 == null && path2 == null) {
+            return true;
+        }
+        if (path1 == null || path2 == null) {
+            return false;
+        }
+        final Path nPath1 = path1.normalize();
+        final Path nPath2 = path2.normalize();
+        final boolean path1Exists = exists(nPath1, linkOptions);
+        if (path1Exists != exists(nPath2, linkOptions)) {
+            return false;
+        }
+        if (!path1Exists) {
+            // Two not existing files are equal?
+            // Same as FileUtils
+            return true;
+        }
+        if (Files.isDirectory(nPath1, linkOptions)) {
+            // don't compare directory contents.
+            throw new IOException("Can't compare directories, only files: " + nPath1);
+        }
+        if (Files.isDirectory(nPath2, linkOptions)) {
+            // don't compare directory contents.
+            throw new IOException("Can't compare directories, only files: " + nPath2);
+        }
+        if (Files.size(nPath1) != Files.size(nPath2)) {
+            // lengths differ, cannot be equal
+            return false;
+        }
+        if (path1.equals(path2)) {
+            // same file
+            return true;
+        }
+        try (InputStream inputStream1 = Files.newInputStream(nPath1, openOptions);
+            InputStream inputStream2 = Files.newInputStream(nPath2, openOptions)) {
+            return IOUtils.contentEquals(inputStream1, inputStream2);
+        }
+    }
+
+    /**
+     * <p>
+     * Applies an {@link IOFileFilter} to the provided {@link File} objects. The resulting array is a subset of the original
+     * file list that matches the provided filter.
+     * </p>
+     *
+     * <p>
+     * The {@link Set} returned by this method is not guaranteed to be thread safe.
+     * </p>
+     *
+     * <pre>
+     * Set&lt;File&gt; allFiles = ...
+     * Set&lt;File&gt; javaFiles = FileFilterUtils.filterSet(allFiles,
+     *     FileFilterUtils.suffixFileFilter(".java"));
+     * </pre>
+     *
+     * @param filter the filter to apply to the set of files.
+     * @param paths the array of files to apply the filter to.
+     *
+     * @return a subset of {@code files} that is accepted by the file filter.
+     * @throws NullPointerException if the filter is {@code null}
+     * @throws IllegalArgumentException if {@code files} contains a {@code null} value.
+     *
+     * @since 2.9.0
+     */
+    public static Path[] filter(final PathFilter filter, final Path... paths) {
+        Objects.requireNonNull(filter, "filter");
+        if (paths == null) {
+            return EMPTY_PATH_ARRAY;
+        }
+        return filterPaths(filter, Stream.of(paths), Collectors.toList()).toArray(EMPTY_PATH_ARRAY);
+    }
+
+    private static <R, A> R filterPaths(final PathFilter filter, final Stream<Path> stream, final Collector<? super Path, A, R> collector) {
+        Objects.requireNonNull(filter, "filter");
+        Objects.requireNonNull(collector, "collector");
+        if (stream == null) {
+            return Stream.<Path>empty().collect(collector);
+        }
+        return stream.filter(p -> {
+            try {
+                return p != null && filter.accept(p, readBasicFileAttributes(p)) == FileVisitResult.CONTINUE;
+            } catch (final IOException e) {
+                return false;
+            }
+        }).collect(collector);
+    }
+
+    /**
+     * Reads the access control list from a file attribute view.
+     *
+     * @param sourcePath the path to the file.
+     * @return a file attribute view of the given type, or null if the attribute view type is not available.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.8.0
+     */
+    public static List<AclEntry> getAclEntryList(final Path sourcePath) throws IOException {
+        final AclFileAttributeView fileAttributeView = getAclFileAttributeView(sourcePath);
+        return fileAttributeView == null ? null : fileAttributeView.getAcl();
+    }
+
+    /**
+     * Shorthand for {@code Files.getFileAttributeView(path, AclFileAttributeView.class)}.
+     *
+     * @param path the path to the file.
+     * @param options how to handle symbolic links.
+     * @return a AclFileAttributeView, or {@code null} if the attribute view type is not available.
+     * @since 2.12.0
+     */
+    public static AclFileAttributeView getAclFileAttributeView(final Path path, final LinkOption... options) {
+        return Files.getFileAttributeView(path, AclFileAttributeView.class, options);
+    }
+
+    /**
+     * Shorthand for {@code Files.getFileAttributeView(path, DosFileAttributeView.class)}.
+     *
+     * @param path the path to the file.
+     * @param options how to handle symbolic links.
+     * @return a DosFileAttributeView, or {@code null} if the attribute view type is not available.
+     * @since 2.12.0
+     */
+    public static DosFileAttributeView getDosFileAttributeView(final Path path, final LinkOption... options) {
+        return Files.getFileAttributeView(path, DosFileAttributeView.class, options);
+    }
+
+    /**
+     * Gets the file's last modified time or null if the file does not exist.
+     * <p>
+     * The method provides a workaround for bug <a href="https://bugs.openjdk.java.net/browse/JDK-8177809">JDK-8177809</a>
+     * where {@link File#lastModified()} looses milliseconds and always ends in 000. This bug is in OpenJDK 8 and 9, and
+     * fixed in 11.
+     * </p>
+     *
+     * @param file the file to query.
+     * @return the file's last modified time.
+     * @throws IOException Thrown if an I/O error occurs.
+     * @since 2.12.0
+     */
+    public static FileTime getLastModifiedFileTime(final File file) throws IOException {
+        return getLastModifiedFileTime(file.toPath(), null, EMPTY_LINK_OPTION_ARRAY);
+    }
+
+    /**
+     * Gets the file's last modified time or null if the file does not exist.
+     *
+     * @param path the file to query.
+     * @param defaultIfAbsent Returns this file time of the file does not exist, may be null.
+     * @param options options indicating how symbolic links are handled.
+     * @return the file's last modified time.
+     * @throws IOException Thrown if an I/O error occurs.
+     * @since 2.12.0
+     */
+    public static FileTime getLastModifiedFileTime(final Path path, final FileTime defaultIfAbsent, final LinkOption... options) throws IOException {
+        return Files.exists(path) ? getLastModifiedTime(path, options) : defaultIfAbsent;
+    }
+
+    /**
+     * Gets the file's last modified time or null if the file does not exist.
+     *
+     * @param path the file to query.
+     * @param options options indicating how symbolic links are handled.
+     * @return the file's last modified time.
+     * @throws IOException Thrown if an I/O error occurs.
+     * @since 2.12.0
+     */
+    public static FileTime getLastModifiedFileTime(final Path path, final LinkOption... options) throws IOException {
+        return getLastModifiedFileTime(path, null, options);
+    }
+
+    /**
+     * Gets the file's last modified time or null if the file does not exist.
+     *
+     * @param uri the file to query.
+     * @return the file's last modified time.
+     * @throws IOException Thrown if an I/O error occurs.
+     * @since 2.12.0
+     */
+    public static FileTime getLastModifiedFileTime(final URI uri) throws IOException {
+        return getLastModifiedFileTime(Paths.get(uri), null, EMPTY_LINK_OPTION_ARRAY);
+    }
+
+    /**
+     * Gets the file's last modified time or null if the file does not exist.
+     *
+     * @param url the file to query.
+     * @return the file's last modified time.
+     * @throws IOException Thrown if an I/O error occurs.
+     * @throws URISyntaxException if the URL is not formatted strictly according to RFC2396 and cannot be converted to a
+     *         URI.
+     * @since 2.12.0
+     */
+    public static FileTime getLastModifiedFileTime(final URL url) throws IOException, URISyntaxException {
+        return getLastModifiedFileTime(url.toURI());
+    }
+
+    private static FileTime getLastModifiedTime(final Path path, final LinkOption... options) throws IOException {
+        return Files.getLastModifiedTime(Objects.requireNonNull(path, "path"), options);
+    }
+
+    private static Path getParent(final Path path) {
+        return path == null ? null : path.getParent();
+    }
+
+    /**
+     * Shorthand for {@code Files.getFileAttributeView(path, PosixFileAttributeView.class)}.
+     *
+     * @param path the path to the file.
+     * @param options how to handle symbolic links.
+     * @return a PosixFileAttributeView, or {@code null} if the attribute view type is not available.
+     * @since 2.12.0
+     */
+    public static PosixFileAttributeView getPosixFileAttributeView(final Path path, final LinkOption... options) {
+        return Files.getFileAttributeView(path, PosixFileAttributeView.class, options);
+    }
+
+    /**
+     * Gets a {@link Path} representing the system temporary directory.
+     *
+     * @return the system temporary directory.
+     * @since 2.12.0
+     */
+    public static Path getTempDirectory() {
+        return Paths.get(FileUtils.getTempDirectoryPath());
+    }
+
+    /**
+     * Tests whether the given {@link Path} is a directory or not. Implemented as a null-safe delegate to
+     * {@code Files.isDirectory(Path path, LinkOption... options)}.
+     *
+     * @param path the path to the file.
+     * @param options options indicating how to handle symbolic links
+     * @return {@code true} if the file is a directory; {@code false} if the path is null, the file does not exist, is not a
+     *         directory, or it cannot be determined if the file is a directory or not.
+     * @throws SecurityException In the case of the default provider, and a security manager is installed, the
+     *         {@link SecurityManager#checkRead(String) checkRead} method is invoked to check read access to the directory.
+     * @since 2.9.0
+     */
+    public static boolean isDirectory(final Path path, final LinkOption... options) {
+        return path != null && Files.isDirectory(path, options);
+    }
+
+    /**
+     * Tests whether the given file or directory is empty.
+     *
+     * @param path the file or directory to query.
+     * @return whether the file or directory is empty.
+     * @throws IOException if an I/O error occurs.
+     */
+    public static boolean isEmpty(final Path path) throws IOException {
+        return Files.isDirectory(path) ? isEmptyDirectory(path) : isEmptyFile(path);
+    }
+
+    /**
+     * Tests whether the directory is empty.
+     *
+     * @param directory the directory to query.
+     * @return whether the directory is empty.
+     * @throws NotDirectoryException if the file could not otherwise be opened because it is not a directory <i>(optional
+     *         specific exception)</i>.
+     * @throws IOException if an I/O error occurs.
+     * @throws SecurityException In the case of the default provider, and a security manager is installed, the
+     *         {@link SecurityManager#checkRead(String) checkRead} method is invoked to check read access to the directory.
+     */
+    public static boolean isEmptyDirectory(final Path directory) throws IOException {
+        try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(directory)) {
+            return !directoryStream.iterator().hasNext();
+        }
+    }
+
+    /**
+     * Tests whether the given file is empty.
+     *
+     * @param file the file to query.
+     * @return whether the file is empty.
+     * @throws IOException if an I/O error occurs.
+     * @throws SecurityException In the case of the default provider, and a security manager is installed, its
+     *         {@link SecurityManager#checkRead(String) checkRead} method denies read access to the file.
+     */
+    public static boolean isEmptyFile(final Path file) throws IOException {
+        return Files.size(file) <= 0;
+    }
+
+    /**
+     * Tests if the given {@link Path} is newer than the given time reference.
+     *
+     * @param file the {@link Path} to test.
+     * @param czdt the time reference.
+     * @param options options indicating how to handle symbolic links.
+     * @return true if the {@link Path} exists and has been modified after the given time reference.
+     * @throws IOException if an I/O error occurs.
+     * @throws NullPointerException if the file is {@code null}.
+     * @since 2.12.0
+     */
+    public static boolean isNewer(final Path file, final ChronoZonedDateTime<?> czdt, final LinkOption... options) throws IOException {
+        Objects.requireNonNull(czdt, "czdt");
+        return isNewer(file, czdt.toInstant(), options);
+    }
+
+    /**
+     * Tests if the given {@link Path} is newer than the given time reference.
+     *
+     * @param file the {@link Path} to test.
+     * @param fileTime the time reference.
+     * @param options options indicating how to handle symbolic links.
+     * @return true if the {@link Path} exists and has been modified after the given time reference.
+     * @throws IOException if an I/O error occurs.
+     * @throws NullPointerException if the file is {@code null}.
+     * @since 2.12.0
+     */
+    public static boolean isNewer(final Path file, final FileTime fileTime, final LinkOption... options) throws IOException {
+        if (notExists(file)) {
+            return false;
+        }
+        return compareLastModifiedTimeTo(file, fileTime, options) > 0;
+    }
+
+    /**
+     * Tests if the given {@link Path} is newer than the given time reference.
+     *
+     * @param file the {@link Path} to test.
+     * @param instant the time reference.
+     * @param options options indicating how to handle symbolic links.
+     * @return true if the {@link Path} exists and has been modified after the given time reference.
+     * @throws IOException if an I/O error occurs.
+     * @throws NullPointerException if the file is {@code null}.
+     * @since 2.12.0
+     */
+    public static boolean isNewer(final Path file, final Instant instant, final LinkOption... options) throws IOException {
+        return isNewer(file, FileTime.from(instant), options);
+    }
+
+    /**
+     * Tests if the given {@link Path} is newer than the given time reference.
+     *
+     * @param file the {@link Path} to test.
+     * @param timeMillis the time reference measured in milliseconds since the epoch (00:00:00 GMT, January 1, 1970)
+     * @param options options indicating how to handle symbolic links.
+     * @return true if the {@link Path} exists and has been modified after the given time reference.
+     * @throws IOException if an I/O error occurs.
+     * @throws NullPointerException if the file is {@code null}.
+     * @since 2.9.0
+     */
+    public static boolean isNewer(final Path file, final long timeMillis, final LinkOption... options) throws IOException {
+        return isNewer(file, FileTime.fromMillis(timeMillis), options);
+    }
+
+    /**
+     * Tests if the given {@link Path} is newer than the reference {@link Path}.
+     *
+     * @param file the {@link File} to test.
+     * @param reference the {@link File} of which the modification date is used.
+     * @return true if the {@link File} exists and has been modified more recently than the reference {@link File}.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.12.0
+     */
+    public static boolean isNewer(final Path file, final Path reference) throws IOException {
+        return isNewer(file, getLastModifiedTime(reference));
+    }
+
+    /**
+     * Tests if the given {@link Path} is older than the given time reference.
+     *
+     * @param file the {@link Path} to test.
+     * @param fileTime the time reference.
+     * @param options options indicating how to handle symbolic links.
+     * @return true if the {@link Path} exists and has been modified before the given time reference.
+     * @throws IOException if an I/O error occurs.
+     * @throws NullPointerException if the file is {@code null}.
+     * @since 2.12.0
+     */
+    public static boolean isOlder(final Path file, final FileTime fileTime, final LinkOption... options) throws IOException {
+        if (notExists(file)) {
+            return false;
+        }
+        return compareLastModifiedTimeTo(file, fileTime, options) < 0;
+    }
+
+    /**
+     * Tests if the given {@link Path} is older than the given time reference.
+     *
+     * @param file the {@link Path} to test.
+     * @param instant the time reference.
+     * @param options options indicating how to handle symbolic links.
+     * @return true if the {@link Path} exists and has been modified before the given time reference.
+     * @throws IOException if an I/O error occurs.
+     * @throws NullPointerException if the file is {@code null}.
+     * @since 2.12.0
+     */
+    public static boolean isOlder(final Path file, final Instant instant, final LinkOption... options) throws IOException {
+        return isOlder(file, FileTime.from(instant), options);
+    }
+
+    /**
+     * Tests if the given {@link Path} is older than the given time reference.
+     *
+     * @param file the {@link Path} to test.
+     * @param timeMillis the time reference measured in milliseconds since the epoch (00:00:00 GMT, January 1, 1970)
+     * @param options options indicating how to handle symbolic links.
+     * @return true if the {@link Path} exists and has been modified before the given time reference.
+     * @throws IOException if an I/O error occurs.
+     * @throws NullPointerException if the file is {@code null}.
+     * @since 2.12.0
+     */
+    public static boolean isOlder(final Path file, final long timeMillis, final LinkOption... options) throws IOException {
+        return isOlder(file, FileTime.fromMillis(timeMillis), options);
+    }
+
+    /**
+     * Tests if the given {@link Path} is older than the reference {@link Path}.
+     *
+     * @param file the {@link File} to test.
+     * @param reference the {@link File} of which the modification date is used.
+     * @return true if the {@link File} exists and has been modified before than the reference {@link File}.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.12.0
+     */
+    public static boolean isOlder(final Path file, final Path reference) throws IOException {
+        return isOlder(file, getLastModifiedTime(reference));
+    }
+
+    /**
+     * Tests whether the given path is on a POSIX file system.
+     *
+     * @param test The Path to test.
+     * @param options options indicating how to handle symbolic links.
+     * @return true if test is on a POSIX file system.
+     * @since 2.12.0
+     */
+    public static boolean isPosix(final Path test, final LinkOption... options) {
+        return exists(test, options) && readPosixFileAttributes(test, options) != null;
+    }
+
+    /**
+     * Tests whether the given {@link Path} is a regular file or not. Implemented as a null-safe delegate to
+     * {@code Files.isRegularFile(Path path, LinkOption... options)}.
+     *
+     * @param path the path to the file.
+     * @param options options indicating how to handle symbolic links.
+     * @return {@code true} if the file is a regular file; {@code false} if the path is null, the file does not exist, is
+     *         not a directory, or it cannot be determined if the file is a regular file or not.
+     * @throws SecurityException In the case of the default provider, and a security manager is installed, the
+     *         {@link SecurityManager#checkRead(String) checkRead} method is invoked to check read access to the directory.
+     * @since 2.9.0
+     */
+    public static boolean isRegularFile(final Path path, final LinkOption... options) {
+        return path != null && Files.isRegularFile(path, options);
+    }
+
+    /**
+     * Creates a new DirectoryStream for Paths rooted at the given directory.
+     *
+     * @param dir the path to the directory to stream.
+     * @param pathFilter the directory stream filter.
+     * @return a new instance.
+     * @throws IOException if an I/O error occurs.
+     */
+    public static DirectoryStream<Path> newDirectoryStream(final Path dir, final PathFilter pathFilter) throws IOException {
+        return Files.newDirectoryStream(dir, new DirectoryStreamFilter(pathFilter));
+    }
+
+    /**
+     * Creates a new OutputStream by opening or creating a file, returning an output stream that may be used to write bytes
+     * to the file.
+     *
+     * @param path the Path.
+     * @param append Whether or not to append.
+     * @return a new OutputStream.
+     * @throws IOException if an I/O error occurs.
+     * @see Files#newOutputStream(Path, OpenOption...)
+     * @since 2.12.0
+     */
+    public static OutputStream newOutputStream(final Path path, final boolean append) throws IOException {
+        return newOutputStream(path, EMPTY_LINK_OPTION_ARRAY, append ? OPEN_OPTIONS_APPEND : OPEN_OPTIONS_TRUNCATE);
+    }
+
+    static OutputStream newOutputStream(final Path path, final LinkOption[] linkOptions, final OpenOption... openOptions) throws IOException {
+        if (!exists(path, linkOptions)) {
+            createParentDirectories(path, linkOptions != null && linkOptions.length > 0 ? linkOptions[0] : NULL_LINK_OPTION);
+        }
+        final List<OpenOption> list = new ArrayList<>(Arrays.asList(openOptions != null ? openOptions : EMPTY_OPEN_OPTION_ARRAY));
+        list.addAll(Arrays.asList(linkOptions != null ? linkOptions : EMPTY_LINK_OPTION_ARRAY));
+        return Files.newOutputStream(path, list.toArray(EMPTY_OPEN_OPTION_ARRAY));
+    }
+
+    /**
+     * Copy of the {@link LinkOption} array for {@link LinkOption#NOFOLLOW_LINKS}.
+     *
+     * @return Copy of the {@link LinkOption} array for {@link LinkOption#NOFOLLOW_LINKS}.
+     */
+    public static LinkOption[] noFollowLinkOptionArray() {
+        return NOFOLLOW_LINK_OPTION_ARRAY.clone();
+    }
+
+    private static boolean notExists(final Path path, final LinkOption... options) {
+        return Files.notExists(Objects.requireNonNull(path, "path"), options);
+    }
+
+    /**
+     * Returns true if the given options contain {@link StandardDeleteOption#OVERRIDE_READ_ONLY}.
+     *
+     * @param deleteOptions the array to test
+     * @return true if the given options contain {@link StandardDeleteOption#OVERRIDE_READ_ONLY}.
+     */
+    private static boolean overrideReadOnly(final DeleteOption... deleteOptions) {
+        if (deleteOptions == null) {
+            return false;
+        }
+        return Stream.of(deleteOptions).anyMatch(e -> e == StandardDeleteOption.OVERRIDE_READ_ONLY);
+    }
+
+    /**
+     * Reads the BasicFileAttributes from the given path. Returns null instead of throwing
+     * {@link UnsupportedOperationException}. Throws {@link Uncheck} instead of {@link IOException}.
+     *
+     * @param <A> The {@link BasicFileAttributes} type
+     * @param path The Path to test.
+     * @param type the {@link Class} of the file attributes required to read.
+     * @param options options indicating how to handle symbolic links.
+     * @return the file attributes.
+     * @see Files#readAttributes(Path, Class, LinkOption...)
+     * @since 2.12.0
+     */
+    public static <A extends BasicFileAttributes> A readAttributes(final Path path, final Class<A> type, final LinkOption... options) {
+        try {
+            return path == null ? null : Uncheck.apply(Files::readAttributes, path, type, options);
+        } catch (final UnsupportedOperationException e) {
+            // For example, on Windows.
+            return null;
+        }
+    }
+
+    /**
+     * Reads the BasicFileAttributes from the given path.
+     *
+     * @param path the path to read.
+     * @return the path attributes.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.9.0
+     * @deprecated Will be removed in 3.0.0 in favor of {@link #readBasicFileAttributes(Path, LinkOption...)}.
+     */
+    @Deprecated
+    public static BasicFileAttributes readBasicFileAttributes(final Path path) throws IOException {
+        return Files.readAttributes(path, BasicFileAttributes.class);
+    }
+
+    /**
+     * Reads the BasicFileAttributes from the given path. Returns null instead of throwing
+     * {@link UnsupportedOperationException}.
+     *
+     * @param path the path to read.
+     * @param options options indicating how to handle symbolic links.
+     * @return the path attributes.
+     * @since 2.12.0
+     */
+    public static BasicFileAttributes readBasicFileAttributes(final Path path, final LinkOption... options) {
+        return readAttributes(path, BasicFileAttributes.class, options);
+    }
+
+    /**
+     * Reads the BasicFileAttributes from the given path. Returns null instead of throwing
+     * {@link UnsupportedOperationException}.
+     *
+     * @param path the path to read.
+     * @return the path attributes.
+     * @throws UncheckedIOException if an I/O error occurs
+     * @since 2.9.0
+     * @deprecated Use {@link #readBasicFileAttributes(Path, LinkOption...)}.
+     */
+    @Deprecated
+    public static BasicFileAttributes readBasicFileAttributesUnchecked(final Path path) {
+        return readBasicFileAttributes(path, EMPTY_LINK_OPTION_ARRAY);
+    }
+
+    /**
+     * Reads the DosFileAttributes from the given path. Returns null instead of throwing
+     * {@link UnsupportedOperationException}.
+     *
+     * @param path the path to read.
+     * @param options options indicating how to handle symbolic links.
+     * @return the path attributes.
+     * @since 2.12.0
+     */
+    public static DosFileAttributes readDosFileAttributes(final Path path, final LinkOption... options) {
+        return readAttributes(path, DosFileAttributes.class, options);
+    }
+
+    private static Path readIfSymbolicLink(final Path path) throws IOException {
+        return path != null ? Files.isSymbolicLink(path) ? Files.readSymbolicLink(path) : path : null;
+    }
+
+    /**
+     * Reads the PosixFileAttributes or DosFileAttributes from the given path. Returns null instead of throwing
+     * {@link UnsupportedOperationException}.
+     *
+     * @param path The Path to read.
+     * @param options options indicating how to handle symbolic links.
+     * @return the file attributes.
+     * @since 2.12.0
+     */
+    public static BasicFileAttributes readOsFileAttributes(final Path path, final LinkOption... options) {
+        final PosixFileAttributes fileAttributes = readPosixFileAttributes(path, options);
+        return fileAttributes != null ? fileAttributes : readDosFileAttributes(path, options);
+    }
+
+    /**
+     * Reads the PosixFileAttributes from the given path. Returns null instead of throwing
+     * {@link UnsupportedOperationException}.
+     *
+     * @param path The Path to read.
+     * @param options options indicating how to handle symbolic links.
+     * @return the file attributes.
+     * @since 2.12.0
+     */
+    public static PosixFileAttributes readPosixFileAttributes(final Path path, final LinkOption... options) {
+        return readAttributes(path, PosixFileAttributes.class, options);
+    }
+
+    /**
+     * Reads the given path as a String.
+     *
+     * @param path The source path.
+     * @param charset How to convert bytes to a String, null uses the default Charset.
+     * @return a new String.
+     * @throws IOException if an I/O error occurs reading from the stream.
+     * @see Files#readAllBytes(Path)
+     * @since 2.12.0
+     */
+    public static String readString(final Path path, final Charset charset) throws IOException {
+        return new String(Files.readAllBytes(path), Charsets.toCharset(charset));
+    }
+
+    /**
+     * Relativizes all files in the given {@code collection} against a {@code parent}.
+     *
+     * @param collection The collection of paths to relativize.
+     * @param parent relativizes against this parent path.
+     * @param sort Whether to sort the result.
+     * @param comparator How to sort.
+     * @return A collection of relativized paths, optionally sorted.
+     */
+    static List<Path> relativize(final Collection<Path> collection, final Path parent, final boolean sort, final Comparator<? super Path> comparator) {
+        Stream<Path> stream = collection.stream().map(parent::relativize);
+        if (sort) {
+            stream = comparator == null ? stream.sorted() : stream.sorted(comparator);
+        }
+        return stream.collect(Collectors.toList());
+    }
+
+    /**
+     * Requires that the given {@link File} exists and throws an {@link IllegalArgumentException} if it doesn't.
+     *
+     * @param file The {@link File} to check.
+     * @param fileParamName The parameter name to use in the exception message in case of {@code null} input.
+     * @param options options indicating how to handle symbolic links.
+     * @return the given file.
+     * @throws NullPointerException if the given {@link File} is {@code null}.
+     * @throws IllegalArgumentException if the given {@link File} does not exist.
+     */
+    private static Path requireExists(final Path file, final String fileParamName, final LinkOption... options) {
+        Objects.requireNonNull(file, fileParamName);
+        if (!exists(file, options)) {
+            throw new IllegalArgumentException("File system element for parameter '" + fileParamName + "' does not exist: '" + file + "'");
+        }
+        return file;
+    }
+
+    private static boolean setDosReadOnly(final Path path, final boolean readOnly, final LinkOption... linkOptions) throws IOException {
+        final DosFileAttributeView dosFileAttributeView = getDosFileAttributeView(path, linkOptions);
+        if (dosFileAttributeView != null) {
+            dosFileAttributeView.setReadOnly(readOnly);
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Sets the given {@code targetFile}'s last modified time to the value from {@code sourceFile}.
+     *
+     * @param sourceFile The source path to query.
+     * @param targetFile The target path to set.
+     * @throws NullPointerException if sourceFile is {@code null}.
+     * @throws NullPointerException if targetFile is {@code null}.
+     * @throws IOException if setting the last-modified time failed.
+     * @since 2.12.0
+     */
+    public static void setLastModifiedTime(final Path sourceFile, final Path targetFile) throws IOException {
+        Objects.requireNonNull(sourceFile, "sourceFile");
+        Files.setLastModifiedTime(targetFile, getLastModifiedTime(sourceFile));
+    }
+
+    /**
+     * To delete a file in POSIX, you need Write and Execute permissions on its parent directory.
+     *
+     * @param parent The parent path for a file element to delete which needs RW permissions.
+     * @param enableDeleteChildren true to set permissions to delete.
+     * @param linkOptions options indicating how handle symbolic links.
+     * @return true if the operation was attempted and succeeded, false if parent is null.
+     * @throws IOException if an I/O error occurs.
+     */
+    private static boolean setPosixDeletePermissions(final Path parent, final boolean enableDeleteChildren, final LinkOption... linkOptions)
+        throws IOException {
+        // To delete a file in POSIX, you need write and execute permissions on its parent directory.
+        // @formatter:off
+        return setPosixPermissions(parent, enableDeleteChildren, Arrays.asList(
+            PosixFilePermission.OWNER_WRITE,
+            //PosixFilePermission.GROUP_WRITE,
+            //PosixFilePermission.OTHERS_WRITE,
+            PosixFilePermission.OWNER_EXECUTE
+            //PosixFilePermission.GROUP_EXECUTE,
+            //PosixFilePermission.OTHERS_EXECUTE
+            ), linkOptions);
+        // @formatter:on
+    }
+
+    /**
+     * Low-level POSIX permission operation to set permissions.
+     *
+     * @param path Set this path's permissions.
+     * @param addPermissions true to add, false to remove.
+     * @param updatePermissions the List of PosixFilePermission to add or remove.
+     * @param linkOptions options indicating how handle symbolic links.
+     * @return true if the operation was attempted and succeeded, false if parent is null.
+     * @throws IOException if an I/O error occurs.
+     */
+    private static boolean setPosixPermissions(final Path path, final boolean addPermissions, final List<PosixFilePermission> updatePermissions,
+        final LinkOption... linkOptions) throws IOException {
+        if (path != null) {
+            final Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(path, linkOptions);
+            if (addPermissions) {
+                permissions.addAll(updatePermissions);
+            } else {
+                permissions.removeAll(updatePermissions);
+            }
+            Files.setPosixFilePermissions(path, permissions);
+            return true;
+        }
+        return false;
+    }
+
+    private static void setPosixReadOnlyFile(final Path path, final boolean readOnly, final LinkOption... linkOptions) throws IOException {
+        // Not Windows 10
+        final Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(path, linkOptions);
+        // @formatter:off
+        final List<PosixFilePermission> readPermissions = Arrays.asList(
+                PosixFilePermission.OWNER_READ
+                //PosixFilePermission.GROUP_READ,
+                //PosixFilePermission.OTHERS_READ
+            );
+        final List<PosixFilePermission> writePermissions = Arrays.asList(
+                PosixFilePermission.OWNER_WRITE
+                //PosixFilePermission.GROUP_WRITE,
+                //PosixFilePermission.OTHERS_WRITE
+            );
+        // @formatter:on
+        if (readOnly) {
+            // RO: We can read, we cannot write.
+            permissions.addAll(readPermissions);
+            permissions.removeAll(writePermissions);
+        } else {
+            // Not RO: We can read, we can write.
+            permissions.addAll(readPermissions);
+            permissions.addAll(writePermissions);
+        }
+        Files.setPosixFilePermissions(path, permissions);
+    }
+
+    /**
+     * Sets the given Path to the {@code readOnly} value.
+     * <p>
+     * This behavior is OS dependent.
+     * </p>
+     *
+     * @param path The path to set.
+     * @param readOnly true for read-only, false for not read-only.
+     * @param linkOptions options indicating how to handle symbolic links.
+     * @return The given path.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.8.0
+     */
+    public static Path setReadOnly(final Path path, final boolean readOnly, final LinkOption... linkOptions) throws IOException {
+        try {
+            // Windows is simplest
+            if (setDosReadOnly(path, readOnly, linkOptions)) {
+                return path;
+            }
+        } catch (final IOException ignored) {
+            // Retry with POSIX below.
+        }
+        final Path parent = getParent(path);
+        if (!isPosix(parent, linkOptions)) { // Test parent because we may not the permissions to test the file.
+            throw new IOException(String.format("DOS or POSIX file operations not available for '%s' %s", path, Arrays.toString(linkOptions)));
+        }
+        // POSIX
+        if (readOnly) {
+            // RO
+            // File, then parent dir (if any).
+            setPosixReadOnlyFile(path, readOnly, linkOptions);
+            setPosixDeletePermissions(parent, false, linkOptions);
+        } else {
+            // RE
+            // Parent dir (if any), then file.
+            setPosixDeletePermissions(parent, true, linkOptions);
+        }
+        return path;
+    }
+
+    /**
+     * Returns the size of the given file or directory. If the provided {@link Path} is a regular file, then the file's size
+     * is returned. If the argument is a directory, then the size of the directory is calculated recursively.
+     * <p>
+     * Note that overflow is not detected, and the return value may be negative if overflow occurs. See
+     * {@link #sizeOfAsBigInteger(Path)} for an alternative method that does not overflow.
+     * </p>
+     *
+     * @param path the regular file or directory to return the size of, must not be {@code null}.
+     * @return the length of the file, or recursive size of the directory, in bytes.
+     * @throws NullPointerException if the file is {@code null}.
+     * @throws IllegalArgumentException if the file does not exist.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.12.0
+     */
+    public static long sizeOf(final Path path) throws IOException {
+        requireExists(path, "path");
+        return Files.isDirectory(path) ? sizeOfDirectory(path) : Files.size(path);
+    }
+
+    /**
+     * Returns the size of the given file or directory. If the provided {@link Path} is a regular file, then the file's size
+     * is returned. If the argument is a directory, then the size of the directory is calculated recursively.
+     *
+     * @param path the regular file or directory to return the size of (must not be {@code null}).
+     * @return the length of the file, or recursive size of the directory, provided (in bytes).
+     * @throws NullPointerException if the file is {@code null}.
+     * @throws IllegalArgumentException if the file does not exist.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.12.0
+     */
+    public static BigInteger sizeOfAsBigInteger(final Path path) throws IOException {
+        requireExists(path, "path");
+        return Files.isDirectory(path) ? sizeOfDirectoryAsBigInteger(path) : BigInteger.valueOf(Files.size(path));
+    }
+
+    /**
+     * Counts the size of a directory recursively (sum of the size of all files).
+     * <p>
+     * Note that overflow is not detected, and the return value may be negative if overflow occurs. See
+     * {@link #sizeOfDirectoryAsBigInteger(Path)} for an alternative method that does not overflow.
+     * </p>
+     *
+     * @param directory directory to inspect, must not be {@code null}.
+     * @return size of directory in bytes, 0 if directory is security restricted, a negative number when the real total is
+     *         greater than {@link Long#MAX_VALUE}.
+     * @throws NullPointerException if the directory is {@code null}.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.12.0
+     */
+    public static long sizeOfDirectory(final Path directory) throws IOException {
+        return countDirectory(directory).getByteCounter().getLong();
+    }
+
+    /**
+     * Counts the size of a directory recursively (sum of the size of all files).
+     *
+     * @param directory directory to inspect, must not be {@code null}.
+     * @return size of directory in bytes, 0 if directory is security restricted.
+     * @throws NullPointerException if the directory is {@code null}.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.12.0
+     */
+    public static BigInteger sizeOfDirectoryAsBigInteger(final Path directory) throws IOException {
+        return countDirectoryAsBigInteger(directory).getByteCounter().getBigInteger();
+    }
+
+    /**
+     * Converts an array of {@link FileVisitOption} to a {@link Set}.
+     *
+     * @param fileVisitOptions input array.
+     * @return a new Set.
+     */
+    static Set<FileVisitOption> toFileVisitOptionSet(final FileVisitOption... fileVisitOptions) {
+        return fileVisitOptions == null ? EnumSet.noneOf(FileVisitOption.class) : Stream.of(fileVisitOptions).collect(Collectors.toSet());
+    }
+
+    /**
+     * Implements behavior similar to the Unix "touch" utility. Creates a new file with size 0, or, if the file exists, just
+     * updates the file's modified time.
+     *
+     * @param file the file to touch.
+     * @return The given file.
+     * @throws NullPointerException if the parameter is {@code null}.
+     * @throws IOException if setting the last-modified time failed or an I/O problem occurs.\
+     * @since 2.12.0
+     */
+    public static Path touch(final Path file) throws IOException {
+        Objects.requireNonNull(file, "file");
+        if (!Files.exists(file)) {
+            Files.createFile(file);
+        } else {
+            FileTimes.setLastModifiedTime(file);
+        }
+        return file;
+    }
+
+    /**
+     * Performs {@link Files#walkFileTree(Path,FileVisitor)} and returns the given visitor.
+     *
+     * Note that {@link Files#walkFileTree(Path,FileVisitor)} returns the given path.
+     *
+     * @param visitor See {@link Files#walkFileTree(Path,FileVisitor)}.
+     * @param directory See {@link Files#walkFileTree(Path,FileVisitor)}.
+     * @param <T> See {@link Files#walkFileTree(Path,FileVisitor)}.
+     * @return the given visitor.
+     *
+     * @throws IOException if an I/O error is thrown by a visitor method.
+     * @throws NullPointerException if the directory is {@code null}.
+     */
+    public static <T extends FileVisitor<? super Path>> T visitFileTree(final T visitor, final Path directory) throws IOException {
+        requireExists(directory, "directory");
+        Files.walkFileTree(directory, visitor);
+        return visitor;
+    }
+
+    /**
+     * Performs {@link Files#walkFileTree(Path,FileVisitor)} and returns the given visitor.
+     *
+     * Note that {@link Files#walkFileTree(Path,FileVisitor)} returns the given path.
+     *
+     * @param start See {@link Files#walkFileTree(Path,Set,int,FileVisitor)}.
+     * @param options See {@link Files#walkFileTree(Path,Set,int,FileVisitor)}.
+     * @param maxDepth See {@link Files#walkFileTree(Path,Set,int,FileVisitor)}.
+     * @param visitor See {@link Files#walkFileTree(Path,Set,int,FileVisitor)}.
+     * @param <T> See {@link Files#walkFileTree(Path,Set,int,FileVisitor)}.
+     * @return the given visitor.
+     *
+     * @throws IOException if an I/O error is thrown by a visitor method.
+     */
+    public static <T extends FileVisitor<? super Path>> T visitFileTree(final T visitor, final Path start, final Set<FileVisitOption> options,
+        final int maxDepth) throws IOException {
+        Files.walkFileTree(start, options, maxDepth, visitor);
+        return visitor;
+    }
+
+    /**
+     * Performs {@link Files#walkFileTree(Path,FileVisitor)} and returns the given visitor.
+     *
+     * Note that {@link Files#walkFileTree(Path,FileVisitor)} returns the given path.
+     *
+     * @param visitor See {@link Files#walkFileTree(Path,FileVisitor)}.
+     * @param first See {@link Paths#get(String,String[])}.
+     * @param more See {@link Paths#get(String,String[])}.
+     * @param <T> See {@link Files#walkFileTree(Path,FileVisitor)}.
+     * @return the given visitor.
+     *
+     * @throws IOException if an I/O error is thrown by a visitor method.
+     */
+    public static <T extends FileVisitor<? super Path>> T visitFileTree(final T visitor, final String first, final String... more) throws IOException {
+        return visitFileTree(visitor, Paths.get(first, more));
+    }
+
+    /**
+     * Performs {@link Files#walkFileTree(Path,FileVisitor)} and returns the given visitor.
+     *
+     * Note that {@link Files#walkFileTree(Path,FileVisitor)} returns the given path.
+     *
+     * @param visitor See {@link Files#walkFileTree(Path,FileVisitor)}.
+     * @param uri See {@link Paths#get(URI)}.
+     * @param <T> See {@link Files#walkFileTree(Path,FileVisitor)}.
+     * @return the given visitor.
+     *
+     * @throws IOException if an I/O error is thrown by a visitor method.
+     */
+    public static <T extends FileVisitor<? super Path>> T visitFileTree(final T visitor, final URI uri) throws IOException {
+        return visitFileTree(visitor, Paths.get(uri));
+    }
+
+    /**
+     * Waits for the file system to propagate a file creation, with a timeout.
+     * <p>
+     * This method repeatedly tests {@link Files#exists(Path,LinkOption...)} until it returns true up to the maximum time
+     * given.
+     * </p>
+     *
+     * @param file the file to check, must not be {@code null}.
+     * @param timeout the maximum time to wait.
+     * @param options options indicating how to handle symbolic links.
+     * @return true if file exists.
+     * @throws NullPointerException if the file is {@code null}.
+     * @since 2.12.0
+     */
+    public static boolean waitFor(final Path file, final Duration timeout, final LinkOption... options) {
+        Objects.requireNonNull(file, "file");
+        final Instant finishInstant = Instant.now().plus(timeout);
+        boolean interrupted = false;
+        final long minSleepMillis = 100;
+        try {
+            while (!exists(file, options)) {
+                final Instant now = Instant.now();
+                if (now.isAfter(finishInstant)) {
+                    return false;
+                }
+                try {
+                    ThreadUtils.sleep(Duration.ofMillis(Math.min(minSleepMillis, finishInstant.minusMillis(now.toEpochMilli()).toEpochMilli())));
+                } catch (final InterruptedException ignore) {
+                    interrupted = true;
+                } catch (final Exception ex) {
+                    break;
+                }
+            }
+        } finally {
+            if (interrupted) {
+                Thread.currentThread().interrupt();
+            }
+        }
+        return exists(file, options);
+    }
+
+    /**
+     * Returns a stream of filtered paths.
+     *
+     * @param start the start path
+     * @param pathFilter the path filter
+     * @param maxDepth the maximum depth of directories to walk.
+     * @param readAttributes whether to call the filters with file attributes (false passes null).
+     * @param options the options to configure the walk.
+     * @return a filtered stream of paths.
+     * @throws IOException if an I/O error is thrown when accessing the starting file.
+     * @since 2.9.0
+     */
+    public static Stream<Path> walk(final Path start, final PathFilter pathFilter, final int maxDepth, final boolean readAttributes,
+        final FileVisitOption... options) throws IOException {
+        return Files.walk(start, maxDepth, options)
+            .filter(path -> pathFilter.accept(path, readAttributes ? readBasicFileAttributesUnchecked(path) : null) == FileVisitResult.CONTINUE);
+    }
+
+    private static <R> R withPosixFileAttributes(final Path path, final LinkOption[] linkOptions, final boolean overrideReadOnly,
+        final IOFunction<PosixFileAttributes, R> function) throws IOException {
+        final PosixFileAttributes posixFileAttributes = overrideReadOnly ? readPosixFileAttributes(path, linkOptions) : null;
+        try {
+            return function.apply(posixFileAttributes);
+        } finally {
+            if (posixFileAttributes != null && path != null && Files.exists(path, linkOptions)) {
+                Files.setPosixFilePermissions(path, posixFileAttributes.permissions());
+            }
+        }
+    }
+
+    /**
+     * Writes the given character sequence to a file at the given path.
+     *
+     * @param path The target file.
+     * @param charSequence The character sequence text.
+     * @param charset The Charset to encode the text.
+     * @param openOptions options How to open the file.
+     * @return The given path.
+     * @throws IOException if an I/O error occurs writing to or creating the file.
+     * @throws NullPointerException if either {@code path} or {@code charSequence} is {@code null}.
+     * @since 2.12.0
+     */
+    public static Path writeString(final Path path, final CharSequence charSequence, final Charset charset, final OpenOption... openOptions)
+        throws IOException {
+        // Check the text is not null before opening file.
+        Objects.requireNonNull(path, "path");
+        Objects.requireNonNull(charSequence, "charSequence");
+        Files.write(path, String.valueOf(charSequence).getBytes(Charsets.toCharset(charset)), openOptions);
+        return path;
+    }
+
+    /**
+     * Does allow to instantiate.
+     */
+    private PathUtils() {
+        // do not instantiate.
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/file/PathVisitor.java b/src/main/java/org/apache/commons/io/file/PathVisitor.java
new file mode 100644
index 0000000..dcd7258
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/PathVisitor.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.nio.file.FileVisitor;
+import java.nio.file.Path;
+
+/**
+ * A {@link FileVisitor} typed to a {@link Path}.
+ *
+ * @since 2.9.0
+ */
+public interface PathVisitor extends FileVisitor<Path> {
+    // empty
+}
diff --git a/src/main/java/org/apache/commons/io/file/SimplePathVisitor.java b/src/main/java/org/apache/commons/io/file/SimplePathVisitor.java
new file mode 100644
index 0000000..1c3b1dd
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/SimplePathVisitor.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.util.Objects;
+
+import org.apache.commons.io.function.IOBiFunction;
+
+/**
+ * A {@link SimpleFileVisitor} typed to a {@link Path}.
+ *
+ * @since 2.7
+ */
+public abstract class SimplePathVisitor extends SimpleFileVisitor<Path> implements PathVisitor {
+
+    private final IOBiFunction<Path, IOException, FileVisitResult> visitFileFailedFunction;
+
+    /**
+     * Constructs a new instance.
+     */
+    protected SimplePathVisitor() {
+        this.visitFileFailedFunction = super::visitFileFailed;
+    }
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param visitFileFailed Called on {@link #visitFileFailed(Path, IOException)}.
+     */
+    protected SimplePathVisitor(final IOBiFunction<Path, IOException, FileVisitResult> visitFileFailed) {
+        this.visitFileFailedFunction = Objects.requireNonNull(visitFileFailed, "visitFileFailed");
+    }
+
+    @Override
+    public FileVisitResult visitFileFailed(final Path file, final IOException exc) throws IOException {
+        return visitFileFailedFunction.apply(file, exc);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/file/StandardDeleteOption.java b/src/main/java/org/apache/commons/io/file/StandardDeleteOption.java
new file mode 100644
index 0000000..c5c2af9
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/StandardDeleteOption.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.util.stream.Stream;
+
+import org.apache.commons.io.IOUtils;
+
+/**
+ * Defines the standard delete options.
+ *
+ * @since 2.8.0
+ */
+public enum StandardDeleteOption implements DeleteOption {
+
+    /**
+     * Overrides the read-only attribute to allow deletion, on POSIX, this means Write and Execute on the parent.
+     */
+    OVERRIDE_READ_ONLY;
+
+    /**
+     * Returns true if the given options contain {@link StandardDeleteOption#OVERRIDE_READ_ONLY}.
+     *
+     * For now, assume the array is not sorted.
+     *
+     * @param options the array to test
+     * @return true if the given options contain {@link StandardDeleteOption#OVERRIDE_READ_ONLY}.
+     */
+    public static boolean overrideReadOnly(final DeleteOption[] options) {
+        if (IOUtils.length(options) == 0) {
+            return false;
+        }
+        return Stream.of(options).anyMatch(e -> StandardDeleteOption.OVERRIDE_READ_ONLY == e);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/file/attribute/FileTimes.java b/src/main/java/org/apache/commons/io/file/attribute/FileTimes.java
new file mode 100644
index 0000000..75b85a9
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/attribute/FileTimes.java
@@ -0,0 +1,226 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file.attribute;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.time.Instant;
+import java.util.Date;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Helps use {@link FileTime} and interoperate Date and NTFS times.
+ *
+ * @since 2.12.0
+ */
+public class FileTimes {
+
+    /**
+     * Constant for the {@code 1970-01-01T00:00:00Z} {@link Instant#EPOCH epoch} as a time stamp attribute.
+     *
+     * @see Instant#EPOCH
+     */
+    public static final FileTime EPOCH = FileTime.from(Instant.EPOCH);
+
+    /**
+     * The offset of Windows time 0 to Unix epoch in 100-nanosecond intervals.
+     *
+     * <a href="https://msdn.microsoft.com/en-us/library/windows/desktop/ms724290%28v=vs.85%29.aspx">Windows File Times</a>
+     * <p>
+     * A file time is a 64-bit value that represents the number of 100-nanosecond intervals that have elapsed since 12:00
+     * A.M. January 1, 1601 Coordinated Universal Time (UTC). This is the offset of Windows time 0 to Unix epoch in
+     * 100-nanosecond intervals.
+     * </p>
+     */
+    static final long WINDOWS_EPOCH_OFFSET = -116444736000000000L;
+
+    /**
+     * The amount of 100-nanosecond intervals in one second.
+     */
+    private static final long HUNDRED_NANOS_PER_SECOND = TimeUnit.SECONDS.toNanos(1) / 100;
+
+    /**
+     * The amount of 100-nanosecond intervals in one millisecond.
+     */
+    static final long HUNDRED_NANOS_PER_MILLISECOND = TimeUnit.MILLISECONDS.toNanos(1) / 100;
+
+    /**
+     * Subtracts milliseconds from a source FileTime.
+     *
+     * @param fileTime The source FileTime.
+     * @param millisToSubtract The milliseconds to subtract.
+     * @return The resulting FileTime.
+     */
+    public static FileTime minusMillis(final FileTime fileTime, final long millisToSubtract) {
+        return FileTime.from(fileTime.toInstant().minusMillis(millisToSubtract));
+    }
+
+    /**
+     * Subtracts nanoseconds from a source FileTime.
+     *
+     * @param fileTime The source FileTime.
+     * @param nanosToSubtract The nanoseconds to subtract.
+     * @return The resulting FileTime.
+     */
+    public static FileTime minusNanos(final FileTime fileTime, final long nanosToSubtract) {
+        return FileTime.from(fileTime.toInstant().minusNanos(nanosToSubtract));
+    }
+
+    /**
+     * Subtracts seconds from a source FileTime.
+     *
+     * @param fileTime The source FileTime.
+     * @param secondsToSubtract The seconds to subtract.
+     * @return The resulting FileTime.
+     */
+    public static FileTime minusSeconds(final FileTime fileTime, final long secondsToSubtract) {
+        return FileTime.from(fileTime.toInstant().minusSeconds(secondsToSubtract));
+    }
+
+    /**
+     * Obtains the current instant FileTime from the system clock.
+     *
+     * @return the current instant FileTime from the system clock.
+     */
+    public static FileTime now() {
+        return FileTime.from(Instant.now());
+    }
+
+    /**
+     * Converts NTFS time (100 nanosecond units since 1 January 1601) to Java time.
+     *
+     * @param ntfsTime the NTFS time in 100 nanosecond units
+     * @return the Date
+     */
+    public static Date ntfsTimeToDate(final long ntfsTime) {
+        final long javaHundredNanos = Math.addExact(ntfsTime, WINDOWS_EPOCH_OFFSET);
+        final long javaMillis = Math.floorDiv(javaHundredNanos, HUNDRED_NANOS_PER_MILLISECOND);
+        return new Date(javaMillis);
+    }
+
+    /**
+     * Converts NTFS time (100-nanosecond units since 1 January 1601) to a FileTime.
+     *
+     * @param ntfsTime the NTFS time in 100-nanosecond units
+     * @return the FileTime
+     *
+     * @see #toNtfsTime(FileTime)
+     */
+    public static FileTime ntfsTimeToFileTime(final long ntfsTime) {
+        final long javaHundredsNanos = Math.addExact(ntfsTime, WINDOWS_EPOCH_OFFSET);
+        final long javaSeconds = Math.floorDiv(javaHundredsNanos, HUNDRED_NANOS_PER_SECOND);
+        final long javaNanos = Math.floorMod(javaHundredsNanos, HUNDRED_NANOS_PER_SECOND) * 100;
+        return FileTime.from(Instant.ofEpochSecond(javaSeconds, javaNanos));
+    }
+
+    /**
+     * Adds milliseconds to a source FileTime.
+     *
+     * @param fileTime The source FileTime.
+     * @param millisToAdd The milliseconds to add.
+     * @return The resulting FileTime.
+     */
+    public static FileTime plusMillis(final FileTime fileTime, final long millisToAdd) {
+        return FileTime.from(fileTime.toInstant().plusMillis(millisToAdd));
+    }
+
+    /**
+     * Adds nanoseconds from a source FileTime.
+     *
+     * @param fileTime The source FileTime.
+     * @param nanosToSubtract The nanoseconds to subtract.
+     * @return The resulting FileTime.
+     */
+    public static FileTime plusNanos(final FileTime fileTime, final long nanosToSubtract) {
+        return FileTime.from(fileTime.toInstant().plusNanos(nanosToSubtract));
+    }
+
+    /**
+     * Adds seconds to a source FileTime.
+     *
+     * @param fileTime The source FileTime.
+     * @param secondsToAdd The seconds to add.
+     * @return The resulting FileTime.
+     */
+    public static FileTime plusSeconds(final FileTime fileTime, final long secondsToAdd) {
+        return FileTime.from(fileTime.toInstant().plusSeconds(secondsToAdd));
+    }
+
+    /**
+     * Sets the last modified time of the given file path to now.
+     *
+     * @param path The file path to set.
+     * @throws IOException if an I/O error occurs.
+     */
+    public static void setLastModifiedTime(final Path path) throws IOException {
+        Files.setLastModifiedTime(path, now());
+    }
+
+    /**
+     * Converts {@link FileTime} to a {@link Date}. If the provided FileTime is {@code null}, the returned Date is also
+     * {@code null}.
+     *
+     * @param fileTime the file time to be converted.
+     * @return a {@link Date} which corresponds to the supplied time, or {@code null} if the time is {@code null}.
+     * @see #toFileTime(Date)
+     */
+    public static Date toDate(final FileTime fileTime) {
+        return fileTime != null ? new Date(fileTime.toMillis()) : null;
+    }
+
+    /**
+     * Converts {@link Date} to a {@link FileTime}. If the provided Date is {@code null}, the returned FileTime is also
+     * {@code null}.
+     *
+     * @param date the date to be converted.
+     * @return a {@link FileTime} which corresponds to the supplied date, or {@code null} if the date is {@code null}.
+     * @see #toDate(FileTime)
+     */
+    public static FileTime toFileTime(final Date date) {
+        return date != null ? FileTime.fromMillis(date.getTime()) : null;
+    }
+
+    /**
+     * Converts a {@link Date} to NTFS time.
+     *
+     * @param date the Date
+     * @return the NTFS time
+     */
+    public static long toNtfsTime(final Date date) {
+        final long javaHundredNanos = date.getTime() * HUNDRED_NANOS_PER_MILLISECOND;
+        return Math.subtractExact(javaHundredNanos, WINDOWS_EPOCH_OFFSET);
+    }
+
+    /**
+     * Converts a {@link FileTime} to NTFS time (100-nanosecond units since 1 January 1601).
+     *
+     * @param fileTime the FileTime
+     * @return the NTFS time in 100-nanosecond units
+     */
+    public static long toNtfsTime(final FileTime fileTime) {
+        final Instant instant = fileTime.toInstant();
+        final long javaHundredNanos = instant.getEpochSecond() * HUNDRED_NANOS_PER_SECOND + instant.getNano() / 100;
+        return Math.subtractExact(javaHundredNanos, WINDOWS_EPOCH_OFFSET);
+    }
+
+    private FileTimes() {
+        // No instances.
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/file/attribute/package-info.java b/src/main/java/org/apache/commons/io/file/attribute/package-info.java
new file mode 100644
index 0000000..351bb63
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/attribute/package-info.java
@@ -0,0 +1,21 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Helps use {@link java.nio.file.attribute}.
+ */
+package org.apache.commons.io.file.attribute;
diff --git a/src/main/java/org/apache/commons/io/file/package-info.java b/src/main/java/org/apache/commons/io/file/package-info.java
new file mode 100644
index 0000000..8396fa4
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/package-info.java
@@ -0,0 +1,21 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * This package provides extensions in the realm of {@link java.nio.file}.
+ */
+package org.apache.commons.io.file;
diff --git a/src/main/java/org/apache/commons/io/file/spi/FileSystemProviders.java b/src/main/java/org/apache/commons/io/file/spi/FileSystemProviders.java
new file mode 100644
index 0000000..4058b79
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/spi/FileSystemProviders.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file.spi;
+
+import java.net.URI;
+import java.net.URL;
+import java.nio.file.FileSystems;
+import java.nio.file.Path;
+import java.nio.file.spi.FileSystemProvider;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Helps to work with {@link FileSystemProvider}.
+ *
+ * @since 2.9.0
+ */
+public class FileSystemProviders {
+
+    private static final FileSystemProviders INSTALLED = new FileSystemProviders(FileSystemProvider.installedProviders());
+
+    /**
+     * Gets the {@link FileSystemProvider} for the given Path.
+     *
+     * @param path The Path to query
+     * @return the {@link FileSystemProvider} for the given Path.
+     */
+    @SuppressWarnings("resource") // FileSystem is not allocated here.
+    public static FileSystemProvider getFileSystemProvider(final Path path) {
+        return Objects.requireNonNull(path, "path").getFileSystem().provider();
+    }
+
+    /**
+     * Returns the instance for the installed providers.
+     *
+     * @return the instance for the installed providers.
+     * @see FileSystemProvider#installedProviders()
+     */
+    public static FileSystemProviders installed() {
+        return INSTALLED;
+    }
+
+    private final List<FileSystemProvider> providers;
+
+    /*
+     * Might make public later.
+     */
+    private FileSystemProviders(final List<FileSystemProvider> providers) {
+        this.providers = providers != null ? providers : Collections.emptyList();
+    }
+
+    /**
+     * Gets the {@link FileSystemProvider} for the given scheme.
+     *
+     * @param scheme The scheme to query.
+     * @return the {@link FileSystemProvider} for the given URI or null.
+     */
+    @SuppressWarnings("resource") // FileSystems.getDefault() returns a constant.
+    public FileSystemProvider getFileSystemProvider(final String scheme) {
+        Objects.requireNonNull(scheme, "scheme");
+        // Check default provider first to avoid loading of installed providers.
+        if (scheme.equalsIgnoreCase("file")) {
+            return FileSystems.getDefault().provider();
+        }
+        // Find provider.
+        return providers.stream().filter(provider -> provider.getScheme().equalsIgnoreCase(scheme)).findFirst().orElse(null);
+    }
+
+    /**
+     * Gets the {@link FileSystemProvider} for the given URI.
+     *
+     * @param uri The URI to query
+     * @return the {@link FileSystemProvider} for the given URI or null.
+     */
+    public FileSystemProvider getFileSystemProvider(final URI uri) {
+        return getFileSystemProvider(Objects.requireNonNull(uri, "uri").getScheme());
+    }
+
+    /**
+     * Gets the {@link FileSystemProvider} for the given URL.
+     *
+     * @param url The URL to query
+     * @return the {@link FileSystemProvider} for the given URI or null.
+     */
+    public FileSystemProvider getFileSystemProvider(final URL url) {
+        return getFileSystemProvider(Objects.requireNonNull(url, "url").getProtocol());
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/file/spi/package-info.java b/src/main/java/org/apache/commons/io/file/spi/package-info.java
new file mode 100644
index 0000000..d347ee5
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/file/spi/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * This package provides extensions in the realm of {@link java.nio.file.spi}.
+ *
+ * @since 2.9.0
+ */
+package org.apache.commons.io.file.spi;
diff --git a/src/main/java/org/apache/commons/io/filefilter/AbstractFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/AbstractFileFilter.java
new file mode 100644
index 0000000..4bc6dcf
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/AbstractFileFilter.java
@@ -0,0 +1,179 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.FilenameFilter;
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.List;
+import java.util.Objects;
+
+import org.apache.commons.io.file.PathFilter;
+import org.apache.commons.io.file.PathVisitor;
+import org.apache.commons.io.function.IOSupplier;
+
+/**
+ * Abstracts the implementation of the {@link FileFilter} (IO), {@link FilenameFilter} (IO), {@link PathFilter} (NIO)
+ * interfaces via our own {@link IOFileFilter} interface.
+ * <p>
+ * Note that a subclass MUST override one of the {@code accept} methods, otherwise that subclass will infinitely loop.
+ * </p>
+ *
+ * @since 1.0
+ */
+public abstract class AbstractFileFilter implements IOFileFilter, PathVisitor {
+
+    static FileVisitResult toDefaultFileVisitResult(final boolean accept) {
+        return accept ? FileVisitResult.CONTINUE : FileVisitResult.TERMINATE;
+    }
+
+    /**
+     * What to do when this filter accepts.
+     */
+    private final FileVisitResult onAccept;
+
+    /**
+     * What to do when this filter rejects.
+     */
+    private final FileVisitResult onReject;
+
+    /**
+     * Constructs a new instance.
+     */
+    public AbstractFileFilter() {
+        this(FileVisitResult.CONTINUE, FileVisitResult.TERMINATE);
+    }
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param onAccept What to do on acceptance.
+     * @param onReject What to do on rejection.
+     * @since 2.12.0.
+     */
+    protected AbstractFileFilter(final FileVisitResult onAccept, final FileVisitResult onReject) {
+        this.onAccept = onAccept;
+        this.onReject = onReject;
+    }
+
+    /**
+     * Checks to see if the File should be accepted by this filter.
+     *
+     * @param file the File to check
+     * @return true if this file matches the test
+     */
+    @Override
+    public boolean accept(final File file) {
+        Objects.requireNonNull(file, "file");
+        return accept(file.getParentFile(), file.getName());
+    }
+
+    /**
+     * Checks to see if the File should be accepted by this filter.
+     *
+     * @param dir the directory File to check
+     * @param name the file name within the directory to check
+     * @return true if this file matches the test
+     */
+    @Override
+    public boolean accept(final File dir, final String name) {
+        Objects.requireNonNull(name, "name");
+        return accept(new File(dir, name));
+    }
+
+    void append(final List<?> list, final StringBuilder buffer) {
+        for (int i = 0; i < list.size(); i++) {
+            if (i > 0) {
+                buffer.append(",");
+            }
+            buffer.append(list.get(i));
+        }
+    }
+
+    void append(final Object[] array, final StringBuilder buffer) {
+        for (int i = 0; i < array.length; i++) {
+            if (i > 0) {
+                buffer.append(",");
+            }
+            buffer.append(array[i]);
+        }
+    }
+
+    FileVisitResult get(final IOSupplier<FileVisitResult> supplier) {
+        try {
+            return supplier.get();
+        } catch (IOException e) {
+            return handle(e);
+        }
+    }
+
+    /**
+     * Handles exceptions caught while accepting.
+     *
+     * @param t the caught Throwable.
+     * @return the given Throwable.
+     * @since 2.9.0
+     */
+    protected FileVisitResult handle(final Throwable t) {
+        return FileVisitResult.TERMINATE;
+    }
+
+    @Override
+    public FileVisitResult postVisitDirectory(final Path dir, final IOException exc) throws IOException {
+        return FileVisitResult.CONTINUE;
+    }
+
+    @Override
+    public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attributes) throws IOException {
+        return accept(dir, attributes);
+    }
+
+    /**
+     * Converts a boolean into a FileVisitResult.
+     *
+     * @param accept accepted or rejected.
+     * @return a FileVisitResult.
+     */
+    FileVisitResult toFileVisitResult(final boolean accept) {
+        return accept ? onAccept : onReject;
+    }
+
+    /**
+     * Provides a String representation of this file filter.
+     *
+     * @return a String representation
+     */
+    @Override
+    public String toString() {
+        return getClass().getSimpleName();
+    }
+
+    @Override
+    public FileVisitResult visitFile(final Path file, final BasicFileAttributes attributes) throws IOException {
+        return accept(file, attributes);
+    }
+
+    @Override
+    public FileVisitResult visitFileFailed(final Path file, final IOException exc) throws IOException {
+        return FileVisitResult.CONTINUE;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/AgeFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/AgeFileFilter.java
new file mode 100644
index 0000000..8f04ffa
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/AgeFileFilter.java
@@ -0,0 +1,212 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.time.Instant;
+import java.util.Date;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.file.PathUtils;
+
+/**
+ * Filters files based on a cutoff time, can filter either newer files or files equal to or older.
+ * <p>
+ * For example, to print all files and directories in the current directory older than one day:
+ * </p>
+ * <h2>Using Classic IO</h2>
+ * <pre>
+ * Path dir = PathUtils.current();
+ * // We are interested in files older than one day
+ * Instant cutoff = Instant.now().minus(Duration.ofDays(1));
+ * String[] files = dir.list(new AgeFileFilter(cutoff));
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <h2>Using NIO</h2>
+ * <pre>
+ * Path dir = PathUtils.current();
+ * // We are interested in files older than one day
+ * Instant cutoff = Instant.now().minus(Duration.ofDays(1));
+ * AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(new AgeFileFilter(cutoff));
+ * //
+ * // Walk one dir
+ * Files.<b>walkFileTree</b>(dir, Collections.emptySet(), 1, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getFileList());
+ * //
+ * visitor.getPathCounters().reset();
+ * //
+ * // Walk dir tree
+ * Files.<b>walkFileTree</b>(dir, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getDirList());
+ * System.out.println(visitor.getFileList());
+ * </pre>
+ *
+ * @see FileFilterUtils#ageFileFilter(Date)
+ * @see FileFilterUtils#ageFileFilter(File)
+ * @see FileFilterUtils#ageFileFilter(long)
+ * @see FileFilterUtils#ageFileFilter(Date, boolean)
+ * @see FileFilterUtils#ageFileFilter(File, boolean)
+ * @see FileFilterUtils#ageFileFilter(long, boolean)
+ * @since 1.2
+ */
+public class AgeFileFilter extends AbstractFileFilter implements Serializable {
+
+    private static final long serialVersionUID = -2132740084016138541L;
+
+    /** Whether the files accepted will be older or newer. */
+    private final boolean acceptOlder;
+
+    /** The cutoff time threshold measured in milliseconds since the epoch (00:00:00 GMT, January 1, 1970). */
+    private final Instant cutoffInstant;
+
+    /**
+     * Constructs a new age file filter for files older than (at or before) a certain cutoff date.
+     *
+     * @param cutoffDate the threshold age of the files
+     */
+    public AgeFileFilter(final Date cutoffDate) {
+        this(cutoffDate, true);
+    }
+
+    /**
+     * Constructs a new age file filter for files on any one side of a certain cutoff date.
+     *
+     * @param cutoffDate the threshold age of the files
+     * @param acceptOlder if true, older files (at or before the cutoff) are accepted, else newer ones (after the
+     *        cutoff).
+     */
+    public AgeFileFilter(final Date cutoffDate, final boolean acceptOlder) {
+        this(cutoffDate.toInstant(), acceptOlder);
+    }
+
+    /**
+     * Constructs a new age file filter for files older than (at or before) a certain File (whose last modification time
+     * will be used as reference).
+     *
+     * @param cutoffReference the file whose last modification time is used as the threshold age of the files
+     */
+    public AgeFileFilter(final File cutoffReference) {
+        this(cutoffReference, true);
+    }
+
+    /**
+     * Constructs a new age file filter for files on any one side of a certain File (whose last modification time will
+     * be used as reference).
+     *
+     * @param cutoffReference the file whose last modification time is used as the threshold age of the files
+     * @param acceptOlder if true, older files (at or before the cutoff) are accepted, else newer ones (after the
+     *        cutoff).
+     */
+    public AgeFileFilter(final File cutoffReference, final boolean acceptOlder) {
+        this(FileUtils.lastModifiedUnchecked(cutoffReference), acceptOlder);
+    }
+
+    /**
+     * Constructs a new age file filter for files equal to or older than a certain cutoff.
+     *
+     * @param cutoffInstant The cutoff time threshold since the epoch (00:00:00 GMT, January 1, 1970).
+     * @since 2.12.0
+     */
+    public AgeFileFilter(final Instant cutoffInstant) {
+        this(cutoffInstant, true);
+    }
+
+    /**
+     * Constructs a new age file filter for files on any one side of a certain cutoff.
+     *
+     * @param cutoffInstant The cutoff time threshold since the epoch (00:00:00 GMT, January 1, 1970).
+     * @param acceptOlder if true, older files (at or before the cutoff) are accepted, else newer ones (after the cutoff).
+     * @since 2.12.0
+     */
+    public AgeFileFilter(final Instant cutoffInstant, final boolean acceptOlder) {
+        this.acceptOlder = acceptOlder;
+        this.cutoffInstant = cutoffInstant;
+    }
+
+    /**
+     * Constructs a new age file filter for files equal to or older than a certain cutoff
+     *
+     * @param cutoffMillis The cutoff time threshold measured in milliseconds since the epoch (00:00:00 GMT, January 1,
+     *        1970).
+     */
+    public AgeFileFilter(final long cutoffMillis) {
+        this(Instant.ofEpochMilli(cutoffMillis), true);
+    }
+
+    /**
+     * Constructs a new age file filter for files on any one side of a certain cutoff.
+     *
+     * @param cutoffMillis The cutoff time threshold measured in milliseconds since the epoch (00:00:00 GMT, January 1,
+     *        1970).
+     * @param acceptOlder if true, older files (at or before the cutoff) are accepted, else newer ones (after the
+     *        cutoff).
+     */
+    public AgeFileFilter(final long cutoffMillis, final boolean acceptOlder) {
+        this(Instant.ofEpochMilli(cutoffMillis), acceptOlder);
+    }
+
+    /**
+     * Checks to see if the last modification of the file matches cutoff favorably.
+     * <p>
+     * If last modification time equals cutoff and newer files are required, file <b>IS NOT</b> selected. If last
+     * modification time equals cutoff and older files are required, file <b>IS</b> selected.
+     * </p>
+     *
+     * @param file the File to check
+     * @return true if the file name matches
+     */
+    @Override
+    public boolean accept(final File file) {
+        return acceptOlder != FileUtils.isFileNewer(file, cutoffInstant);
+    }
+
+    /**
+     * Checks to see if the last modification of the file matches cutoff favorably.
+     * <p>
+     * If last modification time equals cutoff and newer files are required, file <b>IS NOT</b> selected. If last
+     * modification time equals cutoff and older files are required, file <b>IS</b> selected.
+     * </p>
+     * @param file the File to check
+     *
+     * @return true if the file name matches
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return get(() -> toFileVisitResult(acceptOlder != PathUtils.isNewer(file, cutoffInstant)));
+    }
+
+    /**
+     * Provide a String representation of this file filter.
+     *
+     * @return a String representation
+     */
+    @Override
+    public String toString() {
+        final String condition = acceptOlder ? "<=" : ">";
+        return super.toString() + "(" + condition + cutoffInstant + ")";
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/AndFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/AndFileFilter.java
new file mode 100644
index 0000000..e29f780
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/AndFileFilter.java
@@ -0,0 +1,199 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+/**
+ * A {@link java.io.FileFilter} providing conditional AND logic across a list of
+ * file filters. This filter returns {@code true} if all filters in the
+ * list return {@code true}. Otherwise, it returns {@code false}.
+ * Checking of the file filter list stops when the first filter returns
+ * {@code false}.
+ *
+ * @since 1.0
+ * @see FileFilterUtils#and(IOFileFilter...)
+ */
+public class AndFileFilter
+        extends AbstractFileFilter
+        implements ConditionalFileFilter, Serializable {
+
+    private static final long serialVersionUID = 7215974688563965257L;
+
+    /** The list of file filters. */
+    private final List<IOFileFilter> fileFilters;
+
+    /**
+     * Constructs a new empty instance.
+     *
+     * @since 1.1
+     */
+    public AndFileFilter() {
+        this(0);
+    }
+
+    /**
+     * Constructs a new instance with the given initial list.
+     *
+     * @param initialList the initial list.
+     */
+    private AndFileFilter(final ArrayList<IOFileFilter> initialList) {
+        this.fileFilters = Objects.requireNonNull(initialList, "initialList");
+    }
+
+    /**
+     * Constructs a new instance with the given initial capacity.
+     *
+     * @param initialCapacity the initial capacity.
+     */
+    private AndFileFilter(final int initialCapacity) {
+        this(new ArrayList<>(initialCapacity));
+    }
+
+    /**
+     * Constructs a new instance for the give filters.
+     * @param fileFilters filters to OR.
+     *
+     * @since 2.9.0
+     */
+    public AndFileFilter(final IOFileFilter... fileFilters) {
+        this(Objects.requireNonNull(fileFilters, "fileFilters").length);
+        addFileFilter(fileFilters);
+    }
+
+    /**
+     * Constructs a new file filter that ANDs the result of other filters.
+     *
+     * @param filter1  the first filter, must second be null
+     * @param filter2  the first filter, must not be null
+     * @throws IllegalArgumentException if either filter is null
+     */
+    public AndFileFilter(final IOFileFilter filter1, final IOFileFilter filter2) {
+        this(2);
+        addFileFilter(filter1);
+        addFileFilter(filter2);
+    }
+
+    /**
+     * Constructs a new instance of {@link AndFileFilter}
+     * with the specified list of filters.
+     *
+     * @param fileFilters  a List of IOFileFilter instances, copied.
+     * @since 1.1
+     */
+    public AndFileFilter(final List<IOFileFilter> fileFilters) {
+        this(new ArrayList<>(Objects.requireNonNull(fileFilters, "fileFilters")));
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public boolean accept(final File file) {
+        return !isEmpty() && fileFilters.stream().allMatch(fileFilter -> fileFilter.accept(file));
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public boolean accept(final File file, final String name) {
+        return !isEmpty() && fileFilters.stream().allMatch(fileFilter -> fileFilter.accept(file, name));
+    }
+
+    /**
+     * {@inheritDoc}
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return isEmpty() ? FileVisitResult.TERMINATE
+                : toDefaultFileVisitResult(fileFilters.stream().allMatch(fileFilter -> fileFilter.accept(file, attributes) == FileVisitResult.CONTINUE));
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void addFileFilter(final IOFileFilter fileFilter) {
+        this.fileFilters.add(Objects.requireNonNull(fileFilter, "fileFilter"));
+    }
+
+    /**
+     * Adds the given file filters.
+     *
+     * @param fileFilters the filters to add.
+     * @since 2.9.0
+     */
+    public void addFileFilter(final IOFileFilter... fileFilters) {
+        Stream.of(Objects.requireNonNull(fileFilters, "fileFilters")).forEach(this::addFileFilter);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public List<IOFileFilter> getFileFilters() {
+        return Collections.unmodifiableList(this.fileFilters);
+    }
+
+    private boolean isEmpty() {
+        return this.fileFilters.isEmpty();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public boolean removeFileFilter(final IOFileFilter ioFileFilter) {
+        return this.fileFilters.remove(ioFileFilter);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void setFileFilters(final List<IOFileFilter> fileFilters) {
+        this.fileFilters.clear();
+        this.fileFilters.addAll(fileFilters);
+    }
+
+    /**
+     * Provide a String representation of this file filter.
+     *
+     * @return a String representation
+     */
+    @Override
+    public String toString() {
+        final StringBuilder buffer = new StringBuilder();
+        buffer.append(super.toString());
+        buffer.append("(");
+        append(fileFilters, buffer);
+        buffer.append(")");
+        return buffer.toString();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/CanExecuteFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/CanExecuteFileFilter.java
new file mode 100644
index 0000000..96e8b31
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/CanExecuteFileFilter.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+
+/**
+ * This filter accepts {@link File}s that can be executed.
+ * <p>
+ * Example, showing how to print out a list of the
+ * current directory's <i>executable</i> files:
+ * </p>
+ * <h2>Using Classic IO</h2>
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(CanExecuteFileFilter.CAN_EXECUTE);
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <p>
+ * Example, showing how to print out a list of the
+ * current directory's <i>non-executable</i> files:
+ * </p>
+ *
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(CanExecuteFileFilter.CANNOT_EXECUTE);
+ * for (int i = 0; i &lt; files.length; i++) {
+ *     System.out.println(files[i]);
+ * }
+ * </pre>
+ *
+ * @since 2.7
+ */
+public class CanExecuteFileFilter extends AbstractFileFilter implements Serializable {
+
+    /** Singleton instance of <i>executable</i> filter */
+    public static final IOFileFilter CAN_EXECUTE = new CanExecuteFileFilter();
+
+    /** Singleton instance of not <i>executable</i> filter */
+    public static final IOFileFilter CANNOT_EXECUTE = CAN_EXECUTE.negate();
+
+    private static final long serialVersionUID = 3179904805251622989L;
+
+    /**
+     * Restrictive constructor.
+     */
+    protected CanExecuteFileFilter() {
+        // empty.
+    }
+
+    /**
+     * Checks to see if the file can be executed.
+     *
+     * @param file  the File to check.
+     * @return {@code true} if the file can be executed, otherwise {@code false}.
+     */
+    @Override
+    public boolean accept(final File file) {
+        return file.canExecute();
+    }
+
+    /**
+     * Checks to see if the file can be executed.
+     * @param file  the File to check.
+     *
+     * @return {@code true} if the file can be executed, otherwise {@code false}.
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return toFileVisitResult(Files.isExecutable(file));
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/CanReadFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/CanReadFileFilter.java
new file mode 100644
index 0000000..a30dfae
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/CanReadFileFilter.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+
+/**
+ * This filter accepts {@link File}s that can be read.
+ * <p>
+ * Example, showing how to print out a list of the current directory's <i>readable</i> files:
+ * </p>
+ * <h2>Using Classic IO</h2>
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(CanReadFileFilter.CAN_READ);
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <p>
+ * Example, showing how to print out a list of the current directory's <i>un-readable</i> files:
+ *
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(CanReadFileFilter.CANNOT_READ);
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <p>
+ * Example, showing how to print out a list of the current directory's <i>read-only</i> files:
+ *
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(CanReadFileFilter.READ_ONLY);
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * @since 1.3
+ */
+public class CanReadFileFilter extends AbstractFileFilter implements Serializable {
+
+    /** Singleton instance of <i>readable</i> filter */
+    public static final IOFileFilter CAN_READ = new CanReadFileFilter();
+
+    /** Singleton instance of not <i>readable</i> filter */
+    public static final IOFileFilter CANNOT_READ = CAN_READ.negate();
+
+    /** Singleton instance of <i>read-only</i> filter */
+    public static final IOFileFilter READ_ONLY = CAN_READ.and(CanWriteFileFilter.CANNOT_WRITE);
+
+    private static final long serialVersionUID = 3179904805251622989L;
+
+    /**
+     * Restrictive constructor.
+     */
+    protected CanReadFileFilter() {
+    }
+
+    /**
+     * Checks to see if the file can be read.
+     *
+     * @param file the File to check.
+     * @return {@code true} if the file can be read, otherwise {@code false}.
+     */
+    @Override
+    public boolean accept(final File file) {
+        return file.canRead();
+    }
+
+    /**
+     * Checks to see if the file can be read.
+     * @param file the File to check.
+     *
+     * @return {@code true} if the file can be read, otherwise {@code false}.
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return toFileVisitResult(Files.isReadable(file));
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/CanWriteFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/CanWriteFileFilter.java
new file mode 100644
index 0000000..d558027
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/CanWriteFileFilter.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+
+/**
+ * This filter accepts {@link File}s that can be written to.
+ * <p>
+ * Example, showing how to print out a list of the current directory's <i>writable</i> files:
+ * </p>
+ * <h2>Using Classic IO</h2>
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(CanWriteFileFilter.CAN_WRITE);
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <p>
+ * Example, showing how to print out a list of the current directory's <i>un-writable</i> files:
+ *
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(CanWriteFileFilter.CANNOT_WRITE);
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <p>
+ * <b>N.B.</b> For read-only files, use {@code CanReadFileFilter.READ_ONLY}.
+ *
+ * @since 1.3
+ */
+public class CanWriteFileFilter extends AbstractFileFilter implements Serializable {
+
+    /** Singleton instance of <i>writable</i> filter */
+    public static final IOFileFilter CAN_WRITE = new CanWriteFileFilter();
+
+    /** Singleton instance of not <i>writable</i> filter */
+    public static final IOFileFilter CANNOT_WRITE = CAN_WRITE.negate();
+
+    private static final long serialVersionUID = 5132005214688990379L;
+
+    /**
+     * Restrictive constructor.
+     */
+    protected CanWriteFileFilter() {
+    }
+
+    /**
+     * Checks to see if the file can be written to.
+     *
+     * @param file the File to check
+     * @return {@code true} if the file can be written to, otherwise {@code false}.
+     */
+    @Override
+    public boolean accept(final File file) {
+        return file.canWrite();
+    }
+
+    /**
+     * Checks to see if the file can be written to.
+     * @param file the File to check
+     *
+     * @return {@code true} if the file can be written to, otherwise {@code false}.
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return toFileVisitResult(Files.isWritable(file));
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/ConditionalFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/ConditionalFileFilter.java
new file mode 100644
index 0000000..c51d47e
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/ConditionalFileFilter.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.util.List;
+
+/**
+ * Defines operations for conditional file filters.
+ *
+ * @since 1.1
+ */
+public interface ConditionalFileFilter {
+
+    /**
+     * Adds the specified file filter to the list of file filters at the end of
+     * the list.
+     *
+     * @param ioFileFilter the filter to be added
+     * @since 1.1
+     */
+    void addFileFilter(IOFileFilter ioFileFilter);
+
+    /**
+     * Gets this conditional file filter's list of file filters.
+     *
+     * @return the file filter list
+     * @since 1.1
+     */
+    List<IOFileFilter> getFileFilters();
+
+    /**
+     * Removes the specified file filter.
+     *
+     * @param ioFileFilter filter to be removed
+     * @return {@code true} if the filter was found in the list,
+     * {@code false} otherwise
+     * @since 1.1
+     */
+    boolean removeFileFilter(IOFileFilter ioFileFilter);
+
+    /**
+     * Sets the list of file filters, replacing any previously configured
+     * file filters on this filter.
+     *
+     * @param fileFilters the list of filters
+     * @since 1.1
+     */
+    void setFileFilters(List<IOFileFilter> fileFilters);
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/DelegateFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/DelegateFileFilter.java
new file mode 100644
index 0000000..edc15d7
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/DelegateFileFilter.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.FilenameFilter;
+import java.io.Serializable;
+import java.util.Objects;
+
+/**
+ * This class turns a Java FileFilter or FilenameFilter into an IO FileFilter.
+ *
+ * @since 1.0
+ * @see FileFilterUtils#asFileFilter(FileFilter)
+ * @see FileFilterUtils#asFileFilter(FilenameFilter)
+ */
+public class DelegateFileFilter extends AbstractFileFilter implements Serializable {
+
+    private static final long serialVersionUID = -8723373124984771318L;
+    /** The File filter */
+    private final FileFilter fileFilter;
+    /** The Filename filter */
+    private final FilenameFilter filenameFilter;
+
+    /**
+     * Constructs a delegate file filter around an existing FileFilter.
+     *
+     * @param fileFilter  the filter to decorate
+     */
+    public DelegateFileFilter(final FileFilter fileFilter) {
+        Objects.requireNonNull(fileFilter, "filter");
+        this.fileFilter = fileFilter;
+        this.filenameFilter = null;
+    }
+
+    /**
+     * Constructs a delegate file filter around an existing FilenameFilter.
+     *
+     * @param filenameFilter  the filter to decorate
+     */
+    public DelegateFileFilter(final FilenameFilter filenameFilter) {
+        Objects.requireNonNull(filenameFilter, "filter");
+        this.filenameFilter = filenameFilter;
+        this.fileFilter = null;
+    }
+
+    /**
+     * Checks the filter.
+     *
+     * @param file  the file to check
+     * @return true if the filter matches
+     */
+    @Override
+    public boolean accept(final File file) {
+        if (fileFilter != null) {
+            return fileFilter.accept(file);
+        }
+        return super.accept(file);
+    }
+
+    /**
+     * Checks the filter.
+     *
+     * @param dir  the directory
+     * @param name  the file name in the directory
+     * @return true if the filter matches
+     */
+    @Override
+    public boolean accept(final File dir, final String name) {
+        if (filenameFilter != null) {
+            return filenameFilter.accept(dir, name);
+        }
+        return super.accept(dir, name);
+    }
+
+    /**
+     * Provide a String representation of this file filter.
+     *
+     * @return a String representation
+     */
+    @Override
+    public String toString() {
+        final String delegate = fileFilter != null ? fileFilter.toString() : filenameFilter.toString();
+        return super.toString() + "(" + delegate + ")";
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/DirectoryFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/DirectoryFileFilter.java
new file mode 100644
index 0000000..1a5e2fa
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/DirectoryFileFilter.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+
+/**
+ * This filter accepts {@link File}s that are directories.
+ * <p>
+ * For example, here is how to print out a list of the current directory's subdirectories:
+ * </p>
+ * <h2>Using Classic IO</h2>
+ *
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(DirectoryFileFilter.INSTANCE);
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <h2>Using NIO</h2>
+ *
+ * <pre>
+ * final Path dir = PathUtils.current();
+ * final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(DirectoryFileFilter.INSTANCE);
+ * //
+ * // Walk one dir
+ * Files.<b>walkFileTree</b>(dir, Collections.emptySet(), 1, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getFileList());
+ * //
+ * visitor.getPathCounters().reset();
+ * //
+ * // Walk dir tree
+ * Files.<b>walkFileTree</b>(dir, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getDirList());
+ * System.out.println(visitor.getFileList());
+ * </pre>
+ *
+ * @since 1.0
+ * @see FileFilterUtils#directoryFileFilter()
+ */
+public class DirectoryFileFilter extends AbstractFileFilter implements Serializable {
+
+    /**
+     * Singleton instance of directory filter.
+     *
+     * @since 1.3
+     */
+    public static final IOFileFilter DIRECTORY = new DirectoryFileFilter();
+
+    /**
+     * Singleton instance of directory filter. Please use the identical DirectoryFileFilter.DIRECTORY constant. The new
+     * name is more JDK 1.5 friendly as it doesn't clash with other values when using static imports.
+     */
+    public static final IOFileFilter INSTANCE = DIRECTORY;
+
+    private static final long serialVersionUID = -5148237843784525732L;
+
+    /**
+     * Restrictive constructor.
+     */
+    protected DirectoryFileFilter() {
+        // empty.
+    }
+
+    /**
+     * Checks to see if the file is a directory.
+     *
+     * @param file the File to check
+     * @return true if the file is a directory
+     */
+    @Override
+    public boolean accept(final File file) {
+        return file.isDirectory();
+    }
+
+    /**
+     * Checks to see if the file is a directory.
+     * @param file the File to check
+     *
+     * @return true if the file is a directory
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return toFileVisitResult(Files.isDirectory(file));
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/EmptyFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/EmptyFileFilter.java
new file mode 100644
index 0000000..ec93674
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/EmptyFileFilter.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.IOUtils;
+
+/**
+ * This filter accepts files or directories that are empty.
+ * <p>
+ * If the {@link File} is a directory it checks that it contains no files.
+ * </p>
+ * <p>
+ * Example, showing how to print out a list of the current directory's empty files/directories:
+ * </p>
+ * <h2>Using Classic IO</h2>
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(EmptyFileFilter.EMPTY);
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <p>
+ * Example, showing how to print out a list of the current directory's non-empty files/directories:
+ * </p>
+ *
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(EmptyFileFilter.NOT_EMPTY);
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <h2>Using NIO</h2>
+ * <pre>
+ * final Path dir = PathUtils.current();
+ * final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(EmptyFileFilter.EMPTY);
+ * //
+ * // Walk one dir
+ * Files.<b>walkFileTree</b>(dir, Collections.emptySet(), 1, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getFileList());
+ * //
+ * visitor.getPathCounters().reset();
+ * //
+ * // Walk dir tree
+ * Files.<b>walkFileTree</b>(dir, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getDirList());
+ * System.out.println(visitor.getFileList());
+ * </pre>
+ *
+ * @since 1.3
+ */
+public class EmptyFileFilter extends AbstractFileFilter implements Serializable {
+
+    /** Singleton instance of <i>empty</i> filter */
+    public static final IOFileFilter EMPTY = new EmptyFileFilter();
+
+    /** Singleton instance of <i>not-empty</i> filter */
+    public static final IOFileFilter NOT_EMPTY = EMPTY.negate();
+
+    private static final long serialVersionUID = 3631422087512832211L;
+
+    /**
+     * Restrictive constructor.
+     */
+    protected EmptyFileFilter() {
+    }
+
+    /**
+     * Checks to see if the file is empty.
+     *
+     * @param file the file or directory to check
+     * @return {@code true} if the file or directory is <i>empty</i>, otherwise {@code false}.
+     */
+    @Override
+    public boolean accept(final File file) {
+        if (file.isDirectory()) {
+            final File[] files = file.listFiles();
+            return IOUtils.length(files) == 0;
+        }
+        return file.length() == 0;
+    }
+
+    /**
+     * Checks to see if the file is empty.
+     * @param file the file or directory to check
+     *
+     * @return {@code true} if the file or directory is <i>empty</i>, otherwise {@code false}.
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return get(() -> {
+            if (Files.isDirectory(file)) {
+                try (Stream<Path> stream = Files.list(file)) {
+                    return toFileVisitResult(!stream.findFirst().isPresent());
+                }
+            }
+            return toFileVisitResult(Files.size(file) == 0);
+        });
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/FalseFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/FalseFileFilter.java
new file mode 100644
index 0000000..8119f53
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/FalseFileFilter.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+
+/**
+ * A file filter that always returns false.
+ *
+ * @since 1.0
+ * @see FileFilterUtils#falseFileFilter()
+ */
+public class FalseFileFilter implements IOFileFilter, Serializable {
+
+    private static final String TO_STRING = Boolean.FALSE.toString();
+
+    /**
+     * Singleton instance of false filter.
+     *
+     * @since 1.3
+     */
+    public static final IOFileFilter FALSE = new FalseFileFilter();
+
+    /**
+     * Singleton instance of false filter. Please use the identical FalseFileFilter.FALSE constant. The new name is more
+     * JDK 1.5 friendly as it doesn't clash with other values when using static imports.
+     */
+    public static final IOFileFilter INSTANCE = FALSE;
+
+    private static final long serialVersionUID = 6210271677940926200L;
+
+    /**
+     * Restrictive constructor.
+     */
+    protected FalseFileFilter() {
+    }
+
+    /**
+     * Returns false.
+     *
+     * @param file the file to check (ignored)
+     * @return false
+     */
+    @Override
+    public boolean accept(final File file) {
+        return false;
+    }
+
+    /**
+     * Returns false.
+     *
+     * @param dir the directory to check (ignored)
+     * @param name the file name (ignored)
+     * @return false
+     */
+    @Override
+    public boolean accept(final File dir, final String name) {
+        return false;
+    }
+
+    /**
+     * Returns false.
+     *
+     * @param file the file to check (ignored)
+     *
+     * @return false
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return FileVisitResult.TERMINATE;
+    }
+
+    @Override
+    public IOFileFilter and(final IOFileFilter fileFilter) {
+        // FALSE AND expression <=> FALSE
+        return INSTANCE;
+    }
+
+    @Override
+    public IOFileFilter negate() {
+        return TrueFileFilter.INSTANCE;
+    }
+
+    @Override
+    public IOFileFilter or(final IOFileFilter fileFilter) {
+        // FALSE OR expression <=> expression
+        return fileFilter;
+    }
+
+    @Override
+    public String toString() {
+        return TO_STRING;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/FileEqualsFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/FileEqualsFileFilter.java
new file mode 100644
index 0000000..7f776f6
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/FileEqualsFileFilter.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Objects;
+
+/**
+ * Accepts only an exact {@link File} object match. You can use this filter to visit the start directory when walking a
+ * file tree with
+ * {@link java.nio.file.Files#walkFileTree(java.nio.file.Path, java.util.Set, int, java.nio.file.FileVisitor)}.
+ *
+ * @since 2.9.0
+ */
+public class FileEqualsFileFilter extends AbstractFileFilter {
+
+    private final File file;
+    private final Path path;
+
+    /**
+     * Constructs a new instance for the given {@link File}.
+     *
+     * @param file The file to match.
+     */
+    public FileEqualsFileFilter(final File file) {
+        this.file = Objects.requireNonNull(file, "file");
+        this.path = file.toPath();
+    }
+
+    @Override
+    public boolean accept(final File file) {
+        return Objects.equals(this.file, file);
+    }
+
+    @Override
+    public FileVisitResult accept(final Path path, final BasicFileAttributes attributes) {
+        return toFileVisitResult(Objects.equals(this.path, path));
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/FileFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/FileFileFilter.java
new file mode 100644
index 0000000..9cadb64
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/FileFileFilter.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+
+/**
+ * This filter accepts {@link File}s that are files (not directories).
+ * <p>
+ * For example, here is how to print out a list of the real files
+ * within the current directory:
+ * </p>
+ * <h2>Using Classic IO</h2>
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(FileFileFilter.INSTANCE);
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <h2>Using NIO</h2>
+ * <pre>
+ * final Path dir = PathUtils.current();
+ * final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(FileFileFilter.INSTANCE);
+ * //
+ * // Walk one dir
+ * Files.<b>walkFileTree</b>(dir, Collections.emptySet(), 1, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getFileList());
+ * //
+ * visitor.getPathCounters().reset();
+ * //
+ * // Walk dir tree
+ * Files.<b>walkFileTree</b>(dir, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getDirList());
+ * System.out.println(visitor.getFileList());
+ * </pre>
+ *
+ * @since 1.3
+ * @see FileFilterUtils#fileFileFilter()
+ */
+public class FileFileFilter extends AbstractFileFilter implements Serializable {
+
+    /**
+     * Singleton instance of file filter.
+     *
+     * @since 2.9.0
+     */
+    public static final IOFileFilter INSTANCE = new FileFileFilter();
+
+    /**
+     * Singleton instance of file filter.
+     *
+     * @deprecated Use {@link #INSTANCE}.
+     */
+    @Deprecated
+    public static final IOFileFilter FILE = INSTANCE;
+
+    private static final long serialVersionUID = 5345244090827540862L;
+
+    /**
+     * Restrictive constructor.
+     */
+    protected FileFileFilter() {
+    }
+
+    /**
+     * Checks to see if the file is a file.
+     *
+     * @param file  the File to check
+     * @return true if the file is a file
+     */
+    @Override
+    public boolean accept(final File file) {
+        return file.isFile();
+    }
+
+    /**
+     * Checks to see if the file is a file.
+     * @param file  the File to check
+     *
+     * @return true if the file is a file
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return toFileVisitResult(Files.isRegularFile(file));
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/FileFilterUtils.java b/src/main/java/org/apache/commons/io/filefilter/FileFilterUtils.java
new file mode 100644
index 0000000..c1c205e
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/FileFilterUtils.java
@@ -0,0 +1,744 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.FilenameFilter;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collector;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.IOCase;
+
+/**
+ * Useful utilities for working with file filters. It provides access to most
+ * file filter implementations in this package so you don't have to import
+ * every class you use.
+ *
+ * @since 1.0
+ */
+public class FileFilterUtils {
+
+    /* Constructed on demand and then cached */
+    private static final IOFileFilter CVS_FILTER = notFileFilter(
+            and(directoryFileFilter(), nameFileFilter("CVS")));
+
+
+    /* Constructed on demand and then cached */
+    private static final IOFileFilter SVN_FILTER = notFileFilter(
+            and(directoryFileFilter(), nameFileFilter(".svn")));
+
+    /**
+     * Returns a filter that returns true if the file was last modified before
+     * or at the specified cutoff date.
+     *
+     * @param cutoffDate  the time threshold
+     * @return an appropriately configured age file filter
+     * @see AgeFileFilter
+     * @since 1.2
+     */
+    public static IOFileFilter ageFileFilter(final Date cutoffDate) {
+        return new AgeFileFilter(cutoffDate);
+    }
+
+    /**
+     * Returns a filter that filters files based on a cutoff date.
+     *
+     * @param cutoffDate  the time threshold
+     * @param acceptOlder  if true, older files get accepted, if false, newer
+     * @return an appropriately configured age file filter
+     * @see AgeFileFilter
+     * @since 1.2
+     */
+    public static IOFileFilter ageFileFilter(final Date cutoffDate, final boolean acceptOlder) {
+        return new AgeFileFilter(cutoffDate, acceptOlder);
+    }
+
+    /**
+     * Returns a filter that returns true if the file was last modified before
+     * or at the same time as the specified reference file.
+     *
+     * @param cutoffReference  the file whose last modification
+     *        time is used as the threshold age of the files
+     * @return an appropriately configured age file filter
+     * @see AgeFileFilter
+     * @since 1.2
+     */
+    public static IOFileFilter ageFileFilter(final File cutoffReference) {
+        return new AgeFileFilter(cutoffReference);
+    }
+
+    /**
+     * Returns a filter that filters files based on a cutoff reference file.
+     *
+     * @param cutoffReference  the file whose last modification
+     *        time is used as the threshold age of the files
+     * @param acceptOlder  if true, older files get accepted, if false, newer
+     * @return an appropriately configured age file filter
+     * @see AgeFileFilter
+     * @since 1.2
+     */
+    public static IOFileFilter ageFileFilter(final File cutoffReference, final boolean acceptOlder) {
+        return new AgeFileFilter(cutoffReference, acceptOlder);
+    }
+
+    /**
+     * Returns a filter that returns true if the file was last modified before
+     * or at the specified cutoff time.
+     *
+     * @param cutoffMillis  the time threshold
+     * @return an appropriately configured age file filter
+     * @see AgeFileFilter
+     * @since 1.2
+     */
+    public static IOFileFilter ageFileFilter(final long cutoffMillis) {
+        return new AgeFileFilter(cutoffMillis);
+    }
+
+    /**
+     * Returns a filter that filters files based on a cutoff time.
+     *
+     * @param cutoffMillis  the time threshold
+     * @param acceptOlder  if true, older files get accepted, if false, newer
+     * @return an appropriately configured age file filter
+     * @see AgeFileFilter
+     * @since 1.2
+     */
+    public static IOFileFilter ageFileFilter(final long cutoffMillis, final boolean acceptOlder) {
+        return new AgeFileFilter(cutoffMillis, acceptOlder);
+    }
+
+    /**
+     * Returns a filter that ANDs the specified filters.
+     *
+     * @param filters the IOFileFilters that will be ANDed together.
+     * @return a filter that ANDs the specified filters
+     *
+     * @throws IllegalArgumentException if the filters are null or contain a
+     *         null value.
+     * @see AndFileFilter
+     * @since 2.0
+     */
+    public static IOFileFilter and(final IOFileFilter... filters) {
+        return new AndFileFilter(toList(filters));
+    }
+
+    /**
+     * Returns a filter that ANDs the two specified filters.
+     *
+     * @param filter1  the first filter
+     * @param filter2  the second filter
+     * @return a filter that ANDs the two specified filters
+     * @see #and(IOFileFilter...)
+     * @see AndFileFilter
+     * @deprecated use {@link #and(IOFileFilter...)}
+     */
+    @Deprecated
+    public static IOFileFilter andFileFilter(final IOFileFilter filter1, final IOFileFilter filter2) {
+        return new AndFileFilter(filter1, filter2);
+    }
+
+    /**
+     * Returns an {@link IOFileFilter} that wraps the
+     * {@link FileFilter} instance.
+     *
+     * @param filter  the filter to be wrapped
+     * @return a new filter that implements IOFileFilter
+     * @see DelegateFileFilter
+     */
+    public static IOFileFilter asFileFilter(final FileFilter filter) {
+        return new DelegateFileFilter(filter);
+    }
+
+    /**
+     * Returns an {@link IOFileFilter} that wraps the
+     * {@link FilenameFilter} instance.
+     *
+     * @param filter  the filter to be wrapped
+     * @return a new filter that implements IOFileFilter
+     * @see DelegateFileFilter
+     */
+    public static IOFileFilter asFileFilter(final FilenameFilter filter) {
+        return new DelegateFileFilter(filter);
+    }
+
+    /**
+     * Returns a filter that checks if the file is a directory.
+     *
+     * @return file filter that accepts only directories and not files
+     * @see DirectoryFileFilter#DIRECTORY
+     */
+    public static IOFileFilter directoryFileFilter() {
+        return DirectoryFileFilter.DIRECTORY;
+    }
+
+    /**
+     * Returns a filter that always returns false.
+     *
+     * @return a false filter
+     * @see FalseFileFilter#FALSE
+     */
+    public static IOFileFilter falseFileFilter() {
+        return FalseFileFilter.FALSE;
+    }
+
+    /**
+     * Returns a filter that checks if the file is a file (and not a directory).
+     *
+     * @return file filter that accepts only files and not directories
+     * @see FileFileFilter#INSTANCE
+     */
+    public static IOFileFilter fileFileFilter() {
+        return FileFileFilter.INSTANCE;
+    }
+
+    /**
+     * <p>
+     * Applies an {@link IOFileFilter} to the provided {@link File}
+     * objects. The resulting array is a subset of the original file list that
+     * matches the provided filter.
+     * </p>
+     *
+     * <pre>
+     * Set&lt;File&gt; allFiles = ...
+     * Set&lt;File&gt; javaFiles = FileFilterUtils.filterSet(allFiles,
+     *     FileFilterUtils.suffixFileFilter(".java"));
+     * </pre>
+     * @param filter the filter to apply to the set of files.
+     * @param files the array of files to apply the filter to.
+     *
+     * @return a subset of {@code files} that is accepted by the
+     *         file filter.
+     * @throws NullPointerException if the filter is {@code null}
+     *         or {@code files} contains a {@code null} value.
+     *
+     * @since 2.0
+     */
+    public static File[] filter(final IOFileFilter filter, final File... files) {
+        Objects.requireNonNull(filter, "filter");
+        if (files == null) {
+            return FileUtils.EMPTY_FILE_ARRAY;
+        }
+        return filterFiles(filter, Stream.of(files), Collectors.toList()).toArray(FileUtils.EMPTY_FILE_ARRAY);
+    }
+
+    /**
+     * <p>
+     * Applies an {@link IOFileFilter} to the provided {@link File}
+     * objects. The resulting array is a subset of the original file list that
+     * matches the provided filter.
+     * </p>
+     *
+     * <p>
+     * The {@link Set} returned by this method is not guaranteed to be thread safe.
+     * </p>
+     *
+     * <pre>
+     * Set&lt;File&gt; allFiles = ...
+     * Set&lt;File&gt; javaFiles = FileFilterUtils.filterSet(allFiles,
+     *     FileFilterUtils.suffixFileFilter(".java"));
+     * </pre>
+     * @param filter the filter to apply to the set of files.
+     * @param files the array of files to apply the filter to.
+     *
+     * @return a subset of {@code files} that is accepted by the
+     *         file filter.
+     * @throws IllegalArgumentException if the filter is {@code null}
+     *         or {@code files} contains a {@code null} value.
+     *
+     * @since 2.0
+     */
+    public static File[] filter(final IOFileFilter filter, final Iterable<File> files) {
+        return filterList(filter, files).toArray(FileUtils.EMPTY_FILE_ARRAY);
+    }
+
+    /**
+     * <p>
+     * Applies an {@link IOFileFilter} to the provided {@link File} stream and collects the accepted files.
+     * </p>
+     *
+     * @param filter the filter to apply to the stream of files.
+     * @param stream the stream of files on which to apply the filter.
+     * @param collector how to collect the end result.
+     *
+     * @param <R> the return type.
+     * @param <A> the mutable accumulation type of the reduction operation (often hidden as an implementation detail)
+     * @return a subset of files from the stream that is accepted by the filter.
+     * @throws NullPointerException if the filter is {@code null}.
+     */
+    private static <R, A> R filterFiles(final IOFileFilter filter, final Stream<File> stream,
+        final Collector<? super File, A, R> collector) {
+        Objects.requireNonNull(filter, "filter");
+        Objects.requireNonNull(collector, "collector");
+        if (stream == null) {
+            return Stream.<File>empty().collect(collector);
+        }
+        return stream.filter(filter::accept).collect(collector);
+    }
+
+    /**
+     * <p>
+     * Applies an {@link IOFileFilter} to the provided {@link File}
+     * objects. The resulting list is a subset of the original files that
+     * matches the provided filter.
+     * </p>
+     *
+     * <p>
+     * The {@link List} returned by this method is not guaranteed to be thread safe.
+     * </p>
+     *
+     * <pre>
+     * List&lt;File&gt; filesAndDirectories = ...
+     * List&lt;File&gt; directories = FileFilterUtils.filterList(filesAndDirectories,
+     *     FileFilterUtils.directoryFileFilter());
+     * </pre>
+     * @param filter the filter to apply to each files in the list.
+     * @param files the collection of files to apply the filter to.
+     *
+     * @return a subset of {@code files} that is accepted by the
+     *         file filter.
+     * @throws IllegalArgumentException if the filter is {@code null}
+     *         or {@code files} contains a {@code null} value.
+     * @since 2.0
+     */
+    public static List<File> filterList(final IOFileFilter filter, final File... files) {
+        return Arrays.asList(filter(filter, files));
+    }
+
+    /**
+     * <p>
+     * Applies an {@link IOFileFilter} to the provided {@link File}
+     * objects. The resulting list is a subset of the original files that
+     * matches the provided filter.
+     * </p>
+     *
+     * <p>
+     * The {@link List} returned by this method is not guaranteed to be thread safe.
+     * </p>
+     *
+     * <pre>
+     * List&lt;File&gt; filesAndDirectories = ...
+     * List&lt;File&gt; directories = FileFilterUtils.filterList(filesAndDirectories,
+     *     FileFilterUtils.directoryFileFilter());
+     * </pre>
+     * @param filter the filter to apply to each files in the list.
+     * @param files the collection of files to apply the filter to.
+     *
+     * @return a subset of {@code files} that is accepted by the
+     *         file filter.
+     * @throws IllegalArgumentException if the filter is {@code null}
+     * @since 2.0
+     */
+    public static List<File> filterList(final IOFileFilter filter, final Iterable<File> files) {
+        if (files == null) {
+            return Collections.emptyList();
+        }
+        return filterFiles(filter, StreamSupport.stream(files.spliterator(), false), Collectors.toList());
+    }
+
+    /**
+     * <p>
+     * Applies an {@link IOFileFilter} to the provided {@link File}
+     * objects. The resulting set is a subset of the original file list that
+     * matches the provided filter.
+     * </p>
+     *
+     * <p>
+     * The {@link Set} returned by this method is not guaranteed to be thread safe.
+     * </p>
+     *
+     * <pre>
+     * Set&lt;File&gt; allFiles = ...
+     * Set&lt;File&gt; javaFiles = FileFilterUtils.filterSet(allFiles,
+     *     FileFilterUtils.suffixFileFilter(".java"));
+     * </pre>
+     * @param filter the filter to apply to the set of files.
+     * @param files the collection of files to apply the filter to.
+     *
+     * @return a subset of {@code files} that is accepted by the
+     *         file filter.
+     * @throws IllegalArgumentException if the filter is {@code null}
+     *         or {@code files} contains a {@code null} value.
+     *
+     * @since 2.0
+     */
+    public static Set<File> filterSet(final IOFileFilter filter, final File... files) {
+        return new HashSet<>(Arrays.asList(filter(filter, files)));
+    }
+
+    /**
+     * <p>
+     * Applies an {@link IOFileFilter} to the provided {@link File}
+     * objects. The resulting set is a subset of the original file list that
+     * matches the provided filter.
+     * </p>
+     *
+     * <p>
+     * The {@link Set} returned by this method is not guaranteed to be thread safe.
+     * </p>
+     *
+     * <pre>
+     * Set&lt;File&gt; allFiles = ...
+     * Set&lt;File&gt; javaFiles = FileFilterUtils.filterSet(allFiles,
+     *     FileFilterUtils.suffixFileFilter(".java"));
+     * </pre>
+     * @param filter the filter to apply to the set of files.
+     * @param files the collection of files to apply the filter to.
+     *
+     * @return a subset of {@code files} that is accepted by the
+     *         file filter.
+     * @throws IllegalArgumentException if the filter is {@code null}
+     *
+     * @since 2.0
+     */
+    public static Set<File> filterSet(final IOFileFilter filter, final Iterable<File> files) {
+        if (files == null) {
+            return Collections.emptySet();
+        }
+        return filterFiles(filter, StreamSupport.stream(files.spliterator(), false), Collectors.toSet());
+    }
+
+    /**
+     * Returns a filter that accepts files that begin with the provided magic
+     * number.
+     *
+     * @param magicNumber the magic number (byte sequence) to match at the
+     *        beginning of each file.
+     *
+     * @return an IOFileFilter that accepts files beginning with the provided
+     *         magic number.
+     *
+     * @throws IllegalArgumentException if {@code magicNumber} is
+     *         {@code null} or is of length zero.
+     * @see MagicNumberFileFilter
+     * @since 2.0
+     */
+    public static IOFileFilter magicNumberFileFilter(final byte[] magicNumber) {
+        return new MagicNumberFileFilter(magicNumber);
+    }
+
+    /**
+     * Returns a filter that accepts files that contains the provided magic
+     * number at a specified offset within the file.
+     *
+     * @param magicNumber the magic number (byte sequence) to match at the
+     *        provided offset in each file.
+     * @param offset the offset within the files to look for the magic number.
+     *
+     * @return an IOFileFilter that accepts files containing the magic number
+     *         at the specified offset.
+     *
+     * @throws IllegalArgumentException if {@code magicNumber} is
+     *         {@code null}, or contains no bytes, or {@code offset}
+     *         is a negative number.
+     * @see MagicNumberFileFilter
+     * @since 2.0
+     */
+    public static IOFileFilter magicNumberFileFilter(final byte[] magicNumber, final long offset) {
+        return new MagicNumberFileFilter(magicNumber, offset);
+    }
+
+    /**
+     * Returns a filter that accepts files that begin with the provided magic
+     * number.
+     *
+     * @param magicNumber the magic number (byte sequence) to match at the
+     *        beginning of each file.
+     *
+     * @return an IOFileFilter that accepts files beginning with the provided
+     *         magic number.
+     *
+     * @throws IllegalArgumentException if {@code magicNumber} is
+     *         {@code null} or the empty String.
+     * @see MagicNumberFileFilter
+     * @since 2.0
+     */
+    public static IOFileFilter magicNumberFileFilter(final String magicNumber) {
+        return new MagicNumberFileFilter(magicNumber);
+    }
+
+    /**
+     * Returns a filter that accepts files that contains the provided magic
+     * number at a specified offset within the file.
+     *
+     * @param magicNumber the magic number (byte sequence) to match at the
+     *        provided offset in each file.
+     * @param offset the offset within the files to look for the magic number.
+     *
+     * @return an IOFileFilter that accepts files containing the magic number
+     *         at the specified offset.
+     *
+     * @throws IllegalArgumentException if {@code magicNumber} is
+     *         {@code null} or the empty String, or if offset is a
+     *         negative number.
+     * @see MagicNumberFileFilter
+     * @since 2.0
+     */
+    public static IOFileFilter magicNumberFileFilter(final String magicNumber, final long offset) {
+        return new MagicNumberFileFilter(magicNumber, offset);
+    }
+
+    /**
+     * Decorates a filter to make it ignore CVS directories.
+     * Passing in {@code null} will return a filter that accepts everything
+     * except CVS directories.
+     *
+     * @param filter  the filter to decorate, null means an unrestricted filter
+     * @return the decorated filter, never null
+     * @since 1.1 (method existed but had a bug in 1.0)
+     */
+    public static IOFileFilter makeCVSAware(final IOFileFilter filter) {
+        return filter == null ? CVS_FILTER : and(filter, CVS_FILTER);
+    }
+
+    /**
+     * Decorates a filter so that it only applies to directories and not to files.
+     *
+     * @param filter  the filter to decorate, null means an unrestricted filter
+     * @return the decorated filter, never null
+     * @see DirectoryFileFilter#DIRECTORY
+     * @since 1.3
+     */
+    public static IOFileFilter makeDirectoryOnly(final IOFileFilter filter) {
+        if (filter == null) {
+            return DirectoryFileFilter.DIRECTORY;
+        }
+        return DirectoryFileFilter.DIRECTORY.and(filter);
+    }
+
+    /**
+     * Decorates a filter so that it only applies to files and not to directories.
+     *
+     * @param filter  the filter to decorate, null means an unrestricted filter
+     * @return the decorated filter, never null
+     * @see FileFileFilter#INSTANCE
+     * @since 1.3
+     */
+    public static IOFileFilter makeFileOnly(final IOFileFilter filter) {
+        if (filter == null) {
+            return FileFileFilter.INSTANCE;
+        }
+        return FileFileFilter.INSTANCE.and(filter);
+    }
+
+    /**
+     * Decorates a filter to make it ignore SVN directories.
+     * Passing in {@code null} will return a filter that accepts everything
+     * except SVN directories.
+     *
+     * @param filter  the filter to decorate, null means an unrestricted filter
+     * @return the decorated filter, never null
+     * @since 1.1
+     */
+    public static IOFileFilter makeSVNAware(final IOFileFilter filter) {
+        return filter == null ? SVN_FILTER : and(filter, SVN_FILTER);
+    }
+
+    /**
+     * Returns a filter that returns true if the file name matches the specified text.
+     *
+     * @param name  the file name
+     * @return a name checking filter
+     * @see NameFileFilter
+     */
+    public static IOFileFilter nameFileFilter(final String name) {
+        return new NameFileFilter(name);
+    }
+
+    /**
+     * Returns a filter that returns true if the file name matches the specified text.
+     *
+     * @param name  the file name
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     * @return a name checking filter
+     * @see NameFileFilter
+     * @since 2.0
+     */
+    public static IOFileFilter nameFileFilter(final String name, final IOCase ioCase) {
+        return new NameFileFilter(name, ioCase);
+    }
+
+    /**
+     * Returns a filter that NOTs the specified filter.
+     *
+     * @param filter  the filter to invert
+     * @return a filter that NOTs the specified filter
+     * @see NotFileFilter
+     */
+    public static IOFileFilter notFileFilter(final IOFileFilter filter) {
+        return filter.negate();
+    }
+
+    /**
+     * Returns a filter that ORs the specified filters.
+     *
+     * @param filters the IOFileFilters that will be ORed together.
+     * @return a filter that ORs the specified filters
+     *
+     * @throws IllegalArgumentException if the filters are null or contain a
+     *         null value.
+     * @see OrFileFilter
+     * @since 2.0
+     */
+    public static IOFileFilter or(final IOFileFilter... filters) {
+        return new OrFileFilter(toList(filters));
+    }
+
+    /**
+     * Returns a filter that ORs the two specified filters.
+     *
+     * @param filter1  the first filter
+     * @param filter2  the second filter
+     * @return a filter that ORs the two specified filters
+     * @see #or(IOFileFilter...)
+     * @see OrFileFilter
+     * @deprecated use {@link #or(IOFileFilter...)}
+     */
+    @Deprecated
+    public static IOFileFilter orFileFilter(final IOFileFilter filter1, final IOFileFilter filter2) {
+        return new OrFileFilter(filter1, filter2);
+    }
+
+    /**
+     * Returns a filter that returns true if the file name starts with the specified text.
+     *
+     * @param prefix  the file name prefix
+     * @return a prefix checking filter
+     * @see PrefixFileFilter
+     */
+    public static IOFileFilter prefixFileFilter(final String prefix) {
+        return new PrefixFileFilter(prefix);
+    }
+
+    /**
+     * Returns a filter that returns true if the file name starts with the specified text.
+     *
+     * @param prefix  the file name prefix
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     * @return a prefix checking filter
+     * @see PrefixFileFilter
+     * @since 2.0
+     */
+    public static IOFileFilter prefixFileFilter(final String prefix, final IOCase ioCase) {
+        return new PrefixFileFilter(prefix, ioCase);
+    }
+
+    /**
+     * Returns a filter that returns true if the file is bigger than a certain size.
+     *
+     * @param threshold  the file size threshold
+     * @return an appropriately configured SizeFileFilter
+     * @see SizeFileFilter
+     * @since 1.2
+     */
+    public static IOFileFilter sizeFileFilter(final long threshold) {
+        return new SizeFileFilter(threshold);
+    }
+
+    /**
+     * Returns a filter that filters based on file size.
+     *
+     * @param threshold  the file size threshold
+     * @param acceptLarger  if true, larger files get accepted, if false, smaller
+     * @return an appropriately configured SizeFileFilter
+     * @see SizeFileFilter
+     * @since 1.2
+     */
+    public static IOFileFilter sizeFileFilter(final long threshold, final boolean acceptLarger) {
+        return new SizeFileFilter(threshold, acceptLarger);
+    }
+
+    /**
+     * Returns a filter that accepts files whose size is &gt;= minimum size
+     * and &lt;= maximum size.
+     *
+     * @param minSizeInclusive the minimum file size (inclusive)
+     * @param maxSizeInclusive the maximum file size (inclusive)
+     * @return an appropriately configured IOFileFilter
+     * @see SizeFileFilter
+     * @since 1.3
+     */
+    public static IOFileFilter sizeRangeFileFilter(final long minSizeInclusive, final long maxSizeInclusive ) {
+        final IOFileFilter minimumFilter = new SizeFileFilter(minSizeInclusive, true);
+        final IOFileFilter maximumFilter = new SizeFileFilter(maxSizeInclusive + 1L, false);
+        return minimumFilter.and(maximumFilter);
+    }
+
+    /**
+     * Returns a filter that returns true if the file name ends with the specified text.
+     *
+     * @param suffix  the file name suffix
+     * @return a suffix checking filter
+     * @see SuffixFileFilter
+     */
+    public static IOFileFilter suffixFileFilter(final String suffix) {
+        return new SuffixFileFilter(suffix);
+    }
+
+    /**
+     * Returns a filter that returns true if the file name ends with the specified text.
+     *
+     * @param suffix  the file name suffix
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     * @return a suffix checking filter
+     * @see SuffixFileFilter
+     * @since 2.0
+     */
+    public static IOFileFilter suffixFileFilter(final String suffix, final IOCase ioCase) {
+        return new SuffixFileFilter(suffix, ioCase);
+    }
+
+    /**
+     * Create a List of file filters.
+     *
+     * @param filters The file filters
+     * @return The list of file filters
+     * @throws NullPointerException if the filters are null or contain a
+     *         null value.
+     * @since 2.0
+     */
+    public static List<IOFileFilter> toList(final IOFileFilter... filters) {
+        return Stream.of(Objects.requireNonNull(filters, "filters")).map(Objects::requireNonNull).collect(Collectors.toList());
+    }
+
+    /**
+     * Returns a filter that always returns true.
+     *
+     * @return a true filter
+     * @see TrueFileFilter#TRUE
+     */
+    public static IOFileFilter trueFileFilter() {
+        return TrueFileFilter.TRUE;
+    }
+
+    /**
+     * FileFilterUtils is not normally instantiated.
+     */
+    public FileFilterUtils() {
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/HiddenFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/HiddenFileFilter.java
new file mode 100644
index 0000000..c6678e8
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/HiddenFileFilter.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+
+/**
+ * This filter accepts {@link File}s that are hidden.
+ * <p>
+ * Example, showing how to print out a list of the
+ * current directory's <i>hidden</i> files:
+ * </p>
+ * <h2>Using Classic IO</h2>
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(HiddenFileFilter.HIDDEN);
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <p>
+ * Example, showing how to print out a list of the
+ * current directory's <i>visible</i> (i.e. not hidden) files:
+ * </p>
+ *
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(HiddenFileFilter.VISIBLE);
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <h2>Using NIO</h2>
+ * <pre>
+ * final Path dir = PathUtils.current();
+ * final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(HiddenFileFilter.HIDDEN);
+ * //
+ * // Walk one dir
+ * Files.<b>walkFileTree</b>(dir, Collections.emptySet(), 1, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getFileList());
+ * //
+ * visitor.getPathCounters().reset();
+ * //
+ * // Walk dir tree
+ * Files.<b>walkFileTree</b>(dir, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getDirList());
+ * System.out.println(visitor.getFileList());
+ * </pre>
+ *
+ * @since 1.3
+ */
+public class HiddenFileFilter extends AbstractFileFilter implements Serializable {
+
+    /** Singleton instance of <i>hidden</i> filter */
+    public static final IOFileFilter HIDDEN  = new HiddenFileFilter();
+
+    private static final long serialVersionUID = 8930842316112759062L;
+
+    /** Singleton instance of <i>visible</i> filter */
+    public static final IOFileFilter VISIBLE = HIDDEN.negate();
+
+    /**
+     * Restrictive constructor.
+     */
+    protected HiddenFileFilter() {
+    }
+
+    /**
+     * Checks to see if the file is hidden.
+     *
+     * @param file  the File to check
+     * @return {@code true} if the file is
+     *  <i>hidden</i>, otherwise {@code false}.
+     */
+    @Override
+    public boolean accept(final File file) {
+        return file.isHidden();
+    }
+
+    /**
+     * Checks to see if the file is hidden.
+     * @param file  the File to check
+     *
+     * @return {@code true} if the file is
+     *  <i>hidden</i>, otherwise {@code false}.
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return get(() -> toFileVisitResult(Files.isHidden(file)));
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/IOFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/IOFileFilter.java
new file mode 100644
index 0000000..72abc5d
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/IOFileFilter.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.FilenameFilter;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+
+import org.apache.commons.io.file.PathFilter;
+
+/**
+ * An interface which brings the FileFilter, FilenameFilter, and PathFilter interfaces together.
+ *
+ * @since 1.0
+ */
+public interface IOFileFilter extends FileFilter, FilenameFilter, PathFilter {
+
+    /**
+     * An empty String array.
+     */
+    String[] EMPTY_STRING_ARRAY = {};
+
+    /**
+     * Checks to see if the File should be accepted by this filter.
+     * <p>
+     * Defined in {@link java.io.FileFilter}.
+     * </p>
+     *
+     * @param file the File to check.
+     * @return true if this file matches the test.
+     */
+    @Override
+    boolean accept(File file);
+
+    /**
+     * Checks to see if the File should be accepted by this filter.
+     * <p>
+     * Defined in {@link java.io.FilenameFilter}.
+     * </p>
+     *
+     * @param dir the directory File to check.
+     * @param name the file name within the directory to check.
+     * @return true if this file matches the test.
+     */
+    @Override
+    boolean accept(File dir, String name);
+
+    /**
+     * Checks to see if the Path should be accepted by this filter.
+     *
+     * @param path the Path to check.
+     * @return true if this path matches the test.
+     * @since 2.9.0
+     */
+    @Override
+    default FileVisitResult accept(final Path path, final BasicFileAttributes attributes) {
+        return AbstractFileFilter.toDefaultFileVisitResult(accept(path.toFile()));
+    }
+
+    /**
+     * Creates a new "and" filter with this filter.
+     *
+     * @param fileFilter the filter to "and".
+     * @return a new filter.
+     * @since 2.9.0
+     */
+    default IOFileFilter and(final IOFileFilter fileFilter) {
+        return new AndFileFilter(this, fileFilter);
+    }
+
+    /**
+     * Creates a new "not" filter with this filter.
+     *
+     * @return a new filter.
+     * @since 2.9.0
+     */
+    default IOFileFilter negate() {
+        return new NotFileFilter(this);
+    }
+
+    /**
+     * Creates a new "or" filter with this filter.
+     *
+     * @param fileFilter the filter to "or".
+     * @return a new filter.
+     * @since 2.9.0
+     */
+    default IOFileFilter or(final IOFileFilter fileFilter) {
+        return new OrFileFilter(this, fileFilter);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/MagicNumberFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/MagicNumberFileFilter.java
new file mode 100644
index 0000000..88bf64f
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/MagicNumberFileFilter.java
@@ -0,0 +1,329 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.io.Serializable;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.Charset;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Arrays;
+import java.util.Objects;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.RandomAccessFileMode;
+
+/**
+ * <p>
+ * File filter for matching files containing a "magic number". A magic number
+ * is a unique series of bytes common to all files of a specific file format.
+ * For instance, all Java class files begin with the bytes
+ * {@code 0xCAFEBABE}.
+ * </p>
+ * <h2>Using Classic IO</h2>
+ * <pre>
+ * File dir = FileUtils.current();
+ * MagicNumberFileFilter javaClassFileFilter =
+ *     MagicNumberFileFilter(new byte[] {(byte) 0xCA, (byte) 0xFE,
+ *       (byte) 0xBA, (byte) 0xBE});
+ * String[] javaClassFiles = dir.list(javaClassFileFilter);
+ * for (String javaClassFile : javaClassFiles) {
+ *     System.out.println(javaClassFile);
+ * }
+ * </pre>
+ *
+ * <p>
+ * Sometimes, such as in the case of TAR files, the
+ * magic number will be offset by a certain number of bytes in the file. In the
+ * case of TAR archive files, this offset is 257 bytes.
+ * </p>
+ *
+ * <pre>
+ * File dir = FileUtils.current();
+ * MagicNumberFileFilter tarFileFilter =
+ *     MagicNumberFileFilter("ustar", 257);
+ * String[] tarFiles = dir.list(tarFileFilter);
+ * for (String tarFile : tarFiles) {
+ *     System.out.println(tarFile);
+ * }
+ * </pre>
+ * <h2>Using NIO</h2>
+ * <pre>
+ * final Path dir = PathUtils.current();
+ * final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(MagicNumberFileFilter("ustar", 257));
+ * //
+ * // Walk one dir
+ * Files.<b>walkFileTree</b>(dir, Collections.emptySet(), 1, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getFileList());
+ * //
+ * visitor.getPathCounters().reset();
+ * //
+ * // Walk dir tree
+ * Files.<b>walkFileTree</b>(dir, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getDirList());
+ * System.out.println(visitor.getFileList());
+ * </pre>
+ *
+ * @since 2.0
+ * @see FileFilterUtils#magicNumberFileFilter(byte[])
+ * @see FileFilterUtils#magicNumberFileFilter(String)
+ * @see FileFilterUtils#magicNumberFileFilter(byte[], long)
+ * @see FileFilterUtils#magicNumberFileFilter(String, long)
+ */
+public class MagicNumberFileFilter extends AbstractFileFilter implements
+        Serializable {
+
+    /**
+     * The serialization version unique identifier.
+     */
+    private static final long serialVersionUID = -547733176983104172L;
+
+    /**
+     * The magic number to compare against the file's bytes at the provided
+     * offset.
+     */
+    private final byte[] magicNumbers;
+
+    /**
+     * The offset (in bytes) within the files that the magic number's bytes
+     * should appear.
+     */
+    private final long byteOffset;
+
+    /**
+     * <p>
+     * Constructs a new MagicNumberFileFilter and associates it with the magic
+     * number to test for in files. This constructor assumes a starting offset
+     * of {@code 0}.
+     * </p>
+     *
+     * <p>
+     * It is important to note that <em>the array is not cloned</em> and that
+     * any changes to the magic number array after construction will affect the
+     * behavior of this file filter.
+     * </p>
+     *
+     * <pre>
+     * MagicNumberFileFilter javaClassFileFilter =
+     *     MagicNumberFileFilter(new byte[] {(byte) 0xCA, (byte) 0xFE,
+     *       (byte) 0xBA, (byte) 0xBE});
+     * </pre>
+     *
+     * @param magicNumber the magic number to look for in the file.
+     *
+     * @throws IllegalArgumentException if {@code magicNumber} is
+     *         {@code null}, or contains no bytes.
+     */
+    public MagicNumberFileFilter(final byte[] magicNumber) {
+        this(magicNumber, 0);
+    }
+
+    /**
+     * <p>
+     * Constructs a new MagicNumberFileFilter and associates it with the magic
+     * number to test for in files and the byte offset location in the file to
+     * to look for that magic number.
+     * </p>
+     *
+     * <pre>
+     * MagicNumberFileFilter tarFileFilter =
+     *     MagicNumberFileFilter(new byte[] {0x75, 0x73, 0x74, 0x61, 0x72}, 257);
+     * </pre>
+     *
+     * <pre>
+     * MagicNumberFileFilter javaClassFileFilter =
+     *     MagicNumberFileFilter(new byte[] {0xCA, 0xFE, 0xBA, 0xBE}, 0);
+     * </pre>
+     *
+     * @param magicNumbers the magic number to look for in the file.
+     * @param offset the byte offset in the file to start comparing bytes.
+     *
+     * @throws IllegalArgumentException if {@code magicNumber}
+     *         contains no bytes, or {@code offset}
+     *         is a negative number.
+     */
+    public MagicNumberFileFilter(final byte[] magicNumbers, final long offset) {
+        Objects.requireNonNull(magicNumbers, "magicNumbers");
+        if (magicNumbers.length == 0) {
+            throw new IllegalArgumentException("The magic number must contain at least one byte");
+        }
+        if (offset < 0) {
+            throw new IllegalArgumentException("The offset cannot be negative");
+        }
+
+        this.magicNumbers = magicNumbers.clone();
+        this.byteOffset = offset;
+    }
+
+    /**
+     * <p>
+     * Constructs a new MagicNumberFileFilter and associates it with the magic
+     * number to test for in files. This constructor assumes a starting offset
+     * of {@code 0}.
+     * </p>
+     *
+     * Example usage:
+     * <pre>
+     * {@code
+     * MagicNumberFileFilter xmlFileFilter =
+     *     MagicNumberFileFilter("<?xml");
+     * }
+     * </pre>
+     *
+     * @param magicNumber the magic number to look for in the file.
+     *        The string is converted to bytes using the platform default charset.
+     *
+     * @throws IllegalArgumentException if {@code magicNumber} is
+     *         {@code null} or the empty String.
+     */
+    public MagicNumberFileFilter(final String magicNumber) {
+        this(magicNumber, 0);
+    }
+
+    /**
+     * <p>
+     * Constructs a new MagicNumberFileFilter and associates it with the magic
+     * number to test for in files and the byte offset location in the file to
+     * to look for that magic number.
+     * </p>
+     *
+     * <pre>
+     * MagicNumberFileFilter tarFileFilter =
+     *     MagicNumberFileFilter("ustar", 257);
+     * </pre>
+     *
+     * @param magicNumber the magic number to look for in the file.
+     *        The string is converted to bytes using the platform default charset.
+     * @param offset the byte offset in the file to start comparing bytes.
+     *
+     * @throws IllegalArgumentException if {@code magicNumber} is
+     *         the empty String, or {@code offset} is
+     *         a negative number.
+     */
+    public MagicNumberFileFilter(final String magicNumber, final long offset) {
+        Objects.requireNonNull(magicNumber, "magicNumber");
+        if (magicNumber.isEmpty()) {
+            throw new IllegalArgumentException("The magic number must contain at least one byte");
+        }
+        if (offset < 0) {
+            throw new IllegalArgumentException("The offset cannot be negative");
+        }
+
+        this.magicNumbers = magicNumber.getBytes(Charset.defaultCharset()); // explicitly uses the platform default charset
+        this.byteOffset = offset;
+    }
+
+    /**
+     * <p>
+     * Accepts the provided file if the file contains the file filter's magic
+     * number at the specified offset.
+     * </p>
+     *
+     * <p>
+     * If any {@link IOException}s occur while reading the file, the file will
+     * be rejected.
+     * </p>
+     *
+     * @param file the file to accept or reject.
+     *
+     * @return {@code true} if the file contains the filter's magic number
+     *         at the specified offset, {@code false} otherwise.
+     */
+    @Override
+    public boolean accept(final File file) {
+        if (file != null && file.isFile() && file.canRead()) {
+            try {
+                try (RandomAccessFile randomAccessFile = RandomAccessFileMode.READ_ONLY.create(file)) {
+                    final byte[] fileBytes = IOUtils.byteArray(this.magicNumbers.length);
+                    randomAccessFile.seek(byteOffset);
+                    final int read = randomAccessFile.read(fileBytes);
+                    if (read != magicNumbers.length) {
+                        return false;
+                    }
+                    return Arrays.equals(this.magicNumbers, fileBytes);
+                }
+            }
+            catch (final IOException ignored) {
+                // Do nothing, fall through and do not accept file
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * <p>
+     * Accepts the provided file if the file contains the file filter's magic
+     * number at the specified offset.
+     * </p>
+     *
+     * <p>
+     * If any {@link IOException}s occur while reading the file, the file will
+     * be rejected.
+     * </p>
+     * @param file the file to accept or reject.
+     *
+     * @return {@code true} if the file contains the filter's magic number
+     *         at the specified offset, {@code false} otherwise.
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        if (file != null && Files.isRegularFile(file) && Files.isReadable(file)) {
+            try {
+                try (FileChannel fileChannel = FileChannel.open(file)) {
+                    final ByteBuffer byteBuffer = ByteBuffer.allocate(this.magicNumbers.length);
+                    final int read = fileChannel.read(byteBuffer);
+                    if (read != magicNumbers.length) {
+                        return FileVisitResult.TERMINATE;
+                    }
+                    return toFileVisitResult(Arrays.equals(this.magicNumbers, byteBuffer.array()));
+                }
+            }
+            catch (final IOException ignored) {
+                // Do nothing, fall through and do not accept file
+            }
+        }
+        return FileVisitResult.TERMINATE;
+    }
+
+    /**
+     * Returns a String representation of the file filter, which includes the
+     * magic number bytes and byte offset.
+     *
+     * @return a String representation of the file filter.
+     */
+    @Override
+    public String toString() {
+        final StringBuilder builder = new StringBuilder(super.toString());
+        builder.append("(");
+        builder.append(new String(magicNumbers, Charset.defaultCharset()));// TODO perhaps use hex if value is not
+                                                                           // printable
+        builder.append(",");
+        builder.append(this.byteOffset);
+        builder.append(")");
+        return builder.toString();
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/NameFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/NameFileFilter.java
new file mode 100644
index 0000000..71fe7c9
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/NameFileFilter.java
@@ -0,0 +1,211 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.IOCase;
+
+/**
+ * Filters file names for a certain name.
+ * <p>
+ * For example, to print all files and directories in the
+ * current directory whose name is {@code Test}:
+ * </p>
+ * <h2>Using Classic IO</h2>
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(new NameFileFilter("Test"));
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <h2>Using NIO</h2>
+ * <pre>
+ * final Path dir = PathUtils.current();
+ * final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(new NameFileFilter("Test"));
+ * //
+ * // Walk one dir
+ * Files.<b>walkFileTree</b>(dir, Collections.emptySet(), 1, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getFileList());
+ * //
+ * visitor.getPathCounters().reset();
+ * //
+ * // Walk dir tree
+ * Files.<b>walkFileTree</b>(dir, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getDirList());
+ * System.out.println(visitor.getFileList());
+ * </pre>
+ *
+ * @since 1.0
+ * @see FileFilterUtils#nameFileFilter(String)
+ * @see FileFilterUtils#nameFileFilter(String, IOCase)
+ */
+public class NameFileFilter extends AbstractFileFilter implements Serializable {
+
+    private static final long serialVersionUID = 176844364689077340L;
+
+    /** The file names to search for */
+    private final String[] names;
+
+    /** Whether the comparison is case-sensitive. */
+    private final IOCase ioCase;
+
+    /**
+     * Constructs a new case-sensitive name file filter for a list of names.
+     *
+     * @param names  the names to allow, must not be null
+     * @throws IllegalArgumentException if the name list is null
+     * @throws ClassCastException if the list does not contain Strings
+     */
+    public NameFileFilter(final List<String> names) {
+        this(names, null);
+    }
+
+    /**
+     * Constructs a new name file filter for a list of names specifying case-sensitivity.
+     *
+     * @param names  the names to allow, must not be null
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     * @throws NullPointerException if the name list is null
+     * @throws ClassCastException if the list does not contain Strings
+     */
+    public NameFileFilter(final List<String> names, final IOCase ioCase) {
+        Objects.requireNonNull(names, "names");
+        this.names = names.toArray(EMPTY_STRING_ARRAY);
+        this.ioCase = toIOCase(ioCase);
+    }
+
+    /**
+     * Constructs a new case-sensitive name file filter for a single name.
+     *
+     * @param name  the name to allow, must not be null
+     * @throws IllegalArgumentException if the name is null
+     */
+    public NameFileFilter(final String name) {
+        this(name, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new case-sensitive name file filter for an array of names.
+     * <p>
+     * The array is not cloned, so could be changed after constructing the
+     * instance. This would be inadvisable however.
+     * </p>
+     *
+     * @param names  the names to allow, must not be null
+     * @throws IllegalArgumentException if the names array is null
+     */
+    public NameFileFilter(final String... names) {
+        this(names, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new name file filter specifying case-sensitivity.
+     *
+     * @param name  the name to allow, must not be null
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     * @throws NullPointerException if the name is null
+     */
+    public NameFileFilter(final String name, final IOCase ioCase) {
+        Objects.requireNonNull(name, "name");
+        this.names = new String[] {name};
+        this.ioCase = toIOCase(ioCase);
+    }
+
+    /**
+     * Constructs a new name file filter for an array of names specifying case-sensitivity.
+     *
+     * @param names  the names to allow, must not be null
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     * @throws NullPointerException if the names array is null
+     */
+    public NameFileFilter(final String[] names, final IOCase ioCase) {
+        Objects.requireNonNull(names, "names");
+        this.names = names.clone();
+        this.ioCase = toIOCase(ioCase);
+    }
+
+    /**
+     * Checks to see if the file name matches.
+     *
+     * @param file  the File to check
+     * @return true if the file name matches
+     */
+    @Override
+    public boolean accept(final File file) {
+        return acceptBaseName(file.getName());
+    }
+
+    /**
+     * Checks to see if the file name matches.
+     *
+     * @param dir  the File directory (ignored)
+     * @param name  the file name
+     * @return true if the file name matches
+     */
+    @Override
+    public boolean accept(final File dir, final String name) {
+        return acceptBaseName(name);
+    }
+
+    /**
+     * Checks to see if the file name matches.
+     * @param file  the File to check
+     *
+     * @return true if the file name matches
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return toFileVisitResult(acceptBaseName(Objects.toString(file.getFileName(), null)));
+    }
+
+    private boolean acceptBaseName(final String baseName) {
+        return Stream.of(names).anyMatch(testName -> ioCase.checkEquals(baseName, testName));
+    }
+
+    private IOCase toIOCase(final IOCase ioCase) {
+        return IOCase.value(ioCase, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Provide a String representation of this file filter.
+     *
+     * @return a String representation
+     */
+    @Override
+    public String toString() {
+        final StringBuilder buffer = new StringBuilder();
+        buffer.append(super.toString());
+        buffer.append("(");
+        append(names, buffer);
+        buffer.append(")");
+        return buffer.toString();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/NotFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/NotFileFilter.java
new file mode 100644
index 0000000..1cc5d4f
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/NotFileFilter.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Objects;
+
+/**
+ * This filter produces a logical NOT of the filters specified.
+ *
+ * @since 1.0
+ * @see FileFilterUtils#notFileFilter(IOFileFilter)
+ */
+public class NotFileFilter extends AbstractFileFilter implements Serializable {
+
+    private static final long serialVersionUID = 6131563330944994230L;
+
+    /** The filter */
+    private final IOFileFilter filter;
+
+    /**
+     * Constructs a new file filter that NOTs the result of another filter.
+     *
+     * @param filter the filter, must not be null
+     * @throws NullPointerException if the filter is null
+     */
+    public NotFileFilter(final IOFileFilter filter) {
+        Objects.requireNonNull(filter, "filter");
+        this.filter = filter;
+    }
+
+    /**
+     * Returns the logical NOT of the underlying filter's return value for the same File.
+     *
+     * @param file the File to check
+     * @return true if the filter returns false
+     */
+    @Override
+    public boolean accept(final File file) {
+        return !filter.accept(file);
+    }
+
+    /**
+     * Returns the logical NOT of the underlying filter's return value for the same arguments.
+     *
+     * @param file the File directory
+     * @param name the file name
+     * @return true if the filter returns false
+     */
+    @Override
+    public boolean accept(final File file, final String name) {
+        return !filter.accept(file, name);
+    }
+
+    /**
+     * Returns the logical NOT of the underlying filter's return value for the same File.
+     * @param file the File to check
+     *
+     * @return true if the filter returns false
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return not(filter.accept(file, attributes));
+    }
+
+    private FileVisitResult not(final FileVisitResult accept) {
+        return accept == FileVisitResult.CONTINUE ? FileVisitResult.TERMINATE : FileVisitResult.CONTINUE;
+    }
+
+    /**
+     * Provide a String representation of this file filter.
+     *
+     * @return a String representation
+     */
+    @Override
+    public String toString() {
+        return "NOT (" + filter.toString() + ")";
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/OrFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/OrFileFilter.java
new file mode 100644
index 0000000..d8a6b19
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/OrFileFilter.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+/**
+ * A {@link java.io.FileFilter} providing conditional OR logic across a list of file filters. This filter returns
+ * {@code true} if any filters in the list return {@code true}. Otherwise, it returns {@code false}. Checking of the
+ * file filter list stops when the first filter returns {@code true}.
+ *
+ * @since 1.0
+ * @see FileFilterUtils#or(IOFileFilter...)
+ */
+public class OrFileFilter extends AbstractFileFilter implements ConditionalFileFilter, Serializable {
+
+    private static final long serialVersionUID = 5767770777065432721L;
+
+    /** The list of file filters. */
+    private final List<IOFileFilter> fileFilters;
+
+    /**
+     * Constructs a new instance of {@link OrFileFilter}.
+     *
+     * @since 1.1
+     */
+    public OrFileFilter() {
+        this(0);
+    }
+
+    /**
+     * Constructs a new instance with the given initial list.
+     *
+     * @param initialList the initial list.
+     */
+    private OrFileFilter(final ArrayList<IOFileFilter> initialList) {
+        this.fileFilters = Objects.requireNonNull(initialList, "initialList");
+    }
+
+    /**
+     * Constructs a new instance with the given initial capacity.
+     *
+     * @param initialCapacity the initial capacity.
+     */
+    private OrFileFilter(final int initialCapacity) {
+        this(new ArrayList<>(initialCapacity));
+    }
+
+    /**
+     * Constructs a new instance for the give filters.
+     * @param fileFilters filters to OR.
+     *
+     * @since 2.9.0
+     */
+    public OrFileFilter(final IOFileFilter... fileFilters) {
+        this(Objects.requireNonNull(fileFilters, "fileFilters").length);
+        addFileFilter(fileFilters);
+    }
+
+    /**
+     * Constructs a new file filter that ORs the result of other filters.
+     *
+     * @param filter1 the first filter, must not be null
+     * @param filter2 the second filter, must not be null
+     * @throws IllegalArgumentException if either filter is null
+     */
+    public OrFileFilter(final IOFileFilter filter1, final IOFileFilter filter2) {
+        this(2);
+        addFileFilter(filter1);
+        addFileFilter(filter2);
+    }
+
+    /**
+     * Constructs a new instance of {@link OrFileFilter} with the specified filters.
+     *
+     * @param fileFilters the file filters for this filter, copied.
+     * @since 1.1
+     */
+    public OrFileFilter(final List<IOFileFilter> fileFilters) {
+        this(new ArrayList<>(Objects.requireNonNull(fileFilters, "fileFilters")));
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public boolean accept(final File file) {
+        return fileFilters.stream().anyMatch(fileFilter -> fileFilter.accept(file));
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public boolean accept(final File file, final String name) {
+        return fileFilters.stream().anyMatch(fileFilter -> fileFilter.accept(file, name));
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return toDefaultFileVisitResult(fileFilters.stream().anyMatch(fileFilter -> fileFilter.accept(file, attributes) == FileVisitResult.CONTINUE));
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void addFileFilter(final IOFileFilter fileFilter) {
+        this.fileFilters.add(Objects.requireNonNull(fileFilter, "fileFilter"));
+    }
+
+    /**
+     * Adds the given file filters.
+     *
+     * @param fileFilters the filters to add.
+     * @since 2.9.0
+     */
+    public void addFileFilter(final IOFileFilter... fileFilters) {
+        Stream.of(Objects.requireNonNull(fileFilters, "fileFilters")).forEach(this::addFileFilter);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public List<IOFileFilter> getFileFilters() {
+        return Collections.unmodifiableList(this.fileFilters);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public boolean removeFileFilter(final IOFileFilter fileFilter) {
+        return this.fileFilters.remove(fileFilter);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void setFileFilters(final List<IOFileFilter> fileFilters) {
+        this.fileFilters.clear();
+        this.fileFilters.addAll(Objects.requireNonNull(fileFilters, "fileFilters"));
+    }
+
+    /**
+     * Provide a String representation of this file filter.
+     *
+     * @return a String representation
+     */
+    @Override
+    public String toString() {
+        final StringBuilder buffer = new StringBuilder();
+        buffer.append(super.toString());
+        buffer.append("(");
+        append(fileFilters, buffer);
+        buffer.append(")");
+        return buffer.toString();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/PathEqualsFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/PathEqualsFileFilter.java
new file mode 100644
index 0000000..497627f
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/PathEqualsFileFilter.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Objects;
+
+/**
+ * Accepts only an exact {@link Path} object match. You can use this filter to visit the start directory when walking a
+ * file tree with
+ * {@link java.nio.file.Files#walkFileTree(java.nio.file.Path, java.util.Set, int, java.nio.file.FileVisitor)}.
+ *
+ * @since 2.9.0
+ */
+public class PathEqualsFileFilter extends AbstractFileFilter {
+
+    private final Path path;
+
+    /**
+     * Constructs a new instance for the given {@link Path}.
+     *
+     * @param file The file to match.
+     */
+    public PathEqualsFileFilter(final Path file) {
+        this.path = file;
+    }
+
+    @Override
+    public boolean accept(final File file) {
+        return Objects.equals(this.path, file.toPath());
+    }
+
+    @Override
+    public FileVisitResult accept(final Path path, final BasicFileAttributes attributes) {
+        return toFileVisitResult(Objects.equals(this.path, path));
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/PathVisitorFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/PathVisitorFileFilter.java
new file mode 100644
index 0000000..75ea800
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/PathVisitorFileFilter.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+
+import org.apache.commons.io.file.NoopPathVisitor;
+import org.apache.commons.io.file.PathUtils;
+import org.apache.commons.io.file.PathVisitor;
+
+/**
+ * A file filter backed by a path visitor.
+ *
+ * @since 2.9.0
+ */
+public class PathVisitorFileFilter extends AbstractFileFilter {
+
+    private final PathVisitor pathVisitor;
+
+    /**
+     * Constructs a new instance that will forward calls to the given visitor.
+     *
+     * @param pathVisitor visit me.
+     */
+    public PathVisitorFileFilter(final PathVisitor pathVisitor) {
+        this.pathVisitor = pathVisitor == null ? NoopPathVisitor.INSTANCE : pathVisitor;
+    }
+
+    @Override
+    public boolean accept(final File file) {
+        try {
+            final Path path = file.toPath();
+            return visitFile(path, file.exists() ? PathUtils.readBasicFileAttributes(path) : null) == FileVisitResult.CONTINUE;
+        } catch (final IOException e) {
+            return handle(e) == FileVisitResult.CONTINUE;
+        }
+    }
+
+    @Override
+    public boolean accept(final File dir, final String name) {
+        try {
+            final Path path = dir.toPath().resolve(name);
+            return accept(path, PathUtils.readBasicFileAttributes(path)) == FileVisitResult.CONTINUE;
+        } catch (final IOException e) {
+            return handle(e) == FileVisitResult.CONTINUE;
+        }
+    }
+
+    @Override
+    public FileVisitResult accept(final Path path, final BasicFileAttributes attributes) {
+        return get(() -> Files.isDirectory(path) ? pathVisitor.postVisitDirectory(path, null) : visitFile(path, attributes));
+    }
+
+    @Override
+    public FileVisitResult visitFile(final Path path, final BasicFileAttributes attributes) throws IOException {
+        return pathVisitor.visitFile(path, attributes);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/PrefixFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/PrefixFileFilter.java
new file mode 100644
index 0000000..22081a5
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/PrefixFileFilter.java
@@ -0,0 +1,213 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.IOCase;
+
+/**
+ * Filters file names for a certain prefix.
+ * <p>
+ * For example, to print all files and directories in the
+ * current directory whose name starts with {@code Test}:
+ * </p>
+ * <h2>Using Classic IO</h2>
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(new PrefixFileFilter("Test"));
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <h2>Using NIO</h2>
+ * <pre>
+ * final Path dir = PathUtils.current();
+ * final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(new PrefixFileFilter("Test"));
+ * //
+ * // Walk one dir
+ * Files.<b>walkFileTree</b>(dir, Collections.emptySet(), 1, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getFileList());
+ * //
+ * visitor.getPathCounters().reset();
+ * //
+ * // Walk dir tree
+ * Files.<b>walkFileTree</b>(dir, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getDirList());
+ * System.out.println(visitor.getFileList());
+ * </pre>
+ *
+ * @since 1.0
+ * @see FileFilterUtils#prefixFileFilter(String)
+ * @see FileFilterUtils#prefixFileFilter(String, IOCase)
+ */
+public class PrefixFileFilter extends AbstractFileFilter implements Serializable {
+
+    private static final long serialVersionUID = 8533897440809599867L;
+
+    /** The file name prefixes to search for */
+    private final String[] prefixes;
+
+    /** Whether the comparison is case-sensitive. */
+    private final IOCase isCase;
+
+    /**
+     * Constructs a new Prefix file filter for a list of prefixes.
+     *
+     * @param prefixes  the prefixes to allow, must not be null
+     * @throws NullPointerException if the prefix list is null
+     * @throws ClassCastException if the list does not contain Strings
+     */
+    public PrefixFileFilter(final List<String> prefixes) {
+        this(prefixes, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new Prefix file filter for a list of prefixes
+     * specifying case-sensitivity.
+     *
+     * @param prefixes  the prefixes to allow, must not be null
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     * @throws NullPointerException if the prefix list is null
+     * @throws ClassCastException if the list does not contain Strings
+     * @since 1.4
+     */
+    public PrefixFileFilter(final List<String> prefixes, final IOCase ioCase) {
+        Objects.requireNonNull(prefixes, "prefixes");
+        this.prefixes = prefixes.toArray(EMPTY_STRING_ARRAY);
+        this.isCase = IOCase.value(ioCase, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new Prefix file filter for a single prefix.
+     *
+     * @param prefix  the prefix to allow, must not be null
+     * @throws IllegalArgumentException if the prefix is null
+     */
+    public PrefixFileFilter(final String prefix) {
+        this(prefix, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new Prefix file filter for any of an array of prefixes.
+     * <p>
+     * The array is not cloned, so could be changed after constructing the
+     * instance. This would be inadvisable however.
+     *
+     * @param prefixes  the prefixes to allow, must not be null
+     * @throws IllegalArgumentException if the prefix array is null
+     */
+    public PrefixFileFilter(final String... prefixes) {
+        this(prefixes, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new Prefix file filter for a single prefix
+     * specifying case-sensitivity.
+     *
+     * @param prefix  the prefix to allow, must not be null
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     * @throws IllegalArgumentException if the prefix is null
+     * @since 1.4
+     */
+    public PrefixFileFilter(final String prefix, final IOCase ioCase) {
+        Objects.requireNonNull(prefix, "prefix");
+        this.prefixes = new String[] {prefix};
+        this.isCase = IOCase.value(ioCase, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new Prefix file filter for any of an array of prefixes
+     * specifying case-sensitivity.
+     *
+     * @param prefixes  the prefixes to allow, must not be null
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     * @throws IllegalArgumentException if the prefix is null
+     * @since 1.4
+     */
+    public PrefixFileFilter(final String[] prefixes, final IOCase ioCase) {
+        Objects.requireNonNull(prefixes, "prefixes");
+        this.prefixes = prefixes.clone();
+        this.isCase = IOCase.value(ioCase, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Checks to see if the file name starts with the prefix.
+     *
+     * @param file  the File to check
+     * @return true if the file name starts with one of our prefixes
+     */
+    @Override
+    public boolean accept(final File file) {
+        return accept(file == null ? null : file.getName());
+    }
+
+    /**
+     * Checks to see if the file name starts with the prefix.
+     *
+     * @param file  the File directory
+     * @param name  the file name
+     * @return true if the file name starts with one of our prefixes
+     */
+    @Override
+    public boolean accept(final File file, final String name) {
+        return accept(name);
+    }
+
+    /**
+     * Checks to see if the file name starts with the prefix.
+     * @param file  the File to check
+     *
+     * @return true if the file name starts with one of our prefixes
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        final Path fileName = file.getFileName();
+        return toFileVisitResult(accept(fileName == null ? null : fileName.toFile()));
+    }
+
+    private boolean accept(final String name) {
+        return Stream.of(prefixes).anyMatch(prefix -> isCase.checkStartsWith(name, prefix));
+    }
+
+    /**
+     * Provide a String representation of this file filter.
+     *
+     * @return a String representation
+     */
+    @Override
+    public String toString() {
+        final StringBuilder buffer = new StringBuilder();
+        buffer.append(super.toString());
+        buffer.append("(");
+        append(prefixes, buffer);
+        buffer.append(")");
+        return buffer.toString();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/RegexFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/RegexFileFilter.java
new file mode 100644
index 0000000..ea16c3b
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/RegexFileFilter.java
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.regex.Pattern;
+
+import org.apache.commons.io.IOCase;
+
+/**
+ * Filters files using supplied regular expression(s).
+ * <p>
+ * See java.util.regex.Pattern for regex matching rules.
+ * </p>
+ * <h2>Using Classic IO</h2>
+ * <p>
+ * e.g.
+ *
+ * <pre>
+ * File dir = FileUtils.current();
+ * FileFilter fileFilter = new RegexFileFilter("^.*[tT]est(-\\d+)?\\.java$");
+ * File[] files = dir.listFiles(fileFilter);
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <h2>Using NIO</h2>
+ *
+ * <pre>
+ * final Path dir = PathUtils.current();
+ * final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(new RegexFileFilter("^.*[tT]est(-\\d+)?\\.java$"));
+ * //
+ * // Walk one dir
+ * Files.<b>walkFileTree</b>(dir, Collections.emptySet(), 1, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getFileList());
+ * //
+ * visitor.getPathCounters().reset();
+ * //
+ * // Walk dir tree
+ * Files.<b>walkFileTree</b>(dir, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getDirList());
+ * System.out.println(visitor.getFileList());
+ * </pre>
+ *
+ * @since 1.4
+ */
+public class RegexFileFilter extends AbstractFileFilter implements Serializable {
+
+    private static final long serialVersionUID = 4269646126155225062L;
+
+    /**
+     * Compiles the given pattern source.
+     *
+     * @param pattern the source pattern.
+     * @param flags the compilation flags.
+     * @return a new Pattern.
+     */
+    private static Pattern compile(final String pattern, final int flags) {
+        Objects.requireNonNull(pattern, "pattern");
+        return Pattern.compile(pattern, flags);
+    }
+
+    /**
+     * Converts IOCase to Pattern compilation flags.
+     *
+     * @param ioCase case-sensitivity.
+     * @return Pattern compilation flags.
+     */
+    private static int toFlags(final IOCase ioCase) {
+        return IOCase.isCaseSensitive(ioCase) ? 0 : Pattern.CASE_INSENSITIVE;
+    }
+
+    /** The regular expression pattern that will be used to match file names. */
+    private final Pattern pattern;
+
+    /** How convert a path to a string. */
+    private final Function<Path, String> pathToString;
+
+    /**
+     * Constructs a new regular expression filter for a compiled regular expression
+     *
+     * @param pattern regular expression to match.
+     * @throws NullPointerException if the pattern is null.
+     */
+    @SuppressWarnings("unchecked")
+    public RegexFileFilter(final Pattern pattern) {
+        this(pattern, (Function<Path, String> & Serializable) p -> p.getFileName().toString());
+    }
+
+    /**
+     * Constructs a new regular expression filter for a compiled regular expression
+     *
+     * @param pattern regular expression to match.
+     * @param pathToString How convert a path to a string.
+     * @throws NullPointerException if the pattern is null.
+     * @since 2.10.0
+     */
+    public RegexFileFilter(final Pattern pattern, final Function<Path, String> pathToString) {
+        Objects.requireNonNull(pattern, "pattern");
+        this.pattern = pattern;
+        this.pathToString = pathToString;
+    }
+
+    /**
+     * Constructs a new regular expression filter.
+     *
+     * @param pattern regular string expression to match
+     * @throws NullPointerException if the pattern is null
+     */
+    public RegexFileFilter(final String pattern) {
+        this(pattern, 0);
+    }
+
+    /**
+     * Constructs a new regular expression filter with the specified flags.
+     *
+     * @param pattern regular string expression to match
+     * @param flags pattern flags - e.g. {@link Pattern#CASE_INSENSITIVE}
+     * @throws IllegalArgumentException if the pattern is null
+     */
+    public RegexFileFilter(final String pattern, final int flags) {
+        this(compile(pattern, flags));
+    }
+
+    /**
+     * Constructs a new regular expression filter with the specified flags case sensitivity.
+     *
+     * @param pattern regular string expression to match
+     * @param ioCase how to handle case sensitivity, null means case-sensitive
+     * @throws IllegalArgumentException if the pattern is null
+     */
+    public RegexFileFilter(final String pattern, final IOCase ioCase) {
+        this(compile(pattern, toFlags(ioCase)));
+    }
+
+    /**
+     * Checks to see if the file name matches one of the regular expressions.
+     *
+     * @param dir the file directory (ignored)
+     * @param name the file name
+     * @return true if the file name matches one of the regular expressions
+     */
+    @Override
+    public boolean accept(final File dir, final String name) {
+        return pattern.matcher(name).matches();
+    }
+
+    /**
+     * Checks to see if the file name matches one of the regular expressions.
+     *
+     * @param path the path
+     * @param attributes the path attributes
+     * @return true if the file name matches one of the regular expressions
+     */
+    @Override
+    public FileVisitResult accept(final Path path, final BasicFileAttributes attributes) {
+        return toFileVisitResult(pattern.matcher(pathToString.apply(path)).matches());
+    }
+
+    /**
+     * Returns a debug string.
+     *
+     * @since 2.10.0
+     */
+    @Override
+    public String toString() {
+        return "RegexFileFilter [pattern=" + pattern + "]";
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/SizeFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/SizeFileFilter.java
new file mode 100644
index 0000000..287144a
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/SizeFileFilter.java
@@ -0,0 +1,159 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+
+/**
+ * Filters files based on size, can filter either smaller files or
+ * files equal to or larger than a given threshold.
+ * <p>
+ * For example, to print all files and directories in the
+ * current directory whose size is greater than 1 MB:
+ * </p>
+ * <h2>Using Classic IO</h2>
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(new SizeFileFilter(1024 * 1024));
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <h2>Using NIO</h2>
+ * <pre>
+ * final Path dir = PathUtils.current();
+ * final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(new SizeFileFilter(1024 * 1024));
+ * //
+ * // Walk one dir
+ * Files.<b>walkFileTree</b>(dir, Collections.emptySet(), 1, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getFileList());
+ * //
+ * visitor.getPathCounters().reset();
+ * //
+ * // Walk dir tree
+ * Files.<b>walkFileTree</b>(dir, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getDirList());
+ * System.out.println(visitor.getFileList());
+ * </pre>
+ *
+ * @since 1.2
+ * @see FileFilterUtils#sizeFileFilter(long)
+ * @see FileFilterUtils#sizeFileFilter(long, boolean)
+ * @see FileFilterUtils#sizeRangeFileFilter(long, long)
+ */
+public class SizeFileFilter extends AbstractFileFilter implements Serializable {
+
+    private static final long serialVersionUID = 7388077430788600069L;
+
+    /** Whether the files accepted will be larger or smaller. */
+    private final boolean acceptLarger;
+
+    /** The size threshold. */
+    private final long size;
+
+    /**
+     * Constructs a new size file filter for files equal to or
+     * larger than a certain size.
+     *
+     * @param size  the threshold size of the files
+     * @throws IllegalArgumentException if the size is negative
+     */
+    public SizeFileFilter(final long size) {
+        this(size, true);
+    }
+
+    /**
+     * Constructs a new size file filter for files based on a certain size
+     * threshold.
+     *
+     * @param size  the threshold size of the files
+     * @param acceptLarger  if true, files equal to or larger are accepted,
+     * otherwise smaller ones (but not equal to)
+     * @throws IllegalArgumentException if the size is negative
+     */
+    public SizeFileFilter(final long size, final boolean acceptLarger) {
+        if (size < 0) {
+            throw new IllegalArgumentException("The size must be non-negative");
+        }
+        this.size = size;
+        this.acceptLarger = acceptLarger;
+    }
+
+    /**
+     * Checks to see if the size of the file is favorable.
+     * <p>
+     * If size equals threshold and smaller files are required,
+     * file <b>IS NOT</b> selected.
+     * If size equals threshold and larger files are required,
+     * file <b>IS</b> selected.
+     * </p>
+     *
+     * @param file  the File to check
+     * @return true if the file name matches
+     */
+    @Override
+    public boolean accept(final File file) {
+        return accept(file.length());
+    }
+
+    private boolean accept(final long length) {
+        return acceptLarger != length < size;
+    }
+
+    /**
+     * Checks to see if the size of the file is favorable.
+     * <p>
+     * If size equals threshold and smaller files are required,
+     * file <b>IS NOT</b> selected.
+     * If size equals threshold and larger files are required,
+     * file <b>IS</b> selected.
+     * </p>
+     * @param file  the File to check
+     *
+     * @return true if the file name matches
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return get(() -> toFileVisitResult(accept(Files.size(file))));
+    }
+
+    /**
+     * Provide a String representation of this file filter.
+     *
+     * @return a String representation
+     */
+    @Override
+    public String toString() {
+        final String condition = acceptLarger ? ">=" : "<";
+        return super.toString() + "(" + condition + size + ")";
+    }
+
+    @Override
+    public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException {
+        return toFileVisitResult(accept(Files.size(file)));
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/SuffixFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/SuffixFileFilter.java
new file mode 100644
index 0000000..dad1646
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/SuffixFileFilter.java
@@ -0,0 +1,213 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.IOCase;
+
+/**
+ * Filters files based on the suffix (what the file name ends with).
+ * This is used in retrieving all the files of a particular type.
+ * <p>
+ * For example, to retrieve and print all {@code *.java} files
+ * in the current directory:
+ * </p>
+ * <h2>Using Classic IO</h2>
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(new SuffixFileFilter(".java"));
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <h2>Using NIO</h2>
+ * <pre>
+ * final Path dir = PathUtils.current();
+ * final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(new SuffixFileFilter(".java"));
+ * //
+ * // Walk one dir
+ * Files.<b>walkFileTree</b>(dir, Collections.emptySet(), 1, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getFileList());
+ * //
+ * visitor.getPathCounters().reset();
+ * //
+ * // Walk dir tree
+ * Files.<b>walkFileTree</b>(dir, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getDirList());
+ * System.out.println(visitor.getFileList());
+ * </pre>
+ *
+ * @since 1.0
+ * @see FileFilterUtils#suffixFileFilter(String)
+ * @see FileFilterUtils#suffixFileFilter(String, IOCase)
+ */
+public class SuffixFileFilter extends AbstractFileFilter implements Serializable {
+
+    private static final long serialVersionUID = -3389157631240246157L;
+
+    /** The file name suffixes to search for */
+    private final String[] suffixes;
+
+    /** Whether the comparison is case-sensitive. */
+    private final IOCase ioCase;
+
+    /**
+     * Constructs a new Suffix file filter for a list of suffixes.
+     *
+     * @param suffixes  the suffixes to allow, must not be null
+     * @throws IllegalArgumentException if the suffix list is null
+     * @throws ClassCastException if the list does not contain Strings
+     */
+    public SuffixFileFilter(final List<String> suffixes) {
+        this(suffixes, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new Suffix file filter for a list of suffixes
+     * specifying case-sensitivity.
+     *
+     * @param suffixes  the suffixes to allow, must not be null
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     * @throws IllegalArgumentException if the suffix list is null
+     * @throws ClassCastException if the list does not contain Strings
+     * @since 1.4
+     */
+    public SuffixFileFilter(final List<String> suffixes, final IOCase ioCase) {
+        Objects.requireNonNull(suffixes, "suffixes");
+        this.suffixes = suffixes.toArray(EMPTY_STRING_ARRAY);
+        this.ioCase = IOCase.value(ioCase, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new Suffix file filter for a single extension.
+     *
+     * @param suffix  the suffix to allow, must not be null
+     * @throws IllegalArgumentException if the suffix is null
+     */
+    public SuffixFileFilter(final String suffix) {
+        this(suffix, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new Suffix file filter for an array of suffixes.
+     * <p>
+     * The array is not cloned, so could be changed after constructing the
+     * instance. This would be inadvisable however.
+     *
+     * @param suffixes  the suffixes to allow, must not be null
+     * @throws NullPointerException if the suffix array is null
+     */
+    public SuffixFileFilter(final String... suffixes) {
+        this(suffixes, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new Suffix file filter for a single extension
+     * specifying case-sensitivity.
+     *
+     * @param suffix  the suffix to allow, must not be null
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     * @throws NullPointerException if the suffix is null
+     * @since 1.4
+     */
+    public SuffixFileFilter(final String suffix, final IOCase ioCase) {
+        Objects.requireNonNull(suffix, "suffix");
+        this.suffixes = new String[] {suffix};
+        this.ioCase = IOCase.value(ioCase, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new Suffix file filter for an array of suffixes
+     * specifying case-sensitivity.
+     *
+     * @param suffixes  the suffixes to allow, must not be null
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     * @throws NullPointerException if the suffix array is null
+     * @since 1.4
+     */
+    public SuffixFileFilter(final String[] suffixes, final IOCase ioCase) {
+        Objects.requireNonNull(suffixes, "suffixes");
+        this.suffixes = suffixes.clone();
+        this.ioCase = IOCase.value(ioCase, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Checks to see if the file name ends with the suffix.
+     *
+     * @param file  the File to check
+     * @return true if the file name ends with one of our suffixes
+     */
+    @Override
+    public boolean accept(final File file) {
+        return accept(file.getName());
+    }
+
+    /**
+     * Checks to see if the file name ends with the suffix.
+     *
+     * @param file  the File directory
+     * @param name  the file name
+     * @return true if the file name ends with one of our suffixes
+     */
+    @Override
+    public boolean accept(final File file, final String name) {
+        return accept(name);
+    }
+
+    /**
+     * Checks to see if the file name ends with the suffix.
+     * @param file  the File to check
+     *
+     * @return true if the file name ends with one of our suffixes
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return toFileVisitResult(accept(Objects.toString(file.getFileName(), null)));
+    }
+
+    private boolean accept(final String name) {
+        return Stream.of(suffixes).anyMatch(suffix -> ioCase.checkEndsWith(name, suffix));
+    }
+
+    /**
+     * Provide a String representation of this file filter.
+     *
+     * @return a String representation
+     */
+    @Override
+    public String toString() {
+        final StringBuilder buffer = new StringBuilder();
+        buffer.append(super.toString());
+        buffer.append("(");
+        append(suffixes, buffer);
+        buffer.append(")");
+        return buffer.toString();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/SymbolicLinkFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/SymbolicLinkFileFilter.java
new file mode 100644
index 0000000..6dee269
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/SymbolicLinkFileFilter.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+
+/**
+ * This filter accepts {@link File}s that are symbolic links.
+ * <p>
+ * For example, here is how to print out a list of the real files
+ * within the current directory:
+ * </p>
+ * <h2>Using Classic IO</h2>
+ * <pre>
+ * File dir = FileUtils.current();
+ * String[] files = dir.list(SymbolicLinkFileFilter.INSTANCE);
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <h2>Using NIO</h2>
+ * <pre>
+ * final Path dir = PathUtils.current();
+ * final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(SymbolicLinkFileFilter.INSTANCE);
+ * //
+ * // Walk one dir
+ * Files.<b>walkFileTree</b>(dir, Collections.emptySet(), 1, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getFileList());
+ * //
+ * visitor.getPathCounters().reset();
+ * //
+ * // Walk dir tree
+ * Files.<b>walkFileTree</b>(dir, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getDirList());
+ * System.out.println(visitor.getFileList());
+ * </pre>
+ *
+ * @since 2.11.0
+ * @see FileFilterUtils#fileFileFilter()
+ */
+public class SymbolicLinkFileFilter extends AbstractFileFilter implements Serializable {
+
+    /**
+     * Singleton instance of file filter.
+     */
+    public static final SymbolicLinkFileFilter INSTANCE = new SymbolicLinkFileFilter();
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * Restrictive constructor.
+     */
+    protected SymbolicLinkFileFilter() {
+    }
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param onAccept What to do on acceptance.
+     * @param onReject What to do on rejection.
+     * @since 2.12.0.
+     */
+    public SymbolicLinkFileFilter(final FileVisitResult onAccept, final FileVisitResult onReject) {
+        super(onAccept, onReject);
+    }
+
+    /**
+     * Checks to see if the file is a file.
+     *
+     * @param file  the File to check
+     * @return true if the file is a file
+     */
+    @Override
+    public boolean accept(final File file) {
+        return file.isFile();
+    }
+
+    /**
+     * Checks to see if the file is a symbolic link.
+     * @param file  the File to check
+     *
+     * @return true if the file is a symbolic link.
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return toFileVisitResult(Files.isSymbolicLink(file));
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/TrueFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/TrueFileFilter.java
new file mode 100644
index 0000000..aa4001b
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/TrueFileFilter.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+
+/**
+ * A file filter that always returns true.
+ *
+ * @since 1.0
+ * @see FileFilterUtils#trueFileFilter()
+ */
+public class TrueFileFilter implements IOFileFilter, Serializable {
+
+    private static final String TO_STRING = Boolean.TRUE.toString();
+
+    private static final long serialVersionUID = 8782512160909720199L;
+
+    /**
+     * Singleton instance of true filter.
+     *
+     * @since 1.3
+     */
+    public static final IOFileFilter TRUE = new TrueFileFilter();
+
+    /**
+     * Singleton instance of true filter. Please use the identical TrueFileFilter.TRUE constant. The new name is more
+     * JDK 1.5 friendly as it doesn't clash with other values when using static imports.
+     */
+    public static final IOFileFilter INSTANCE = TRUE;
+
+    /**
+     * Restrictive constructor.
+     */
+    protected TrueFileFilter() {
+    }
+
+    /**
+     * Returns true.
+     *
+     * @param file the file to check (ignored)
+     * @return true
+     */
+    @Override
+    public boolean accept(final File file) {
+        return true;
+    }
+
+    /**
+     * Returns true.
+     *
+     * @param dir the directory to check (ignored)
+     * @param name the file name (ignored)
+     * @return true
+     */
+    @Override
+    public boolean accept(final File dir, final String name) {
+        return true;
+    }
+
+    /**
+     * Returns true.
+     * @param file the file to check (ignored)
+     *
+     * @return true
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return FileVisitResult.CONTINUE;
+    }
+
+    @Override
+    public IOFileFilter and(final IOFileFilter fileFilter) {
+        // TRUE AND expression <=> expression
+        return fileFilter;
+    }
+
+    @Override
+    public IOFileFilter negate() {
+        return FalseFileFilter.INSTANCE;
+    }
+
+    @Override
+    public IOFileFilter or(final IOFileFilter fileFilter) {
+        // TRUE OR expression <=> true
+        return INSTANCE;
+    }
+
+    @Override
+    public String toString() {
+        return TO_STRING;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/WildcardFileFilter.java b/src/main/java/org/apache/commons/io/filefilter/WildcardFileFilter.java
new file mode 100644
index 0000000..a532e44
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/WildcardFileFilter.java
@@ -0,0 +1,212 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.io.IOCase;
+
+/**
+ * Filters files using the supplied wildcards.
+ * <p>
+ * This filter selects files and directories based on one or more wildcards.
+ * Testing is case-sensitive by default, but this can be configured.
+ * </p>
+ * <p>
+ * The wildcard matcher uses the characters '?' and '*' to represent a
+ * single or multiple wildcard characters.
+ * This is the same as often found on DOS/Unix command lines.
+ * The check is case-sensitive by default.
+ * See {@link FilenameUtils#wildcardMatchOnSystem(String,String)} for more information.
+ * </p>
+ * <p>
+ * For example:
+ * </p>
+ * <h2>Using Classic IO</h2>
+ * <pre>
+ * File dir = FileUtils.current();
+ * FileFilter fileFilter = new WildcardFileFilter("*test*.java~*~");
+ * File[] files = dir.listFiles(fileFilter);
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <h2>Using NIO</h2>
+ * <pre>
+ * final Path dir = PathUtils.current();
+ * final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(new WildcardFileFilter("*test*.java~*~"));
+ * //
+ * // Walk one dir
+ * Files.<b>walkFileTree</b>(dir, Collections.emptySet(), 1, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getFileList());
+ * //
+ * visitor.getPathCounters().reset();
+ * //
+ * // Walk dir tree
+ * Files.<b>walkFileTree</b>(dir, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getDirList());
+ * System.out.println(visitor.getFileList());
+ * </pre>
+ *
+ * @since 1.3
+ */
+public class WildcardFileFilter extends AbstractFileFilter implements Serializable {
+
+    private static final long serialVersionUID = -7426486598995782105L;
+
+    /** The wildcards that will be used to match file names. */
+    private final String[] wildcards;
+
+    /** Whether the comparison is case-sensitive. */
+    private final IOCase ioCase;
+
+    /**
+     * Constructs a new case-sensitive wildcard filter for a list of wildcards.
+     *
+     * @param wildcards  the list of wildcards to match, not null
+     * @throws IllegalArgumentException if the pattern list is null
+     * @throws ClassCastException if the list does not contain Strings
+     */
+    public WildcardFileFilter(final List<String> wildcards) {
+        this(wildcards, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new wildcard filter for a list of wildcards specifying case-sensitivity.
+     *
+     * @param wildcards  the list of wildcards to match, not null
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     * @throws IllegalArgumentException if the pattern list is null
+     * @throws ClassCastException if the list does not contain Strings
+     */
+    public WildcardFileFilter(final List<String> wildcards, final IOCase ioCase) {
+        Objects.requireNonNull(wildcards, "wildcards");
+        this.wildcards = wildcards.toArray(EMPTY_STRING_ARRAY);
+        this.ioCase = IOCase.value(ioCase, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new case-sensitive wildcard filter for a single wildcard.
+     *
+     * @param wildcard  the wildcard to match
+     * @throws IllegalArgumentException if the pattern is null
+     */
+    public WildcardFileFilter(final String wildcard) {
+        this(wildcard, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new case-sensitive wildcard filter for an array of wildcards.
+     *
+     * @param wildcards  the array of wildcards to match
+     * @throws NullPointerException if the pattern array is null
+     */
+    public WildcardFileFilter(final String... wildcards) {
+        this(wildcards, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new wildcard filter for a single wildcard specifying case-sensitivity.
+     *
+     * @param wildcard  the wildcard to match, not null
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     * @throws NullPointerException if the pattern is null
+     */
+    public WildcardFileFilter(final String wildcard, final IOCase ioCase) {
+        Objects.requireNonNull(wildcard, "wildcard");
+        this.wildcards = new String[] {wildcard};
+        this.ioCase = IOCase.value(ioCase, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Constructs a new wildcard filter for an array of wildcards specifying case-sensitivity.
+     *
+     * @param wildcards  the array of wildcards to match, not null
+     * @param ioCase  how to handle case sensitivity, null means case-sensitive
+     * @throws NullPointerException if the pattern array is null
+     */
+    public WildcardFileFilter(final String[] wildcards, final IOCase ioCase) {
+        Objects.requireNonNull(wildcards, "wildcards");
+        this.wildcards = wildcards.clone();
+        this.ioCase = IOCase.value(ioCase, IOCase.SENSITIVE);
+    }
+
+    /**
+     * Checks to see if the file name matches one of the wildcards.
+     *
+     * @param file  the file to check
+     * @return true if the file name matches one of the wildcards
+     */
+    @Override
+    public boolean accept(final File file) {
+        return accept(file.getName());
+    }
+
+    /**
+     * Checks to see if the file name matches one of the wildcards.
+     *
+     * @param dir  the file directory (ignored)
+     * @param name  the file name
+     * @return true if the file name matches one of the wildcards
+     */
+    @Override
+    public boolean accept(final File dir, final String name) {
+        return accept(name);
+    }
+
+    /**
+     * Checks to see if the file name matches one of the wildcards.
+     * @param file  the file to check
+     *
+     * @return true if the file name matches one of the wildcards.
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        return toFileVisitResult(accept(Objects.toString(file.getFileName(), null)));
+    }
+
+    private boolean accept(final String name) {
+        return Stream.of(wildcards).anyMatch(wildcard -> FilenameUtils.wildcardMatch(name, wildcard, ioCase));
+    }
+
+    /**
+     * Provide a String representation of this file filter.
+     *
+     * @return a String representation
+     */
+    @Override
+    public String toString() {
+        final StringBuilder buffer = new StringBuilder();
+        buffer.append(super.toString());
+        buffer.append("(");
+        append(wildcards, buffer);
+        buffer.append(")");
+        return buffer.toString();
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/WildcardFilter.java b/src/main/java/org/apache/commons/io/filefilter/WildcardFilter.java
new file mode 100644
index 0000000..3b9eae8
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/WildcardFilter.java
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.FilenameUtils;
+
+/**
+ * Filters files using the supplied wildcards.
+ * <p>
+ * This filter selects files, but not directories, based on one or more wildcards
+ * and using case-sensitive comparison.
+ * </p>
+ * <p>
+ * The wildcard matcher uses the characters '?' and '*' to represent a
+ * single or multiple wildcard characters.
+ * This is the same as often found on DOS/Unix command lines.
+ * The extension check is case-sensitive.
+ * See {@link FilenameUtils#wildcardMatch(String, String)} for more information.
+ * </p>
+ * <p>
+ * For example:
+ * </p>
+ * <h2>Using Classic IO</h2>
+ * <pre>
+ * File dir = FileUtils.current();
+ * FileFilter fileFilter = new WildcardFilter("*test*.java~*~");
+ * File[] files = dir.listFiles(fileFilter);
+ * for (String file : files) {
+ *     System.out.println(file);
+ * }
+ * </pre>
+ *
+ * <h2>Using NIO</h2>
+ * <pre>
+ * final Path dir = PathUtils.current();
+ * final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(new WildcardFilter("*test*.java~*~"));
+ * //
+ * // Walk one dir
+ * Files.<b>walkFileTree</b>(dir, Collections.emptySet(), 1, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getFileList());
+ * //
+ * visitor.getPathCounters().reset();
+ * //
+ * // Walk dir tree
+ * Files.<b>walkFileTree</b>(dir, visitor);
+ * System.out.println(visitor.getPathCounters());
+ * System.out.println(visitor.getDirList());
+ * System.out.println(visitor.getFileList());
+ * </pre>
+ *
+ * @since 1.1
+ * @deprecated Use WildcardFileFilter. Deprecated as this class performs directory
+ * filtering which it shouldn't do, but that can't be removed due to compatibility.
+ */
+@Deprecated
+public class WildcardFilter extends AbstractFileFilter implements Serializable {
+
+    private static final long serialVersionUID = -5037645902506953517L;
+
+    /** The wildcards that will be used to match file names. */
+    private final String[] wildcards;
+
+    /**
+     * Constructs a new case-sensitive wildcard filter for a list of wildcards.
+     *
+     * @param wildcards  the list of wildcards to match
+     * @throws NullPointerException if the pattern list is null
+     * @throws ClassCastException if the list does not contain Strings
+     */
+    public WildcardFilter(final List<String> wildcards) {
+        Objects.requireNonNull(wildcards, "wildcards");
+        this.wildcards = wildcards.toArray(EMPTY_STRING_ARRAY);
+    }
+
+    /**
+     * Constructs a new case-sensitive wildcard filter for a single wildcard.
+     *
+     * @param wildcard  the wildcard to match
+     * @throws NullPointerException if the pattern is null
+     */
+    public WildcardFilter(final String wildcard) {
+        Objects.requireNonNull(wildcard, "wildcard");
+        this.wildcards = new String[] { wildcard };
+    }
+
+    /**
+     * Constructs a new case-sensitive wildcard filter for an array of wildcards.
+     *
+     * @param wildcards  the array of wildcards to match
+     * @throws NullPointerException if the pattern array is null
+     */
+    public WildcardFilter(final String... wildcards) {
+        Objects.requireNonNull(wildcards, "wildcards");
+        this.wildcards = wildcards.clone();
+    }
+
+    /**
+     * Checks to see if the file name matches one of the wildcards.
+     *
+     * @param file the file to check
+     * @return true if the file name matches one of the wildcards
+     */
+    @Override
+    public boolean accept(final File file) {
+        if (file.isDirectory()) {
+            return false;
+        }
+        return Stream.of(wildcards).anyMatch(wildcard -> FilenameUtils.wildcardMatch(file.getName(), wildcard));
+    }
+
+    /**
+     * Checks to see if the file name matches one of the wildcards.
+     *
+     * @param dir  the file directory
+     * @param name  the file name
+     * @return true if the file name matches one of the wildcards
+     */
+    @Override
+    public boolean accept(final File dir, final String name) {
+        if (dir != null && new File(dir, name).isDirectory()) {
+            return false;
+        }
+        return Stream.of(wildcards).anyMatch(wildcard -> FilenameUtils.wildcardMatch(name, wildcard));
+    }
+
+    /**
+     * Checks to see if the file name matches one of the wildcards.
+     * @param file the file to check
+     *
+     * @return true if the file name matches one of the wildcards
+     * @since 2.9.0
+     */
+    @Override
+    public FileVisitResult accept(final Path file, final BasicFileAttributes attributes) {
+        if (Files.isDirectory(file)) {
+            return FileVisitResult.TERMINATE;
+        }
+        return toDefaultFileVisitResult(
+                Stream.of(wildcards).anyMatch(wildcard -> FilenameUtils.wildcardMatch(Objects.toString(file.getFileName(), null), wildcard)));
+
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/filefilter/package.html b/src/main/java/org/apache/commons/io/filefilter/package.html
new file mode 100644
index 0000000..545ee6a
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/filefilter/package.html
@@ -0,0 +1,177 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<html>
+<body>
+<p>This package defines an interface (IOFileFilter) that combines both 
+{@link java.io.FileFilter} and {@link java.io.FilenameFilter}. Besides
+that the package offers a series of ready-to-use implementations of the
+IOFileFilter interface including implementation that allow you to combine
+other such filters.</p>
+<p>These filter can be used to list files or in {@link java.awt.FileDialog}, 
+for example.</p>
+
+<table>
+    <caption>There are a number of 'primitive' filters:</caption>
+       <tbody>
+    <tr>
+      <td><a href="DirectoryFileFilter.html">DirectoryFilter</a></td>
+      <td>Only accept directories</td>
+    </tr>
+       <tr>
+      <td><a href="PrefixFileFilter.html">PrefixFileFilter</a></td>
+      <td>Filter based on a prefix</td>
+    </tr>
+       <tr>
+      <td><a href="SuffixFileFilter.html">SuffixFileFilter</a></td>
+      <td>Filter based on a suffix</td>
+    </tr>
+    <tr>
+      <td><a href="NameFileFilter.html">NameFileFilter</a></td>
+      <td>Filter based on a filename</td>
+    </tr>
+    <tr>
+      <td><a href="WildcardFileFilter.html">WildcardFileFilter</a></td>
+      <td>Filter based on wildcards</td>
+    </tr>
+    <tr>
+      <td><a href="AgeFileFilter.html">AgeFileFilter</a></td>
+      <td>Filter based on last modified time of file</td>
+    </tr>
+    <tr>
+      <td><a href="SizeFileFilter.html">SizeFileFilter</a></td>
+      <td>Filter based on file size</td>
+    </tr>
+  </tbody>
+</table>
+<table>
+    <caption>And there are five 'boolean' filters:</caption>
+       <tbody>
+       <tr>
+      <td><a href="TrueFileFilter.html">TrueFileFilter</a></td>
+      <td>Accept all files</td>
+    </tr>
+       <tr>
+      <td><a href="FalseFileFilter.html">FalseFileFilter</a></td>
+      <td>Accept no files</td>
+    </tr>
+       <tr>
+      <td><a href="NotFileFilter.html">NotFileFilter</a></td>
+      <td>Applies a logical NOT to an existing filter</td>
+    </tr>
+    <tr>
+      <td><a href="AndFileFilter.html">AndFileFilter</a></td>
+      <td>Combines two filters using a logical AND</td>
+    </tr>
+       <tr>
+      <td><a href="OrFileFilter.html">OrFileFilter</a></td>
+      <td>Combines two filter using a logical OR</td>
+    </tr>
+     
+  </tbody>
+</table>
+
+<h2>Using Classic IO</h2>      
+<p>These boolean FilenameFilters can be nested, to allow arbitrary expressions.
+For example, here is how one could print all non-directory files in the
+current directory, starting with "A", and ending in ".java" or ".class":</p>
+     
+<pre>
+  File dir = new File(".");
+  String[] files = dir.list( 
+    new AndFileFilter(
+      new AndFileFilter(
+        new PrefixFileFilter("A"),
+        new OrFileFilter(
+          new SuffixFileFilter(".class"),
+          new SuffixFileFilter(".java")
+        )
+      ),
+      new NotFileFilter(
+        new DirectoryFileFilter()
+      )
+    )
+  );
+  for (int i=0; i&lt;files.length; i++) {
+    System.out.println(files[i]);
+  }
+</pre>
+<p>
+You can alternatively build a filter tree using the "and", "or", and "not" methods on filters themselves:
+</p>
+<pre>
+  File dir = new File(".");
+  String[] files = dir.list( 
+    new AndFileFilter(
+      new PrefixFileFilter("A").and(
+        new SuffixFileFilter(".class").or(new SuffixFileFilter(".java"))),
+      new DirectoryFileFilter().not()
+    )
+  );
+  for (int i=0; i&lt;files.length; i++) {
+    System.out.println(files[i]);
+  }
+</pre>
+<p>This package also contains a utility class: 
+<a href="FileFilterUtils.html">FileFilterUtils</a>. It allows you to use all 
+file filters without having to put them in the import section. Here's how the 
+above example will look using FileFilterUtils:</p>
+<pre>
+  File dir = new File(".");
+  String[] files = dir.list( 
+    FileFilterUtils.andFileFilter(
+      FileFilterUtils.andFileFilter(
+        FileFilterUtils.prefixFileFilter("A"),
+        FileFilterUtils.orFileFilter(
+          FileFilterUtils.suffixFileFilter(".class"),
+          FileFilterUtils.suffixFileFilter(".java")
+        )
+      ),
+      FileFilterUtils.notFileFilter(
+        FileFilterUtils.directoryFileFilter()
+      )
+    )
+  );
+  for (int i=0; i&lt;files.length; i++) {
+    System.out.println(files[i]);
+  }
+</pre>
+<h2>Using NIO</h2>
+<p>You can combine Java <b>file tree walking</b> by using <code>java.nio.file.Files.walk()</code> APIs with filters:</p>
+<pre>
+   final Path dir = Paths.get("");
+   // We are interested in files older than one day
+   final long cutoff = System.currentTimeMillis() - (24 * 60 * 60 * 1000);
+   final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(new AgeFileFilter(cutoff));
+   //
+   // Walk one dir
+   Files.<b>walkFileTree</b>(dir, Collections.emptySet(), 1, visitor);
+   System.out.println(visitor.getPathCounters());
+   System.out.println(visitor.getFileList());
+   //
+   visitor.getPathCounters().reset();
+   //
+   // Walk dir tree
+   Files.<b>walkFileTree</b>(dir, visitor);
+   System.out.println(visitor.getPathCounters());
+   System.out.println(visitor.getDirList());
+   System.out.println(visitor.getFileList());
+</pre>
+<p>There are a few other goodies in that class so please have a look at the 
+documentation in detail.</p>
+    </body>
+</html>
diff --git a/src/main/java/org/apache/commons/io/function/Constants.java b/src/main/java/org/apache/commons/io/function/Constants.java
new file mode 100644
index 0000000..6ad3a63
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/Constants.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+/**
+ * Defines package-private constants.
+ */
+final class Constants {
+
+    /**
+     * No-op singleton.
+     */
+    @SuppressWarnings("rawtypes")
+    static final IOBiConsumer IO_BI_CONSUMER = (t, u) -> {/* No-op */};
+
+    /**
+     * No-op singleton.
+     */
+    @SuppressWarnings("rawtypes")
+    static final IOBiFunction IO_BI_FUNCTION = (t, u) -> null;
+
+    /**
+     * No-op singleton.
+     */
+    @SuppressWarnings("rawtypes")
+    static final IOFunction IO_FUNCTION_ID = t -> t;
+
+    /**
+     * Always false.
+     */
+    static final IOPredicate<Object> IO_PREDICATE_FALSE = t -> false;
+
+    /**
+     * Always true.
+     */
+    static final IOPredicate<Object> IO_PREDICATE_TRUE = t -> true;
+
+    /**
+     * No-op singleton.
+     */
+    @SuppressWarnings("rawtypes")
+    static final IOTriConsumer IO_TRI_CONSUMER = (t, u, v) -> {/* No-op */};
+
+    private Constants() {
+        // We don't want instances
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/Erase.java b/src/main/java/org/apache/commons/io/function/Erase.java
new file mode 100644
index 0000000..9c883bf
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/Erase.java
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+
+/**
+ * Erases {@link IOException} for the compiler but still throws that exception at runtime.
+ */
+final class Erase {
+
+    /**
+     * Delegates to the given {@link IOBiConsumer} but erases its {@link IOException} for the compiler, while still throwing
+     * the exception at runtime.
+     *
+     * @param <T> See delegate.
+     * @param <U> See delegate.
+     * @param consumer See delegate.
+     * @param t See delegate.
+     * @param u See delegate.
+     * @see IOBiConsumer
+     */
+    static <T, U> void accept(final IOBiConsumer<T, U> consumer, final T t, final U u) {
+        try {
+            consumer.accept(t, u);
+        } catch (final IOException ex) {
+            rethrow(ex); // throws IOException
+        }
+    }
+
+    /**
+     * Delegates to the given {@link IOConsumer} but erases its {@link IOException} for the compiler, while still throwing
+     * the exception at runtime.
+     *
+     * @param <T> See delegate.
+     * @param consumer See delegate.
+     * @param t See delegate.
+     * @see IOConsumer
+     */
+    static <T> void accept(final IOConsumer<T> consumer, final T t) {
+        try {
+            consumer.accept(t);
+        } catch (final IOException ex) {
+            rethrow(ex); // throws IOException
+        }
+    }
+
+    /**
+     * Delegates to the given {@link IOBiFunction} but erases its {@link IOException} for the compiler, while still throwing
+     * the exception at runtime.
+     *
+     * @param <T> See delegate.
+     * @param <U> See delegate.
+     * @param <R> See delegate.
+     * @param mapper See delegate.
+     * @param t See delegate.
+     * @param u See delegate.
+     * @return See delegate.
+     * @see IOBiFunction
+     */
+    static <T, U, R> R apply(final IOBiFunction<? super T, ? super U, ? extends R> mapper, final T t, final U u) {
+        try {
+            return mapper.apply(t, u);
+        } catch (final IOException e) {
+            throw rethrow(e); // throws IOException
+        }
+    }
+
+    /**
+     * Delegates to the given {@link IOFunction} but erases its {@link IOException} for the compiler, while still throwing
+     * the exception at runtime.
+     *
+     * @param <T> See delegate.
+     * @param <R> See delegate.
+     * @param mapper See delegate.
+     * @param t See delegate.
+     * @return See delegate.
+     * @see IOFunction
+     */
+    static <T, R> R apply(final IOFunction<? super T, ? extends R> mapper, final T t) {
+        try {
+            return mapper.apply(t);
+        } catch (final IOException e) {
+            throw rethrow(e); // throws IOException
+        }
+    }
+
+    /**
+     * Delegates to the given {@link IOComparator} but erases its {@link IOException} for the compiler, while still throwing
+     * the exception at runtime.
+     *
+     * @param <T> See delegate.
+     * @param comparator See delegate.
+     * @param t See delegate.
+     * @param u See delegate.
+     * @return See delegate.
+     * @see IOComparator
+     */
+    static <T> int compare(final IOComparator<? super T> comparator, final T t, final T u) {
+        try {
+            return comparator.compare(t, u);
+        } catch (final IOException e) {
+            throw rethrow(e); // throws IOException
+        }
+    }
+
+    /**
+     * Delegates to the given {@link IOSupplier} but erases its {@link IOException} for the compiler, while still throwing
+     * the exception at runtime.
+     *
+     * @param <T> See delegate.
+     * @param supplier See delegate.
+     * @return See delegate.
+     * @see IOSupplier
+     */
+    static <T> T get(final IOSupplier<T> supplier) {
+        try {
+            return supplier.get();
+        } catch (final IOException e) {
+            throw rethrow(e); // throws IOException
+        }
+    }
+
+    /**
+     * Throws the given throwable.
+     *
+     * @param <T> The throwable cast type.
+     * @param throwable The throwable to rethrow.
+     * @return nothing because we throw.
+     * @throws T Always thrown.
+     */
+    @SuppressWarnings("unchecked")
+    static <T extends Throwable> RuntimeException rethrow(final Throwable throwable) throws T {
+        throw (T) throwable;
+    }
+
+    /**
+     * Delegates to the given {@link IORunnable} but erases its {@link IOException} for the compiler, while still throwing
+     * the exception at runtime.
+     *
+     * @param runnable See delegate.
+     * @see IORunnable
+     */
+    static void run(final IORunnable runnable) {
+        try {
+            runnable.run();
+        } catch (final IOException e) {
+            throw rethrow(e); // throws IOException
+        }
+    }
+
+    /**
+     * Delegates to the given {@link IOPredicate} but erases its {@link IOException} for the compiler, while still throwing
+     * the exception at runtime.
+     *
+     * @param <T> See delegate.
+     * @param predicate See delegate.
+     * @param t See delegate.
+     * @return See delegate.
+     * @see IOPredicate
+     */
+    static <T> boolean test(final IOPredicate<? super T> predicate, final T t) {
+        try {
+            return predicate.test(t);
+        } catch (final IOException e) {
+            throw rethrow(e); // throws IOException
+        }
+    }
+
+    /** No instances. */
+    private Erase() {
+        // No instances.
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOBaseStream.java b/src/main/java/org/apache/commons/io/function/IOBaseStream.java
new file mode 100644
index 0000000..6ac9bee
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOBaseStream.java
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.stream.BaseStream;
+import java.util.stream.Stream;
+
+/**
+ * Like {@link BaseStream} but throws {@link IOException}.
+ *
+ * @param <T> the type of the stream elements.
+ * @param <S> the type of the IO stream extending {@code IOBaseStream}.
+ * @param <B> the type of the stream extending {@code BaseStream}.
+ * @since 2.12.0
+ */
+public interface IOBaseStream<T, S extends IOBaseStream<T, S, B>, B extends BaseStream<T, B>> extends Closeable {
+
+    /**
+     * Creates a {@link BaseStream} for this instance that throws {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @return an {@link UncheckedIOException} {@link BaseStream}.
+     */
+    @SuppressWarnings("unchecked")
+    default BaseStream<T, B> asBaseStream() {
+        return new UncheckedIOBaseStream<>((S) this);
+    }
+
+    /**
+     * Like {@link BaseStream#close()}.
+     *
+     * @see BaseStream#close()
+     */
+    @Override
+    default void close() {
+        unwrap().close();
+    }
+
+    /**
+     * Like {@link BaseStream#isParallel()}.
+     *
+     * @return See {@link BaseStream#isParallel() delegate}.
+     * @see BaseStream#isParallel()
+     */
+    @SuppressWarnings("resource") // for unwrap()
+    default boolean isParallel() {
+        return unwrap().isParallel();
+    }
+
+    /**
+     * Like {@link BaseStream#iterator()}.
+     *
+     * @return See {@link BaseStream#iterator() delegate}.
+     * @see BaseStream#iterator()
+     */
+    @SuppressWarnings("resource") // for unwrap()
+    default IOIterator<T> iterator() {
+        return IOIteratorAdapter.adapt(unwrap().iterator());
+    }
+
+    /**
+     * Like {@link BaseStream#onClose(Runnable)}.
+     *
+     * @param closeHandler See {@link BaseStream#onClose(Runnable) delegate}.
+     * @return See {@link BaseStream#onClose(Runnable) delegate}.
+     * @throws IOException if an I/O error occurs.
+     * @see BaseStream#onClose(Runnable)
+     */
+    @SuppressWarnings({"unused", "resource"}) // throws IOException, unwrap()
+    default S onClose(final IORunnable closeHandler) throws IOException {
+        return wrap(unwrap().onClose(() -> Erase.run(closeHandler)));
+    }
+
+    /**
+     * Like {@link BaseStream#parallel()}.
+     *
+     * @return See {@link BaseStream#parallel() delegate}.
+     * @see BaseStream#parallel()
+     */
+    @SuppressWarnings({"resource", "unchecked"}) // for unwrap(), this
+    default S parallel() {
+        return isParallel() ? (S) this : wrap(unwrap().parallel());
+    }
+
+    /**
+     * Like {@link BaseStream#sequential()}.
+     *
+     * @return See {@link BaseStream#sequential() delegate}.
+     * @see BaseStream#sequential()
+     */
+    @SuppressWarnings({"resource", "unchecked"}) // for unwrap(), this
+    default S sequential() {
+        return isParallel() ? wrap(unwrap().sequential()) : (S) this;
+    }
+
+    /**
+     * Like {@link BaseStream#spliterator()}.
+     *
+     * @return See {@link BaseStream#spliterator() delegate}.
+     * @see BaseStream#spliterator()
+     */
+    @SuppressWarnings("resource") // for unwrap()
+    default IOSpliterator<T> spliterator() {
+        return IOSpliteratorAdapter.adapt(unwrap().spliterator());
+    }
+
+    /**
+     * Like {@link BaseStream#unordered()}.
+     *
+     * @return See {@link BaseStream#unordered() delegate}.
+     * @see java.util.stream.BaseStream#unordered()
+     */
+    @SuppressWarnings("resource") // for unwrap()
+    default S unordered() {
+        return wrap(unwrap().unordered());
+    }
+
+    /**
+     * Unwraps this instance and returns the underlying {@link Stream}.
+     * <p>
+     * Implementations may not have anything to unwrap and that behavior is undefined for now.
+     * </p>
+     *
+     * @return the underlying stream.
+     */
+    B unwrap();
+
+    /**
+     * Wraps a {@link Stream}.
+     *
+     * @param delegate The delegate.
+     * @return An IO stream.
+     */
+    S wrap(B delegate);
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOBaseStreamAdapter.java b/src/main/java/org/apache/commons/io/function/IOBaseStreamAdapter.java
new file mode 100644
index 0000000..c1413da
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOBaseStreamAdapter.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.util.Objects;
+import java.util.stream.BaseStream;
+
+/**
+ * Abstracts an {@link IOBaseStream} implementation.
+ *
+ * Keep package-private for now.
+ *
+ * @param <T> the type of the stream elements.
+ * @param <S> the type of the stream extending {@code IOBaseStream}.
+ */
+abstract class IOBaseStreamAdapter<T, S extends IOBaseStream<T, S, B>, B extends BaseStream<T, B>> implements IOBaseStream<T, S, B> {
+
+    /**
+     * The underlying base stream.
+     */
+    private final B delegate;
+
+    /**
+     * Constructs an instance.
+     *
+     * @param delegate the delegate.
+     */
+    IOBaseStreamAdapter(final B delegate) {
+        this.delegate = Objects.requireNonNull(delegate, "delegate");
+    }
+
+    @Override
+    public B unwrap() {
+        return delegate;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOBiConsumer.java b/src/main/java/org/apache/commons/io/function/IOBiConsumer.java
new file mode 100644
index 0000000..492ff4e
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOBiConsumer.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Objects;
+import java.util.function.BiConsumer;
+
+/**
+ * Like {@link BiConsumer} but throws {@link IOException}.
+ *
+ * @param <T> the type of the first argument to the operation
+ * @param <U> the type of the second argument to the operation
+ *
+ * @see BiConsumer
+ * @since 2.12.0
+ */
+@FunctionalInterface
+public interface IOBiConsumer<T, U> {
+
+    /**
+     * Returns the no-op singleton.
+     *
+     * @param <T> the type of the first argument to the operation
+     * @param <U> the type of the second argument to the operation
+     * @return The no-op singleton.
+     */
+    static <T, U> IOBiConsumer<T, U> noop() {
+        return Constants.IO_BI_CONSUMER;
+    }
+
+    /**
+     * Performs this operation on the given arguments.
+     *
+     * @param t the first input argument
+     * @param u the second input argument
+     * @throws IOException if an I/O error occurs.
+     */
+    void accept(T t, U u) throws IOException;
+
+    /**
+     * Creates a composed {@link IOBiConsumer} that performs, in sequence, this operation followed by the {@code after}
+     * operation. If performing either operation throws an exception, it is relayed to the caller of the composed operation.
+     * If performing this operation throws an exception, the {@code after} operation will not be performed.
+     *
+     * @param after the operation to perform after this operation
+     * @return a composed {@link IOBiConsumer} that performs in sequence this operation followed by the {@code after}
+     *         operation
+     * @throws NullPointerException if {@code after} is null
+     */
+    default IOBiConsumer<T, U> andThen(final IOBiConsumer<? super T, ? super U> after) {
+        Objects.requireNonNull(after);
+        return (t, u) -> {
+            accept(t, u);
+            after.accept(t, u);
+        };
+    }
+
+    /**
+     * Creates a {@link BiConsumer} for this instance that throws {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @return an UncheckedIOException BiConsumer.
+     * @throws UncheckedIOException Wraps an {@link IOException}.
+     * @since 2.12.0
+     */
+    default BiConsumer<T, U> asBiConsumer() {
+        return (t, u) -> Uncheck.accept(this, t, u);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOBiFunction.java b/src/main/java/org/apache/commons/io/function/IOBiFunction.java
new file mode 100644
index 0000000..07cd613
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOBiFunction.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Objects;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+/**
+ * Like {@link BiFunction} but throws {@link IOException}.
+ *
+ * <p>
+ * This is a <a href="package-summary.html">functional interface</a> whose functional method is
+ * {@link #apply(Object, Object)}.
+ * </p>
+ *
+ * @param <T> the type of the first argument to the function
+ * @param <U> the type of the second argument to the function
+ * @param <R> the type of the result of the function
+ *
+ * @see Function
+ * @since 2.12.0
+ */
+@FunctionalInterface
+public interface IOBiFunction<T, U, R> {
+
+    /**
+     * Returns the no-op singleton.
+     *
+     * @param <T> the type of the first argument to the function
+     * @param <U> the type of the second argument to the function
+     * @param <R> the type of the result of the function
+     * @return The no-op singleton.
+     */
+    static <T, U, R> IOBiFunction<T, U, R> noop() {
+        return Constants.IO_BI_FUNCTION;
+    }
+
+    /**
+     * Creates a composed function that first applies this function to its input, and then applies the {@code after}
+     * function to the result. If evaluation of either function throws an exception, it is relayed to the caller of the
+     * composed function.
+     *
+     * @param <V> the type of output of the {@code after} function, and of the composed function
+     * @param after the function to apply after this function is applied
+     * @return a composed function that first applies this function and then applies the {@code after} function
+     * @throws NullPointerException if after is null
+     */
+    default <V> IOBiFunction<T, U, V> andThen(final IOFunction<? super R, ? extends V> after) {
+        Objects.requireNonNull(after);
+        return (final T t, final U u) -> after.apply(apply(t, u));
+    }
+
+    /**
+     * Applies this function to the given arguments.
+     *
+     * @param t the first function argument
+     * @param u the second function argument
+     * @return the function result
+     * @throws IOException if an I/O error occurs.
+     */
+    R apply(T t, U u) throws IOException;
+
+    /**
+     * Creates a {@link BiFunction} for this instance that throws {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @return an UncheckedIOException BiFunction.
+     */
+    default BiFunction<T, U, R> asBiFunction() {
+        return (t, u) -> Uncheck.apply(this, t, u);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOBinaryOperator.java b/src/main/java/org/apache/commons/io/function/IOBinaryOperator.java
new file mode 100644
index 0000000..e91d648
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOBinaryOperator.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Objects;
+import java.util.function.BinaryOperator;
+
+/**
+ * Like {@link BinaryOperator} but throws {@link IOException}.
+ *
+ * @param <T> the type of the operands and result of the operator.
+ *
+ * @see IOBiFunction
+ * @see BinaryOperator
+ * @since 2.12.0
+ */
+@FunctionalInterface
+public interface IOBinaryOperator<T> extends IOBiFunction<T, T, T> {
+
+    /**
+     * Creates a {@link IOBinaryOperator} which returns the lesser of two elements according to the specified
+     * {@code Comparator}.
+     *
+     * @param <T> the type of the input arguments of the comparator
+     * @param comparator a {@code Comparator} for comparing the two values
+     * @return a {@code BinaryOperator} which returns the lesser of its operands, according to the supplied
+     *         {@code Comparator}
+     * @throws NullPointerException if the argument is null
+     */
+    static <T> IOBinaryOperator<T> minBy(final IOComparator<? super T> comparator) {
+        Objects.requireNonNull(comparator);
+        return (a, b) -> comparator.compare(a, b) <= 0 ? a : b;
+    }
+
+    /**
+     * Creates a {@link IOBinaryOperator} which returns the greater of two elements according to the specified
+     * {@code Comparator}.
+     *
+     * @param <T> the type of the input arguments of the comparator
+     * @param comparator a {@code Comparator} for comparing the two values
+     * @return a {@code BinaryOperator} which returns the greater of its operands, according to the supplied
+     *         {@code Comparator}
+     * @throws NullPointerException if the argument is null
+     */
+    static <T> IOBinaryOperator<T> maxBy(final IOComparator<? super T> comparator) {
+        Objects.requireNonNull(comparator);
+        return (a, b) -> comparator.compare(a, b) >= 0 ? a : b;
+    }
+
+    /**
+     * Creates a {@link BinaryOperator} for this instance that throws {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @return an unchecked BiFunction.
+     */
+    default BinaryOperator<T> asBinaryOperator() {
+        return (t, u) -> Uncheck.apply(this, t, u);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOComparator.java b/src/main/java/org/apache/commons/io/function/IOComparator.java
new file mode 100644
index 0000000..01a5132
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOComparator.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Comparator;
+
+/**
+ * Like {@link Comparator} but throws {@link IOException}.
+ *
+ * @param <T> the type of objects that may be compared by this comparator
+ *
+ * @see Comparator
+ * @since 2.12.0
+ */
+@FunctionalInterface
+public interface IOComparator<T> {
+
+    /**
+     * Creates a {@link Comparator} for this instance that throws {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @return an UncheckedIOException BiFunction.
+     */
+    default Comparator<T> asComparator() {
+        return (t, u) -> Uncheck.compare(this, t, u);
+    }
+
+    /**
+     * Like {@link Comparator#compare(Object, Object)} but throws {@link IOException}.
+     *
+     * @param o1 the first object to be compared.
+     * @param o2 the second object to be compared.
+     * @return a negative integer, zero, or a positive integer as the first argument is less than, equal to, or greater than
+     *         the second.
+     * @throws NullPointerException if an argument is null and this comparator does not permit null arguments
+     * @throws ClassCastException if the arguments' types prevent them from being compared by this comparator.
+     * @throws IOException if an I/O error occurs.
+     */
+    int compare(T o1, T o2) throws IOException;
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOConsumer.java b/src/main/java/org/apache/commons/io/function/IOConsumer.java
new file mode 100644
index 0000000..e417178
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOConsumer.java
@@ -0,0 +1,169 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.IOExceptionList;
+import org.apache.commons.io.IOIndexedException;
+
+/**
+ * Like {@link Consumer} but throws {@link IOException}.
+ *
+ * @param <T> the type of the input to the operations.
+ * @since 2.7
+ */
+@FunctionalInterface
+public interface IOConsumer<T> {
+
+    /**
+     * Consider private.
+     */
+    IOConsumer<?> NOOP_IO_CONSUMER = t -> {/* noop */};
+
+    /**
+     * Performs an action for each element of the collection gathering any exceptions.
+     *
+     * @param action The action to apply to each input element.
+     * @param iterable The input to stream.
+     * @param <T> The element type.
+     * @throws IOExceptionList if any I/O errors occur.
+     * @since 2.12.0
+     */
+    static <T> void forAll(final IOConsumer<T> action, final Iterable<T> iterable) throws IOExceptionList {
+        IOStreams.forAll(IOStreams.of(iterable), action);
+    }
+
+    /**
+     * Performs an action for each element of the collection gathering any exceptions.
+     *
+     * @param action The action to apply to each input element.
+     * @param stream The input to stream.
+     * @param <T> The element type.
+     * @throws IOExceptionList if any I/O errors occur.
+     * @since 2.12.0
+     */
+    static <T> void forAll(final IOConsumer<T> action, final Stream<T> stream) throws IOExceptionList {
+        IOStreams.forAll(stream, action, IOIndexedException::new);
+    }
+
+    /**
+     * Performs an action for each element of the array gathering any exceptions.
+     *
+     * @param action The action to apply to each input element.
+     * @param array The input to stream.
+     * @param <T> The element type.
+     * @throws IOExceptionList if any I/O errors occur.
+     * @since 2.12.0
+     */
+    @SafeVarargs
+    static <T> void forAll(final IOConsumer<T> action, final T... array) throws IOExceptionList {
+        IOStreams.forAll(IOStreams.of(array), action);
+    }
+
+    /**
+     * Performs an action for each element of the collection, stopping at the first exception.
+     *
+     * @param <T> The element type.
+     * @param iterable The input to stream.
+     * @param action The action to apply to each input element.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.12.0
+     */
+    static <T> void forEach(final Iterable<T> iterable, final IOConsumer<T> action) throws IOException {
+        IOStreams.forEach(IOStreams.of(iterable), action);
+    }
+
+    /**
+     * Performs an action for each element of the stream, stopping at the first exception.
+     *
+     * @param <T> The element type.
+     * @param stream The input to stream.
+     * @param action The action to apply to each input element.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.12.0
+     */
+    static <T> void forEach(final Stream<T> stream, final IOConsumer<T> action) throws IOException {
+        IOStreams.forEach(stream, action);
+    }
+
+    /**
+     * Performs an action for each element of this array, stopping at the first exception.
+     *
+     * @param <T> The element type.
+     * @param array The input to stream.
+     * @param action The action to apply to each input element.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.12.0
+     */
+    static <T> void forEach(final T[] array, final IOConsumer<T> action) throws IOException {
+        IOStreams.forEach(IOStreams.of(array), action);
+    }
+
+    /**
+     * Returns the constant no-op consumer.
+     *
+     * @param <T> Type consumer type.
+     * @return a constant no-op consumer.
+     * @since 2.9.0
+     */
+    @SuppressWarnings("unchecked")
+    static <T> IOConsumer<T> noop() {
+        return (IOConsumer<T>) NOOP_IO_CONSUMER;
+    }
+
+    /**
+     * Performs this operation on the given argument.
+     *
+     * @param t the input argument
+     * @throws IOException if an I/O error occurs.
+     */
+    void accept(T t) throws IOException;
+
+    /**
+     * Returns a composed {@link IOConsumer} that performs, in sequence, this operation followed by the {@code after}
+     * operation. If performing either operation throws an exception, it is relayed to the caller of the composed operation.
+     * If performing this operation throws an exception, the {@code after} operation will not be performed.
+     *
+     * @param after the operation to perform after this operation
+     * @return a composed {@link Consumer} that performs in sequence this operation followed by the {@code after} operation
+     * @throws NullPointerException if {@code after} is null
+     */
+    default IOConsumer<T> andThen(final IOConsumer<? super T> after) {
+        Objects.requireNonNull(after, "after");
+        return (final T t) -> {
+            accept(t);
+            after.accept(t);
+        };
+    }
+
+    /**
+     * Creates a {@link Consumer} for this instance that throws {@link UncheckedIOException} instead of {@link IOException}.
+     *
+     * @return an UncheckedIOException Consumer.
+     * @since 2.12.0
+     */
+    default Consumer<T> asConsumer() {
+        return t -> Uncheck.accept(this, t);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOFunction.java b/src/main/java/org/apache/commons/io/function/IOFunction.java
new file mode 100644
index 0000000..7fffacd
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOFunction.java
@@ -0,0 +1,197 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/**
+ * Like {@link Function} but throws {@link IOException}.
+ *
+ * @param <T> the type of the input to the operations.
+ * @param <R> the return type of the operations.
+ * @since 2.7
+ */
+@FunctionalInterface
+public interface IOFunction<T, R> {
+
+    /**
+     * Returns a {@link IOFunction} that always returns its input argument.
+     *
+     * @param <T> the type of the input and output objects to the function
+     * @return a function that always returns its input argument
+     */
+    static <T> IOFunction<T, T> identity() {
+        return Constants.IO_FUNCTION_ID;
+    }
+
+    /**
+     * Returns a composed {@link IOFunction} that first applies this function to its input, and then applies the
+     * {@code after} consumer to the result. If evaluation of either function throws an exception, it is relayed to the
+     * caller of the composed function.
+     *
+     * @param after the consumer to apply after this function is applied
+     * @return a composed function that first applies this function and then applies the {@code after} consumer
+     * @throws NullPointerException if after is null
+     *
+     * @see #compose(IOFunction)
+     */
+    default IOConsumer<T> andThen(final Consumer<? super R> after) {
+        Objects.requireNonNull(after, "after");
+        return (final T t) -> after.accept(apply(t));
+    }
+
+    /**
+     * Returns a composed {@link IOFunction} that first applies this function to its input, and then applies the
+     * {@code after} function to the result. If evaluation of either function throws an exception, it is relayed to the
+     * caller of the composed function.
+     *
+     * @param <V> the type of output of the {@code after} function, and of the composed function
+     * @param after the function to apply after this function is applied
+     * @return a composed function that first applies this function and then applies the {@code after} function
+     * @throws NullPointerException if after is null
+     *
+     * @see #compose(IOFunction)
+     */
+    default <V> IOFunction<T, V> andThen(final Function<? super R, ? extends V> after) {
+        Objects.requireNonNull(after, "after");
+        return (final T t) -> after.apply(apply(t));
+    }
+
+    /**
+     * Returns a composed {@link IOFunction} that first applies this function to its input, and then applies the
+     * {@code after} consumer to the result. If evaluation of either function throws an exception, it is relayed to the
+     * caller of the composed function.
+     *
+     * @param after the consumer to apply after this function is applied
+     * @return a composed function that first applies this function and then applies the {@code after} consumer
+     * @throws NullPointerException if after is null
+     *
+     * @see #compose(IOFunction)
+     */
+    default IOConsumer<T> andThen(final IOConsumer<? super R> after) {
+        Objects.requireNonNull(after, "after");
+        return (final T t) -> after.accept(apply(t));
+    }
+
+    /**
+     * Returns a composed {@link IOFunction} that first applies this function to its input, and then applies the
+     * {@code after} function to the result. If evaluation of either function throws an exception, it is relayed to the
+     * caller of the composed function.
+     *
+     * @param <V> the type of output of the {@code after} function, and of the composed function
+     * @param after the function to apply after this function is applied
+     * @return a composed function that first applies this function and then applies the {@code after} function
+     * @throws NullPointerException if after is null
+     *
+     * @see #compose(IOFunction)
+     */
+    default <V> IOFunction<T, V> andThen(final IOFunction<? super R, ? extends V> after) {
+        Objects.requireNonNull(after, "after");
+        return (final T t) -> after.apply(apply(t));
+    }
+
+    /**
+     * Applies this function to the given argument.
+     *
+     * @param t the function argument
+     * @return the function result
+     * @throws IOException if an I/O error occurs.
+     */
+    R apply(final T t) throws IOException;
+
+    /**
+     * Creates a {@link Function} for this instance that throws {@link UncheckedIOException} instead of {@link IOException}.
+     *
+     * @return an UncheckedIOException Function.
+     * @since 2.12.0
+     */
+    default Function<T, R> asFunction() {
+        return t -> Uncheck.apply(this, t);
+    }
+
+    /**
+     * Returns a composed {@link IOFunction} that first applies the {@code before} function to its input, and then applies
+     * this function to the result. If evaluation of either function throws an exception, it is relayed to the caller of the
+     * composed function.
+     *
+     * @param <V> the type of input to the {@code before} function, and to the composed function
+     * @param before the function to apply before this function is applied
+     * @return a composed function that first applies the {@code before} function and then applies this function
+     * @throws NullPointerException if before is null
+     *
+     * @see #andThen(IOFunction)
+     */
+    default <V> IOFunction<V, R> compose(final Function<? super V, ? extends T> before) {
+        Objects.requireNonNull(before, "before");
+        return (final V v) -> apply(before.apply(v));
+    }
+
+    /**
+     * Returns a composed {@link IOFunction} that first applies the {@code before} function to its input, and then applies
+     * this function to the result. If evaluation of either function throws an exception, it is relayed to the caller of the
+     * composed function.
+     *
+     * @param <V> the type of input to the {@code before} function, and to the composed function
+     * @param before the function to apply before this function is applied
+     * @return a composed function that first applies the {@code before} function and then applies this function
+     * @throws NullPointerException if before is null
+     *
+     * @see #andThen(IOFunction)
+     */
+    default <V> IOFunction<V, R> compose(final IOFunction<? super V, ? extends T> before) {
+        Objects.requireNonNull(before, "before");
+        return (final V v) -> apply(before.apply(v));
+    }
+
+    /**
+     * Returns a composed {@link IOFunction} that first applies the {@code before} function to its input, and then applies
+     * this function to the result. If evaluation of either function throws an exception, it is relayed to the caller of the
+     * composed function.
+     *
+     * @param before the supplier which feeds the application of this function
+     * @return a composed function that first applies the {@code before} function and then applies this function
+     * @throws NullPointerException if before is null
+     *
+     * @see #andThen(IOFunction)
+     */
+    default IOSupplier<R> compose(final IOSupplier<? extends T> before) {
+        Objects.requireNonNull(before, "before");
+        return () -> apply(before.get());
+    }
+
+    /**
+     * Returns a composed {@link IOFunction} that first applies the {@code before} function to its input, and then applies
+     * this function to the result. If evaluation of either function throws an exception, it is relayed to the caller of the
+     * composed function.
+     *
+     * @param before the supplier which feeds the application of this function
+     * @return a composed function that first applies the {@code before} function and then applies this function
+     * @throws NullPointerException if before is null
+     *
+     * @see #andThen(IOFunction)
+     */
+    default IOSupplier<R> compose(final Supplier<? extends T> before) {
+        Objects.requireNonNull(before, "before");
+        return () -> apply(before.get());
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOIterator.java b/src/main/java/org/apache/commons/io/function/IOIterator.java
new file mode 100644
index 0000000..dd64d23
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOIterator.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+/**
+ * Like {@link Iterator} but throws {@link IOException}.
+ *
+ * @param <E> the type of elements returned by this iterator.
+ * @since 2.12.0
+ */
+public interface IOIterator<E> {
+
+    /**
+     * Adapts the given Iterator as an IOIterator.
+     *
+     * @param <E> the type of the stream elements.
+     * @param iterator The iterator to adapt
+     * @return A new IOIterator
+     */
+    static <E> IOIterator<E> adapt(final Iterator<E> iterator) {
+        return IOIteratorAdapter.adapt(iterator);
+    }
+
+    /**
+     * Creates an {@link Iterator} for this instance that throws {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @return an {@link UncheckedIOException} {@link Iterator}.
+     */
+    default Iterator<E> asIterator() {
+        return new UncheckedIOIterator<>(this);
+    }
+
+    /**
+     * Like {@link Iterator#forEachRemaining(Consumer)}.
+     *
+     * @param action See delegate.
+     * @throws IOException if an I/O error occurs.
+     */
+    default void forEachRemaining(final IOConsumer<? super E> action) throws IOException {
+        Objects.requireNonNull(action);
+        while (hasNext()) {
+            action.accept(next());
+        }
+    }
+
+    /**
+     * Like {@link Iterator#hasNext()}.
+     *
+     * @return See delegate.
+     * @throws IOException if an I/O error occurs.
+     */
+    boolean hasNext() throws IOException;
+
+    /**
+     * Like {@link Iterator#next()}.
+     *
+     * @return See delegate.
+     * @throws IOException if an I/O error occurs.
+     * @throws NoSuchElementException if the iteration has no more elements
+     */
+    E next() throws IOException;
+
+    /**
+     * Like {@link Iterator#remove()}.
+     *
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused")
+    default void remove() throws IOException {
+        unwrap().remove();
+    }
+
+    /**
+     * Unwraps this instance and returns the underlying {@link Iterator}.
+     * <p>
+     * Implementations may not have anything to unwrap and that behavior is undefined for now.
+     * </p>
+     * @return the underlying Iterator.
+     */
+    Iterator<E> unwrap();
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOIteratorAdapter.java b/src/main/java/org/apache/commons/io/function/IOIteratorAdapter.java
new file mode 100644
index 0000000..c650f99
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOIteratorAdapter.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.Objects;
+
+/**
+ * Adapts an {@link Iterator} as an {@link IOIterator}.
+ *
+ * @param <E> the type of the stream elements.
+ */
+final class IOIteratorAdapter<E> implements IOIterator<E> {
+
+    static <E> IOIteratorAdapter<E> adapt(final Iterator<E> delegate) {
+        return new IOIteratorAdapter<>(delegate);
+    }
+
+    private final Iterator<E> delegate;
+
+    IOIteratorAdapter(final Iterator<E> delegate) {
+        this.delegate = Objects.requireNonNull(delegate, "delegate");
+    }
+
+    @Override
+    public boolean hasNext() throws IOException {
+        return delegate.hasNext();
+    }
+
+    @Override
+    public E next() throws IOException {
+        return delegate.next();
+    }
+
+    @Override
+    public Iterator<E> unwrap() {
+        return delegate;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOPredicate.java b/src/main/java/org/apache/commons/io/function/IOPredicate.java
new file mode 100644
index 0000000..c2170af
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOPredicate.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Objects;
+import java.util.function.Predicate;
+
+/**
+ * Like {@link Predicate} but throws {@link IOException}.
+ *
+ * @param <T> the type of the input to the predicate
+ * @since 2.12.0
+ */
+@FunctionalInterface
+public interface IOPredicate<T> {
+
+    /**
+     * Always false.
+     *
+     * @param <T> the type of the input to the predicate
+     * @return a constant predicate that tests always false.
+     */
+    @SuppressWarnings("unchecked")
+    static <T> IOPredicate<T> alwaysFalse() {
+        return (IOPredicate<T>) Constants.IO_PREDICATE_FALSE;
+    }
+
+    /**
+     * Always true.
+     *
+     * @param <T> the type of the input to the predicate
+     * @return a constant predicate that tests always true.
+     */
+    @SuppressWarnings("unchecked")
+    static <T> IOPredicate<T> alwaysTrue() {
+        return (IOPredicate<T>) Constants.IO_PREDICATE_TRUE;
+    }
+
+    /**
+     * Creates a predicate that tests if two arguments are equal using {@link Objects#equals(Object, Object)}.
+     *
+     * @param <T> the type of arguments to the predicate
+     * @param target the object to compare for equality, may be {@code null}
+     * @return a predicate that tests if two arguments are equal using {@link Objects#equals(Object, Object)}
+     */
+    static <T> IOPredicate<T> isEqual(final Object target) {
+        return null == target ? Objects::isNull : object -> target.equals(object);
+    }
+
+    /**
+     * Creates a composed predicate that represents a short-circuiting logical AND of this predicate and another. When
+     * evaluating the composed predicate, if this predicate is {@code false}, then the {@code other} predicate is not
+     * evaluated.
+     *
+     * <p>
+     * Any exceptions thrown during evaluation of either predicate are relayed to the caller; if evaluation of this
+     * predicate throws an exception, the {@code other} predicate will not be evaluated.
+     * </p>
+     *
+     * @param other a predicate that will be logically-ANDed with this predicate
+     * @return a composed predicate that represents the short-circuiting logical AND of this predicate and the {@code other}
+     *         predicate
+     * @throws NullPointerException if other is null
+     */
+    default IOPredicate<T> and(final IOPredicate<? super T> other) {
+        Objects.requireNonNull(other);
+        return t -> test(t) && other.test(t);
+    }
+
+    /**
+     * Creates a {@link Predicate} for this instance that throws {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @return an UncheckedIOException Predicate.
+     */
+    default Predicate<T> asPredicate() {
+        return t -> Uncheck.test(this, t);
+    }
+
+    /**
+     * Creates a predicate that represents the logical negation of this predicate.
+     *
+     * @return a predicate that represents the logical negation of this predicate
+     */
+    default IOPredicate<T> negate() {
+        return t -> !test(t);
+    }
+
+    /**
+     * Creates a composed predicate that represents a short-circuiting logical OR of this predicate and another. When
+     * evaluating the composed predicate, if this predicate is {@code true}, then the {@code other} predicate is not
+     * evaluated.
+     *
+     * <p>
+     * Any exceptions thrown during evaluation of either predicate are relayed to the caller; if evaluation of this
+     * predicate throws an exception, the {@code other} predicate will not be evaluated.
+     * </p>
+     *
+     * @param other a predicate that will be logically-ORed with this predicate
+     * @return a composed predicate that represents the short-circuiting logical OR of this predicate and the {@code other}
+     *         predicate
+     * @throws NullPointerException if other is null
+     */
+    default IOPredicate<T> or(final IOPredicate<? super T> other) {
+        Objects.requireNonNull(other);
+        return t -> test(t) || other.test(t);
+    }
+
+    /**
+     * Evaluates this predicate on the given argument.
+     *
+     * @param t the input argument
+     * @return {@code true} if the input argument matches the predicate, otherwise {@code false}
+     * @throws IOException if an I/O error occurs.
+     */
+    boolean test(T t) throws IOException;
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOQuadFunction.java b/src/main/java/org/apache/commons/io/function/IOQuadFunction.java
new file mode 100644
index 0000000..161fe6e
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOQuadFunction.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.util.Objects;
+import java.util.function.Function;
+
+/**
+ * Represents a function that accepts four arguments and produces a result. This is the four-arity specialization of
+ * {@link IOFunction}.
+ *
+ * <p>
+ * This is a <a href="package-summary.html">functional interface</a> whose functional method is
+ * {@link #apply(Object, Object, Object, Object)}.
+ * </p>
+ *
+ * @param <T> the type of the first argument to the function
+ * @param <U> the type of the second argument to the function
+ * @param <V> the type of the third argument to the function
+ * @param <W> the type of the fourth argument to the function
+ * @param <R> the type of the result of the function
+ *
+ * @see Function
+ * @since 2.12.0
+ */
+@FunctionalInterface
+public interface IOQuadFunction<T, U, V, W, R> {
+
+    /**
+     * Creates a composed function that first applies this function to its input, and then applies the {@code after}
+     * function to the result. If evaluation of either function throws an exception, it is relayed to the caller of the
+     * composed function.
+     *
+     * @param <X> the type of output of the {@code after} function, and of the composed function
+     * @param after the function to apply after this function is applied
+     * @return a composed function that first applies this function and then applies the {@code after} function
+     * @throws NullPointerException if after is null
+     */
+    default <X> IOQuadFunction<T, U, V, W, X> andThen(final IOFunction<? super R, ? extends X> after) {
+        Objects.requireNonNull(after);
+        return (final T t, final U u, final V v, final W w) -> after.apply(apply(t, u, v, w));
+    }
+
+    /**
+     * Applies this function to the given arguments.
+     *
+     * @param t the first function argument
+     * @param u the second function argument
+     * @param v the third function argument
+     * @param w the fourth function argument
+     * @return the function result
+     * @throws IOException if an I/O error occurs.
+     */
+    R apply(T t, U u, V v, W w) throws IOException;
+}
diff --git a/src/main/java/org/apache/commons/io/function/IORunnable.java b/src/main/java/org/apache/commons/io/function/IORunnable.java
new file mode 100644
index 0000000..60b76bd
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IORunnable.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+
+/**
+ * Like {@link Runnable} but throws {@link IOException}.
+ *
+ * @since 2.12.0
+ */
+@FunctionalInterface
+public interface IORunnable {
+
+    /**
+     * Creates a {@link Runnable} for this instance that throws {@link UncheckedIOException} instead of {@link IOException}.
+     *
+     * @return an UncheckedIOException Predicate.
+     */
+    default Runnable asRunnable() {
+        return () -> Uncheck.run(this);
+    }
+
+    /**
+     * Like {@link Runnable#run()} but throws {@link IOException}.
+     *
+     * @throws IOException if an I/O error occurs.
+     */
+    void run() throws IOException;
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOSpliterator.java b/src/main/java/org/apache/commons/io/function/IOSpliterator.java
new file mode 100644
index 0000000..550f13a
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOSpliterator.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Objects;
+import java.util.Spliterator;
+import java.util.function.Consumer;
+
+/**
+ * Like {@link Spliterator} but throws {@link IOException}.
+ *
+ * @param <T> the type of elements returned by this IOSpliterator.
+ * @since 2.12.0
+ */
+public interface IOSpliterator<T> {
+
+    /**
+     * Adapts the given Spliterator as an IOSpliterator.
+     *
+     * @param <E> the type of the stream elements.
+     * @param iterator The iterator to adapt
+     * @return A new IOSpliterator
+     */
+    static <E> IOSpliterator<E> adapt(final Spliterator<E> iterator) {
+        return IOSpliteratorAdapter.adapt(iterator);
+    }
+
+    /**
+     * Creates a {@link Spliterator} for this instance that throws {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @return an {@link UncheckedIOException} {@link Spliterator}.
+     */
+    default Spliterator<T> asSpliterator() {
+        return new UncheckedIOSpliterator<>(this);
+    }
+
+    /**
+     * Like {@link Spliterator#characteristics()}.
+     *
+     * @return a representation of characteristics
+     */
+    default int characteristics() {
+        return unwrap().characteristics();
+    }
+
+    /**
+     * Like {@link Spliterator#estimateSize()}.
+     *
+     *
+     * @return the estimated size, or {@code Long.MAX_VALUE} if infinite, unknown, or too expensive to compute.
+     */
+    default long estimateSize() {
+        return unwrap().estimateSize();
+    }
+
+    /**
+     * Like {@link Spliterator#forEachRemaining(Consumer)}.
+     *
+     * @param action The action
+     * @throws NullPointerException if the specified action is null
+     */
+    default void forEachRemaining(final IOConsumer<? super T> action) {
+        while (tryAdvance(action)) { // NOPMD
+        }
+    }
+
+    /**
+     * Like {@link Spliterator#getComparator()}.
+     *
+     * @return a Comparator, or {@code null} if the elements are sorted in the natural order.
+     * @throws IllegalStateException if the spliterator does not report a characteristic of {@code SORTED}.
+     */
+    @SuppressWarnings("unchecked")
+    default IOComparator<? super T> getComparator() {
+        return (IOComparator<T>) unwrap().getComparator();
+    }
+
+    /**
+     * Like {@link Spliterator#getExactSizeIfKnown()}.
+     *
+     * @return the exact size, if known, else {@code -1}.
+     */
+    default long getExactSizeIfKnown() {
+        return unwrap().getExactSizeIfKnown();
+    }
+
+    /**
+     * Like {@link Spliterator#hasCharacteristics(int)}.
+     *
+     * @param characteristics the characteristics to check for
+     * @return {@code true} if all the specified characteristics are present, else {@code false}
+     */
+    default boolean hasCharacteristics(final int characteristics) {
+        return unwrap().hasCharacteristics(characteristics);
+    }
+
+    /**
+     * Like {@link Spliterator#tryAdvance(Consumer)}.
+     *
+     * @param action The action
+     * @return {@code false} if no remaining elements existed upon entry to this method, else {@code true}.
+     * @throws NullPointerException if the specified action is null
+     */
+    default boolean tryAdvance(IOConsumer<? super T> action) {
+        return unwrap().tryAdvance(Objects.requireNonNull(action, "action").asConsumer());
+    }
+
+    /**
+     * Like {@link Spliterator#trySplit()}.
+     *
+     * @return a {@code Spliterator} covering some portion of the elements, or {@code null} if this spliterator cannot be
+     *         split
+     */
+    default IOSpliterator<T> trySplit() {
+        return adapt(unwrap().trySplit());
+    }
+
+    /**
+     * Unwraps this instance and returns the underlying {@link Spliterator}.
+     * <p>
+     * Implementations may not have anything to unwrap and that behavior is undefined for now.
+     * </p>
+     *
+     * @return the underlying Spliterator.
+     */
+    Spliterator<T> unwrap();
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOSpliteratorAdapter.java b/src/main/java/org/apache/commons/io/function/IOSpliteratorAdapter.java
new file mode 100644
index 0000000..8c3101c
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOSpliteratorAdapter.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.util.Objects;
+import java.util.Spliterator;
+
+/**
+ * Adapts an {@link Spliterator} as an {@link IOSpliterator}.
+ *
+ * @param <T> the type of the stream elements.
+ */
+final class IOSpliteratorAdapter<T> implements IOSpliterator<T> {
+
+    static <E> IOSpliteratorAdapter<E> adapt(final Spliterator<E> delegate) {
+        return new IOSpliteratorAdapter<>(delegate);
+    }
+
+    private final Spliterator<T> delegate;
+
+    IOSpliteratorAdapter(final Spliterator<T> delegate) {
+        this.delegate = Objects.requireNonNull(delegate, "delegate");
+    }
+
+    @Override
+    public Spliterator<T> unwrap() {
+        return delegate;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOStream.java b/src/main/java/org/apache/commons/io/function/IOStream.java
new file mode 100644
index 0000000..116a7a4
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOStream.java
@@ -0,0 +1,597 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Spliterator;
+import java.util.Spliterators;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BiFunction;
+import java.util.function.IntFunction;
+import java.util.function.ToDoubleFunction;
+import java.util.function.ToIntFunction;
+import java.util.function.ToLongFunction;
+import java.util.function.UnaryOperator;
+import java.util.stream.Collector;
+import java.util.stream.DoubleStream;
+import java.util.stream.IntStream;
+import java.util.stream.LongStream;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+import org.apache.commons.io.IOExceptionList;
+
+/**
+ * Like {@link Stream} but throws {@link IOException}.
+ *
+ * @param <T> the type of the stream elements.
+ * @since 2.12.0
+ */
+public interface IOStream<T> extends IOBaseStream<T, IOStream<T>, Stream<T>> {
+
+    /**
+     * Constructs a new IOStream for the given Stream.
+     *
+     * @param <T> the type of the stream elements.
+     * @param stream The stream to delegate.
+     * @return a new IOStream.
+     */
+    static <T> IOStream<T> adapt(final Stream<T> stream) {
+        return IOStreamAdapter.adapt(stream);
+    }
+
+    /**
+     * This class' version of {@link Stream#empty()}.
+     *
+     * @param <T> the type of the stream elements
+     * @return an empty sequential {@code IOStreamImpl}.
+     * @see Stream#empty()
+     */
+    static <T> IOStream<T> empty() {
+        return IOStreamAdapter.adapt(Stream.empty());
+    }
+
+    /**
+     * Performs an action for each element gathering any exceptions.
+     *
+     * @param action The action to apply to each element.
+     * @throws IOExceptionList if any I/O errors occur.
+     */
+    default void forAll(final IOConsumer<T> action) throws IOExceptionList {
+        forAll(action, (i, e) -> e);
+    }
+
+    /**
+     * Performs an action for each element gathering any exceptions.
+     *
+     * @param action The action to apply to each element.
+     * @param exSupplier The exception supplier.
+     * @throws IOExceptionList if any I/O errors occur.
+     */
+    default void forAll(final IOConsumer<T> action, final BiFunction<Integer, IOException, IOException> exSupplier) throws IOExceptionList {
+        final AtomicReference<List<IOException>> causeList = new AtomicReference<>();
+        final AtomicInteger index = new AtomicInteger();
+        final IOConsumer<T> safeAction = IOStreams.toIOConsumer(action);
+        unwrap().forEach(e -> {
+            try {
+                safeAction.accept(e);
+            } catch (final IOException innerEx) {
+                if (causeList.get() == null) {
+                    // Only allocate if required
+                    causeList.set(new ArrayList<>());
+                }
+                if (exSupplier != null) {
+                    causeList.get().add(exSupplier.apply(index.get(), innerEx));
+                }
+            }
+            index.incrementAndGet();
+        });
+        IOExceptionList.checkEmpty(causeList.get(), null);
+    }
+
+    /**
+     * Like {@link Stream#iterate(Object, UnaryOperator)} but for IO.
+     *
+     * @param <T> the type of stream elements.
+     * @param seed the initial element.
+     * @param f a function to be applied to the previous element to produce a new element.
+     * @return a new sequential {@code IOStream}.
+     */
+    static <T> IOStream<T> iterate(final T seed, final IOUnaryOperator<T> f) {
+        Objects.requireNonNull(f);
+        final Iterator<T> iterator = new Iterator<T>() {
+            @SuppressWarnings("unchecked")
+            T t = (T) IOStreams.NONE;
+
+            @Override
+            public boolean hasNext() {
+                return true;
+            }
+
+            @Override
+            public T next() {
+                return t = t == IOStreams.NONE ? seed : Erase.apply(f, t);
+            }
+        };
+        return adapt(StreamSupport.stream(Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED | Spliterator.IMMUTABLE), false));
+    }
+
+    /**
+     * Null-safe version of {@link StreamSupport#stream(java.util.Spliterator, boolean)}.
+     *
+     * Copied from Apache Commons Lang.
+     *
+     * @param <T> the type of stream elements.
+     * @param values the elements of the new stream, may be {@code null}.
+     * @return the new stream on {@code values} or {@link Stream#empty()}.
+     */
+    @SuppressWarnings("resource") // call to #empty()
+    static <T> IOStream<T> of(final Iterable<T> values) {
+        return values == null ? empty() : adapt(StreamSupport.stream(values.spliterator(), false));
+    }
+
+    /**
+     * Null-safe version of {@link Stream#of(Object[])} for an IO stream.
+     *
+     * @param <T> the type of stream elements.
+     * @param values the elements of the new stream, may be {@code null}.
+     * @return the new stream on {@code values} or {@link Stream#empty()}.
+     */
+    @SuppressWarnings("resource")
+    @SafeVarargs // Creating a stream from an array is safe
+    static <T> IOStream<T> of(final T... values) {
+        return values == null || values.length == 0 ? empty() : adapt(Arrays.stream(values));
+    }
+
+    /**
+     * Returns a sequential {@code IOStreamImpl} containing a single element.
+     *
+     * @param t the single element
+     * @param <T> the type of stream elements
+     * @return a singleton sequential stream
+     */
+    static <T> IOStream<T> of(final T t) {
+        return adapt(Stream.of(t));
+    }
+
+    /**
+     * Like {@link Stream#allMatch(java.util.function.Predicate)} but throws {@link IOException}.
+     *
+     * @param predicate {@link Stream#allMatch(java.util.function.Predicate)}.
+     * @return Like {@link Stream#allMatch(java.util.function.Predicate)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default boolean allMatch(final IOPredicate<? super T> predicate) throws IOException {
+        return unwrap().allMatch(t -> Erase.test(predicate, t));
+    }
+
+    /**
+     * Like {@link Stream#anyMatch(java.util.function.Predicate)} but throws {@link IOException}.
+     *
+     * @param predicate {@link Stream#anyMatch(java.util.function.Predicate)}.
+     * @return Like {@link Stream#anyMatch(java.util.function.Predicate)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default boolean anyMatch(final IOPredicate<? super T> predicate) throws IOException {
+        return unwrap().anyMatch(t -> Erase.test(predicate, t));
+    }
+
+    /**
+     * TODO Package-private for now, needs IOCollector?
+     *
+     * Adding this method now and an IO version later is an issue because call sites would have to type-cast to pick one. It
+     * would be ideal to have only one.
+     *
+     * Like {@link Stream#collect(Collector)}.
+     *
+     * Package private for now.
+     *
+     * @param <R> Like {@link Stream#collect(Collector)}.
+     * @param <A> Like {@link Stream#collect(Collector)}.
+     * @param collector Like {@link Stream#collect(Collector)}.
+     * @return Like {@link Stream#collect(Collector)}.
+     */
+    default <R, A> R collect(final Collector<? super T, A, R> collector) {
+        return unwrap().collect(collector);
+    }
+
+    /**
+     * Like
+     * {@link Stream#collect(java.util.function.Supplier, java.util.function.BiConsumer, java.util.function.BiConsumer)}.
+     *
+     * @param <R> Like
+     *        {@link Stream#collect(java.util.function.Supplier, java.util.function.BiConsumer, java.util.function.BiConsumer)}.
+     * @param supplier Like
+     *        {@link Stream#collect(java.util.function.Supplier, java.util.function.BiConsumer, java.util.function.BiConsumer)}.
+     * @param accumulator Like
+     *        {@link Stream#collect(java.util.function.Supplier, java.util.function.BiConsumer, java.util.function.BiConsumer)}.
+     * @param combiner Like
+     *        {@link Stream#collect(java.util.function.Supplier, java.util.function.BiConsumer, java.util.function.BiConsumer)}.
+     * @return Like
+     *         {@link Stream#collect(java.util.function.Supplier, java.util.function.BiConsumer, java.util.function.BiConsumer)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default <R> R collect(final IOSupplier<R> supplier, final IOBiConsumer<R, ? super T> accumulator, final IOBiConsumer<R, R> combiner) throws IOException {
+        return unwrap().collect(() -> Erase.get(supplier), (t, u) -> Erase.accept(accumulator, t, u), (t, u) -> Erase.accept(combiner, t, u));
+    }
+
+    /**
+     * Like {@link Stream#count()}.
+     *
+     * @return Like {@link Stream#count()}.
+     */
+    default long count() {
+        return unwrap().count();
+    }
+
+    /**
+     * Like {@link Stream#distinct()}.
+     *
+     * @return Like {@link Stream#distinct()}.
+     */
+    default IOStream<T> distinct() {
+        return adapt(unwrap().distinct());
+    }
+
+    /**
+     * Like {@link Stream#filter(java.util.function.Predicate)}.
+     *
+     * @param predicate Like {@link Stream#filter(java.util.function.Predicate)}.
+     * @return Like {@link Stream#filter(java.util.function.Predicate)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default IOStream<T> filter(final IOPredicate<? super T> predicate) throws IOException {
+        return adapt(unwrap().filter(t -> Erase.test(predicate, t)));
+    }
+
+    /**
+     * Like {@link Stream#findAny()}.
+     *
+     * @return Like {@link Stream#findAny()}.
+     */
+    default Optional<T> findAny() {
+        return unwrap().findAny();
+    }
+
+    /**
+     * Like {@link Stream#findFirst()}.
+     *
+     * @return Like {@link Stream#findFirst()}.
+     */
+    default Optional<T> findFirst() {
+        return unwrap().findFirst();
+    }
+
+    /**
+     * Like {@link Stream#flatMap(java.util.function.Function)}.
+     *
+     * @param <R> Like {@link Stream#flatMap(java.util.function.Function)}.
+     * @param mapper Like {@link Stream#flatMap(java.util.function.Function)}.
+     * @return Like {@link Stream#flatMap(java.util.function.Function)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default <R> IOStream<R> flatMap(final IOFunction<? super T, ? extends IOStream<? extends R>> mapper) throws IOException {
+        return adapt(unwrap().flatMap(t -> Erase.apply(mapper, t).unwrap()));
+    }
+
+    /**
+     * TODO Package-private for now, needs IODoubleStream?
+     *
+     * Adding this method now and an IO version later is an issue because call sites would have to type-cast to pick one. It
+     * would be ideal to have only one.
+     *
+     * Like {@link Stream#flatMapToDouble(java.util.function.Function)}.
+     *
+     * @param mapper Like {@link Stream#flatMapToDouble(java.util.function.Function)}.
+     * @return Like {@link Stream#flatMapToDouble(java.util.function.Function)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default DoubleStream flatMapToDouble(final IOFunction<? super T, ? extends DoubleStream> mapper) throws IOException {
+        return unwrap().flatMapToDouble(t -> Erase.apply(mapper, t));
+    }
+
+    /**
+     * TODO Package-private for now, needs IOIntStream?
+     *
+     * Adding this method now and an IO version later is an issue because call sites would have to type-cast to pick one. It
+     * would be ideal to have only one.
+     *
+     * Like {@link Stream#flatMapToInt(java.util.function.Function)}.
+     *
+     * @param mapper Like {@link Stream#flatMapToInt(java.util.function.Function)}.
+     * @return Like {@link Stream#flatMapToInt(java.util.function.Function)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default IntStream flatMapToInt(final IOFunction<? super T, ? extends IntStream> mapper) throws IOException {
+        return unwrap().flatMapToInt(t -> Erase.apply(mapper, t));
+    }
+
+    /**
+     * TODO Package-private for now, needs IOLongStream?
+     *
+     * Adding this method now and an IO version later is an issue because call sites would have to type-cast to pick one. It
+     * would be ideal to have only one.
+     *
+     * Like {@link Stream#flatMapToLong(java.util.function.Function)}.
+     *
+     * @param mapper Like {@link Stream#flatMapToLong(java.util.function.Function)}.
+     * @return Like {@link Stream#flatMapToLong(java.util.function.Function)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default LongStream flatMapToLong(final IOFunction<? super T, ? extends LongStream> mapper) throws IOException {
+        return unwrap().flatMapToLong(t -> Erase.apply(mapper, t));
+    }
+
+    /**
+     * Like {@link Stream#forEach(java.util.function.Consumer)} but throws {@link IOException}.
+     *
+     * @param action Like {@link Stream#forEach(java.util.function.Consumer)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default void forEach(final IOConsumer<? super T> action) throws IOException {
+        unwrap().forEach(e -> Erase.accept(action, e));
+    }
+
+    /**
+     * Like {@link Stream#forEachOrdered(java.util.function.Consumer)}.
+     *
+     * @param action Like {@link Stream#forEachOrdered(java.util.function.Consumer)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default void forEachOrdered(final IOConsumer<? super T> action) throws IOException {
+        unwrap().forEachOrdered(e -> Erase.accept(action, e));
+    }
+
+    /**
+     * Like {@link Stream#limit(long)}.
+     *
+     * @param maxSize Like {@link Stream#limit(long)}.
+     * @return Like {@link Stream#limit(long)}.
+     */
+    default IOStream<T> limit(final long maxSize) {
+        return adapt(unwrap().limit(maxSize));
+    }
+
+    /**
+     * Like {@link Stream#map(java.util.function.Function)}.
+     *
+     * @param <R> Like {@link Stream#map(java.util.function.Function)}.
+     * @param mapper Like {@link Stream#map(java.util.function.Function)}.
+     * @return Like {@link Stream#map(java.util.function.Function)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default <R> IOStream<R> map(final IOFunction<? super T, ? extends R> mapper) throws IOException {
+        return adapt(unwrap().map(t -> Erase.apply(mapper, t)));
+    }
+
+    /**
+     * TODO Package-private for now, needs IOToDoubleFunction?
+     *
+     * Adding this method now and an IO version later is an issue because call sites would have to type-cast to pick one. It
+     * would be ideal to have only one.
+     *
+     * Like {@link Stream#mapToDouble(ToDoubleFunction)}.
+     *
+     * Package private for now.
+     *
+     * @param mapper Like {@link Stream#mapToDouble(ToDoubleFunction)}.
+     * @return Like {@link Stream#mapToDouble(ToDoubleFunction)}.
+     */
+    default DoubleStream mapToDouble(final ToDoubleFunction<? super T> mapper) {
+        return unwrap().mapToDouble(mapper);
+    }
+
+    /**
+     * TODO Package-private for now, needs IOToIntFunction?
+     *
+     * Adding this method now and an IO version later is an issue because call sites would have to type-cast to pick one. It
+     * would be ideal to have only one.
+     *
+     * Like {@link Stream#mapToInt(ToIntFunction)}.
+     *
+     * Package private for now.
+     *
+     * @param mapper Like {@link Stream#mapToInt(ToIntFunction)}.
+     * @return Like {@link Stream#mapToInt(ToIntFunction)}.
+     */
+    default IntStream mapToInt(final ToIntFunction<? super T> mapper) {
+        return unwrap().mapToInt(mapper);
+    }
+
+    /**
+     * TODO Package-private for now, needs IOToLongFunction?
+     *
+     * Adding this method now and an IO version later is an issue because call sites would have to type-cast to pick one. It
+     * would be ideal to have only one.
+     *
+     * Like {@link Stream#mapToLong(ToLongFunction)}.
+     *
+     * Package private for now.
+     *
+     * @param mapper Like {@link Stream#mapToLong(ToLongFunction)}.
+     * @return Like {@link Stream#mapToLong(ToLongFunction)}.
+     */
+    default LongStream mapToLong(final ToLongFunction<? super T> mapper) {
+        return unwrap().mapToLong(mapper);
+    }
+
+    /**
+     * Like {@link Stream#max(java.util.Comparator)}.
+     *
+     * @param comparator Like {@link Stream#max(java.util.Comparator)}.
+     * @return Like {@link Stream#max(java.util.Comparator)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default Optional<T> max(final IOComparator<? super T> comparator) throws IOException {
+        return unwrap().max((t, u) -> Erase.compare(comparator, t, u));
+    }
+
+    /**
+     * Like {@link Stream#min(java.util.Comparator)}.
+     *
+     * @param comparator Like {@link Stream#min(java.util.Comparator)}.
+     * @return Like {@link Stream#min(java.util.Comparator)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default Optional<T> min(final IOComparator<? super T> comparator) throws IOException {
+        return unwrap().min((t, u) -> Erase.compare(comparator, t, u));
+    }
+
+    /**
+     * Like {@link Stream#noneMatch(java.util.function.Predicate)}.
+     *
+     * @param predicate Like {@link Stream#noneMatch(java.util.function.Predicate)}.
+     * @return Like {@link Stream#noneMatch(java.util.function.Predicate)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default boolean noneMatch(final IOPredicate<? super T> predicate) throws IOException {
+        return unwrap().noneMatch(t -> Erase.test(predicate, t));
+    }
+
+    /**
+     * Like {@link Stream#peek(java.util.function.Consumer)}.
+     *
+     * @param action Like {@link Stream#peek(java.util.function.Consumer)}.
+     * @return Like {@link Stream#peek(java.util.function.Consumer)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default IOStream<T> peek(final IOConsumer<? super T> action) throws IOException {
+        return adapt(unwrap().peek(t -> Erase.accept(action, t)));
+    }
+
+    /**
+     * Like {@link Stream#reduce(java.util.function.BinaryOperator)}.
+     *
+     * @param accumulator Like {@link Stream#reduce(java.util.function.BinaryOperator)}.
+     * @return Like {@link Stream#reduce(java.util.function.BinaryOperator)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default Optional<T> reduce(final IOBinaryOperator<T> accumulator) throws IOException {
+        return unwrap().reduce((t, u) -> Erase.apply(accumulator, t, u));
+    }
+
+    /**
+     * Like {@link Stream#reduce(Object, java.util.function.BinaryOperator)}.
+     *
+     * @param identity Like {@link Stream#reduce(Object, java.util.function.BinaryOperator)}.
+     * @param accumulator Like {@link Stream#reduce(Object, java.util.function.BinaryOperator)}.
+     * @return Like {@link Stream#reduce(Object, java.util.function.BinaryOperator)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default T reduce(final T identity, final IOBinaryOperator<T> accumulator) throws IOException {
+        return unwrap().reduce(identity, (t, u) -> Erase.apply(accumulator, t, u));
+    }
+
+    /**
+     * Like {@link Stream#reduce(Object, BiFunction, java.util.function.BinaryOperator)}.
+     *
+     * @param <U> Like {@link Stream#reduce(Object, BiFunction, java.util.function.BinaryOperator)}.
+     * @param identity Like {@link Stream#reduce(Object, BiFunction, java.util.function.BinaryOperator)}.
+     * @param accumulator Like {@link Stream#reduce(Object, BiFunction, java.util.function.BinaryOperator)}.
+     * @param combiner Like {@link Stream#reduce(Object, BiFunction, java.util.function.BinaryOperator)}.
+     * @return Like {@link Stream#reduce(Object, BiFunction, java.util.function.BinaryOperator)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default <U> U reduce(final U identity, final IOBiFunction<U, ? super T, U> accumulator, final IOBinaryOperator<U> combiner) throws IOException {
+        return unwrap().reduce(identity, (t, u) -> Erase.apply(accumulator, t, u), (t, u) -> Erase.apply(combiner, t, u));
+    }
+
+    /**
+     * Like {@link Stream#skip(long)}.
+     *
+     * @param n Like {@link Stream#skip(long)}.
+     * @return Like {@link Stream#skip(long)}.
+     */
+    default IOStream<T> skip(final long n) {
+        return adapt(unwrap().skip(n));
+    }
+
+    /**
+     * Like {@link Stream#sorted()}.
+     *
+     * @return Like {@link Stream#sorted()}.
+     */
+    default IOStream<T> sorted() {
+        return adapt(unwrap().sorted());
+    }
+
+    /**
+     * Like {@link Stream#sorted(java.util.Comparator)}.
+     *
+     * @param comparator Like {@link Stream#sorted(java.util.Comparator)}.
+     * @return Like {@link Stream#sorted(java.util.Comparator)}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("unused") // thrown by Erase.
+    default IOStream<T> sorted(final IOComparator<? super T> comparator) throws IOException {
+        return adapt(unwrap().sorted((t, u) -> Erase.compare(comparator, t, u)));
+    }
+
+    /**
+     * Like {@link Stream#toArray()}.
+     *
+     * @return {@link Stream#toArray()}.
+     */
+    default Object[] toArray() {
+        return unwrap().toArray();
+    }
+
+    /**
+     * TODO Package-private for now, needs IOIntFunction?
+     *
+     * Adding this method now and an IO version later is an issue because call sites would have to type-cast to pick one. It
+     * would be ideal to have only one.
+     *
+     * Like {@link Stream#toArray(IntFunction)}.
+     *
+     * Package private for now.
+     *
+     * @param <A> Like {@link Stream#toArray(IntFunction)}.
+     * @param generator Like {@link Stream#toArray(IntFunction)}.
+     * @return Like {@link Stream#toArray(IntFunction)}.
+     */
+    default <A> A[] toArray(final IntFunction<A[]> generator) {
+        return unwrap().toArray(generator);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOStreamAdapter.java b/src/main/java/org/apache/commons/io/function/IOStreamAdapter.java
new file mode 100644
index 0000000..aed21a7
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOStreamAdapter.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.util.stream.Stream;
+
+/**
+ * Adapts an {@link Stream} as an {@link IOStream}.
+ *
+ * Keep package-private for now.
+ *
+ * @param <T> the type of the stream elements.
+ */
+final class IOStreamAdapter<T> extends IOBaseStreamAdapter<T, IOStream<T>, Stream<T>> implements IOStream<T> {
+
+    @SuppressWarnings("resource")
+    static <T> IOStream<T> adapt(final Stream<T> delegate) {
+        return delegate != null ? new IOStreamAdapter<>(delegate) : IOStream.empty();
+    }
+
+    private IOStreamAdapter(final Stream<T> delegate) {
+        super(delegate);
+    }
+
+    @Override
+    public IOStream<T> wrap(final Stream<T> delegate) {
+        return unwrap() == delegate ? this : adapt(delegate);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOStreams.java b/src/main/java/org/apache/commons/io/function/IOStreams.java
new file mode 100644
index 0000000..70cac78
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOStreams.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.util.function.BiFunction;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+import org.apache.commons.io.IOExceptionList;
+import org.apache.commons.io.IOIndexedException;
+
+/**
+ * Keep this code package-private for now.
+ */
+final class IOStreams {
+
+    static final Object NONE = new Object();
+
+    static <T> void forAll(final Stream<T> stream, final IOConsumer<T> action) throws IOExceptionList {
+        forAll(stream, action, (i, e) -> e);
+    }
+
+    @SuppressWarnings("resource") // adapt()
+    static <T> void forAll(final Stream<T> stream, final IOConsumer<T> action, final BiFunction<Integer, IOException, IOException> exSupplier)
+        throws IOExceptionList {
+        IOStream.adapt(stream).forAll(action, IOIndexedException::new);
+    }
+
+    @SuppressWarnings("unused") // IOStreams.rethrow() throws
+    static <T> void forEach(final Stream<T> stream, final IOConsumer<T> action) throws IOException {
+        final IOConsumer<T> actualAction = toIOConsumer(action);
+        of(stream).forEach(e -> Erase.accept(actualAction, e));
+    }
+
+    /**
+     * Null-safe version of {@link StreamSupport#stream(java.util.Spliterator, boolean)}.
+     *
+     * Copied from Apache Commons Lang.
+     *
+     * @param <T> the type of stream elements.
+     * @param values the elements of the new stream, may be {@code null}.
+     * @return the new stream on {@code values} or {@link Stream#empty()}.
+     */
+    static <T> Stream<T> of(final Iterable<T> values) {
+        return values == null ? Stream.empty() : StreamSupport.stream(values.spliterator(), false);
+    }
+
+    static <T> Stream<T> of(final Stream<T> stream) {
+        return stream == null ? Stream.empty() : stream;
+    }
+
+    /**
+     * Null-safe version of {@link Stream#of(Object[])}.
+     *
+     * Copied from Apache Commons Lang.
+     *
+     * @param <T> the type of stream elements.
+     * @param values the elements of the new stream, may be {@code null}.
+     * @return the new stream on {@code values} or {@link Stream#empty()}.
+     */
+    @SafeVarargs // Creating a stream from an array is safe
+    static <T> Stream<T> of(final T... values) {
+        return values == null ? Stream.empty() : Stream.of(values);
+    }
+
+    static <T> IOConsumer<T> toIOConsumer(final IOConsumer<T> action) {
+        return action != null ? action : IOConsumer.noop();
+    }
+
+    private IOStreams() {
+        // no instances
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOSupplier.java b/src/main/java/org/apache/commons/io/function/IOSupplier.java
new file mode 100644
index 0000000..5a81b40
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOSupplier.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.function.Supplier;
+
+/**
+ * Like {@link Supplier} but throws {@link IOException}.
+ *
+ * @param <T> the return type of the operations.
+ * @since 2.7
+ */
+@FunctionalInterface
+public interface IOSupplier<T> {
+
+    /**
+     * Creates a {@link Supplier} for this instance that throws {@link UncheckedIOException} instead of {@link IOException}.
+     *
+     * @return an UncheckedIOException Supplier.
+     * @since 2.12.0
+     */
+    default Supplier<T> asSupplier() {
+        return () -> Uncheck.get(this);
+    }
+
+    /**
+     * Gets a result.
+     *
+     * @return a result
+     * @throws IOException if an I/O error occurs.
+     */
+    T get() throws IOException;
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOTriConsumer.java b/src/main/java/org/apache/commons/io/function/IOTriConsumer.java
new file mode 100644
index 0000000..27a629f
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOTriConsumer.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.util.Objects;
+import java.util.function.BiConsumer;
+
+/**
+ * Like {@link BiConsumer} but throws {@link IOException}.
+ *
+ * @param <T> the type of the first argument to the operation
+ * @param <U> the type of the second argument to the operation
+ * @param <V> the type of the third argument to the operation
+ *
+ * @see BiConsumer
+ * @since 2.12.0
+ */
+@FunctionalInterface
+public interface IOTriConsumer<T, U, V> {
+
+    /**
+     * Returns the no-op singleton.
+     *
+     * @param <T> the type of the first argument to the operation
+     * @param <U> the type of the second argument to the operation
+     * @param <V> the type of the third argument to the operation
+     * @return The no-op singleton.
+     */
+    static <T, U, V> IOTriConsumer<T, U, V> noop() {
+        return Constants.IO_TRI_CONSUMER;
+    }
+
+    /**
+     * Performs this operation on the given arguments.
+     *
+     * @param t the first input argument
+     * @param u the second input argument
+     * @param v the second third argument
+     * @throws IOException if an I/O error occurs.
+     */
+    void accept(T t, U u, V v) throws IOException;
+
+    /**
+     * Creates a composed {@link IOTriConsumer} that performs, in sequence, this operation followed by the {@code after}
+     * operation. If performing either operation throws an exception, it is relayed to the caller of the composed operation.
+     * If performing this operation throws an exception, the {@code after} operation will not be performed.
+     *
+     * @param after the operation to perform after this operation
+     * @return a composed {@link IOTriConsumer} that performs in sequence this operation followed by the {@code after}
+     *         operation
+     * @throws NullPointerException if {@code after} is null
+     */
+    default IOTriConsumer<T, U, V> andThen(final IOTriConsumer<? super T, ? super U, ? super V> after) {
+        Objects.requireNonNull(after);
+        return (t, u, v) -> {
+            accept(t, u, v);
+            after.accept(t, u, v);
+        };
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOTriFunction.java b/src/main/java/org/apache/commons/io/function/IOTriFunction.java
new file mode 100644
index 0000000..ddd3b21
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOTriFunction.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.util.Objects;
+import java.util.function.Function;
+
+/**
+ * Represents a function that accepts three arguments and produces a result. This is the three-arity specialization of
+ * {@link IOFunction}.
+ *
+ * <p>
+ * This is a <a href="package-summary.html">functional interface</a> whose functional method is
+ * {@link #apply(Object, Object, Object)}.
+ * </p>
+ *
+ * @param <T> the type of the first argument to the function
+ * @param <U> the type of the second argument to the function
+ * @param <V> the type of the third argument to the function
+ * @param <R> the type of the result of the function
+ *
+ * @see Function
+ * @since 2.12.0
+ */
+@FunctionalInterface
+public interface IOTriFunction<T, U, V, R> {
+
+    /**
+     * Creates a composed function that first applies this function to its input, and then applies the {@code after}
+     * function to the result. If evaluation of either function throws an exception, it is relayed to the caller of the
+     * composed function.
+     *
+     * @param <W> the type of output of the {@code after} function, and of the composed function
+     * @param after the function to apply after this function is applied
+     * @return a composed function that first applies this function and then applies the {@code after} function
+     * @throws NullPointerException if after is null
+     */
+    default <W> IOTriFunction<T, U, V, W> andThen(final IOFunction<? super R, ? extends W> after) {
+        Objects.requireNonNull(after);
+        return (final T t, final U u, final V v) -> after.apply(apply(t, u, v));
+    }
+
+    /**
+     * Applies this function to the given arguments.
+     *
+     * @param t the first function argument
+     * @param u the second function argument
+     * @param v the third function argument
+     * @return the function result
+     * @throws IOException if an I/O error occurs.
+     */
+    R apply(T t, U u, V v) throws IOException;
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/IOUnaryOperator.java b/src/main/java/org/apache/commons/io/function/IOUnaryOperator.java
new file mode 100644
index 0000000..cf6445d
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/IOUnaryOperator.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.function.UnaryOperator;
+
+/**
+ * Like {@link UnaryOperator} but throws {@link IOException}.
+ *
+ * @param <T> the type of the operand and result of the operator.
+ *
+ * @see UnaryOperator
+ * @see IOFunction
+ * @since 2.12.0
+ */
+@FunctionalInterface
+public interface IOUnaryOperator<T> extends IOFunction<T, T> {
+
+    /**
+     * Creates a unary operator that always returns its input argument.
+     *
+     * @param <T> the type of the input and output of the operator.
+     * @return a unary operator that always returns its input argument.
+     */
+    static <T> IOUnaryOperator<T> identity() {
+        return t -> t;
+    }
+
+    /**
+     * Creates a {@link UnaryOperator} for this instance that throws {@link UncheckedIOException} instead of
+     * {@link IOException}.
+     *
+     * @return an unchecked BiFunction.
+     */
+    default UnaryOperator<T> asUnaryOperator() {
+        return t -> Uncheck.apply(this, t);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/Uncheck.java b/src/main/java/org/apache/commons/io/function/Uncheck.java
new file mode 100644
index 0000000..e18937f
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/Uncheck.java
@@ -0,0 +1,251 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+
+/**
+ * Unchecks calls by throwing {@link UncheckedIOException} instead of {@link IOException}.
+ *
+ * @since 2.12.0
+ */
+public final class Uncheck {
+
+    /**
+     * Accepts an IO consumer with the given arguments.
+     *
+     * @param <T> the first input type.
+     * @param <U> the second input type.
+     * @param t the first input argument.
+     * @param u the second input argument.
+     * @param consumer Consumes the value.
+     * @throws UncheckedIOException if an I/O error occurs.
+     */
+    public static <T, U> void accept(final IOBiConsumer<T, U> consumer, final T t, final U u) {
+        try {
+            consumer.accept(t, u);
+        } catch (final IOException e) {
+            throw wrap(e);
+        }
+    }
+
+    /**
+     * Accepts an IO consumer with the given argument.
+     *
+     * @param <T> the input type.
+     * @param t the input argument.
+     * @param consumer Consumes the value.
+     * @throws UncheckedIOException if an I/O error occurs.
+     */
+    public static <T> void accept(final IOConsumer<T> consumer, final T t) {
+        try {
+            consumer.accept(t);
+        } catch (final IOException e) {
+            throw wrap(e);
+        }
+    }
+
+    /**
+     * Accepts an IO consumer with the given arguments.
+     *
+     * @param <T> the first input type.
+     * @param <U> the second input type.
+     * @param <V> the third input type.
+     * @param t the first input argument.
+     * @param u the second input argument.
+     * @param v the third input argument.
+     * @param consumer Consumes the value.
+     * @throws UncheckedIOException if an I/O error occurs.
+     */
+    public static <T, U, V> void accept(final IOTriConsumer<T, U, V> consumer, final T t, final U u, final V v) {
+        try {
+            consumer.accept(t, u, v);
+        } catch (final IOException e) {
+            throw wrap(e);
+        }
+    }
+
+    /**
+     * Applies an IO function with the given arguments.
+     *
+     * @param <T> the first function argument type.
+     * @param <U> the second function argument type.
+     * @param <R> the return type.
+     * @param function the function.
+     * @param t the first function argument.
+     * @param u the second function argument.
+     * @return the function result.
+     * @throws UncheckedIOException if an I/O error occurs.
+     */
+    public static <T, U, R> R apply(final IOBiFunction<T, U, R> function, final T t, final U u) {
+        try {
+            return function.apply(t, u);
+        } catch (final IOException e) {
+            throw wrap(e);
+        }
+    }
+
+    /**
+     * Applies an IO function with the given arguments.
+     *
+     * @param function the function.
+     * @param <T> the first function argument type.
+     * @param <R> the return type.
+     * @param t the first function argument.
+     * @return the function result.
+     * @throws UncheckedIOException if an I/O error occurs.
+     */
+    public static <T, R> R apply(final IOFunction<T, R> function, final T t) {
+        try {
+            return function.apply(t);
+        } catch (final IOException e) {
+            throw wrap(e);
+        }
+    }
+
+    /**
+     * Applies an IO quad-function with the given arguments.
+     *
+     * @param function the function.
+     * @param <T> the first function argument type.
+     * @param <U> the second function argument type.
+     * @param <V> the third function argument type.
+     * @param <W> the fourth function argument type.
+     * @param <R> the return type.
+     * @param t the first function argument.
+     * @param u the second function argument.
+     * @param v the third function argument.
+     * @param w the fourth function argument.
+     * @return the function result.
+     * @throws UncheckedIOException if an I/O error occurs.
+     */
+    public static <T, U, V, W, R> R apply(final IOQuadFunction<T, U, V, W, R> function, final T t, final U u, final V v, final W w) {
+        try {
+            return function.apply(t, u, v, w);
+        } catch (final IOException e) {
+            throw wrap(e);
+        }
+    }
+
+    /**
+     * Applies an IO tri-function with the given arguments.
+     *
+     * @param <T> the first function argument type.
+     * @param <U> the second function argument type.
+     * @param <V> the third function argument type.
+     * @param <R> the return type.
+     * @param function the function.
+     * @param t the first function argument.
+     * @param u the second function argument.
+     * @param v the third function argument.
+     * @return the function result.
+     * @throws UncheckedIOException if an I/O error occurs.
+     */
+    public static <T, U, V, R> R apply(final IOTriFunction<T, U, V, R> function, final T t, final U u, final V v) {
+        try {
+            return function.apply(t, u, v);
+        } catch (final IOException e) {
+            throw wrap(e);
+        }
+    }
+
+    /**
+     * Compares the arguments with the comparator.
+     *
+     * @param <T> the first function argument type.
+     * @param comparator the function.
+     * @param t the first function argument.
+     * @param u the second function argument.
+     * @return the comparator result.
+     * @throws UncheckedIOException if an I/O error occurs.
+     */
+    public static <T> int compare(final IOComparator<T> comparator, final T t, final T u) {
+        try {
+            return comparator.compare(t, u);
+        } catch (final IOException e) {
+            throw wrap(e);
+        }
+    }
+
+    /**
+     * Gets the result from an IO supplier.
+     *
+     * @param <T> the return type of the operations.
+     * @param supplier Supplies the return value.
+     * @return result from the supplier.
+     * @throws UncheckedIOException if an I/O error occurs.
+     */
+    public static <T> T get(final IOSupplier<T> supplier) {
+        try {
+            return supplier.get();
+        } catch (final IOException e) {
+            throw wrap(e);
+        }
+    }
+
+    /**
+     * Runs an IO runnable.
+     *
+     * @param runnable The runnable to run.
+     * @throws UncheckedIOException if an I/O error occurs.
+     */
+    public static void run(final IORunnable runnable) {
+        try {
+            runnable.run();
+        } catch (final IOException e) {
+            throw wrap(e);
+        }
+    }
+
+    /**
+     * Tests an IO predicate.
+     *
+     * @param <T> the type of the input to the predicate.
+     * @param predicate the predicate.
+     * @param t the input to the predicate.
+     * @return {@code true} if the input argument matches the predicate, otherwise {@code false}.
+     */
+    public static <T> boolean test(final IOPredicate<T> predicate, final T t) {
+        try {
+            return predicate.test(t);
+        } catch (final IOException e) {
+            throw wrap(e);
+        }
+    }
+
+    /**
+     * Creates a new UncheckedIOException for the given detail message.
+     * <p>
+     * This method exists because there is no String constructor in {@link UncheckedIOException}.
+     * </p>
+     *
+     * @param e The exception to wrap.
+     * @return a new {@link UncheckedIOException}.
+     */
+    private static UncheckedIOException wrap(final IOException e) {
+        return new UncheckedIOException(e);
+    }
+
+    /**
+     * No instances needed.
+     */
+    private Uncheck() {
+        // no instances needed.
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/function/UncheckedIOBaseStream.java b/src/main/java/org/apache/commons/io/function/UncheckedIOBaseStream.java
new file mode 100644
index 0000000..3e033ee
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/UncheckedIOBaseStream.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Iterator;
+import java.util.Spliterator;
+import java.util.stream.BaseStream;
+
+/**
+ * An {@link BaseStream} for a {@link IOBaseStream} that throws {@link UncheckedIOException} instead of
+ * {@link IOException}.
+ *
+ * Keep package-private for now.
+ *
+ * @param <T> the type of the stream elements.
+ * @param <S> the type of the IO stream extending {@code IOBaseStream}.
+ * @param <B> the type of the stream extending {@code BaseStream}.
+ */
+class UncheckedIOBaseStream<T, S extends IOBaseStream<T, S, B>, B extends BaseStream<T, B>> implements BaseStream<T, B> {
+
+    private final S delegate;
+
+    UncheckedIOBaseStream(final S delegate) {
+        this.delegate = delegate;
+    }
+
+    @Override
+    public void close() {
+        delegate.close();
+    }
+
+    @Override
+    public boolean isParallel() {
+        return delegate.isParallel();
+    }
+
+    @Override
+    public Iterator<T> iterator() {
+        return delegate.iterator().asIterator();
+    }
+
+    @SuppressWarnings("resource")
+    @Override
+    public B onClose(final Runnable closeHandler) {
+        return Uncheck.apply(delegate::onClose, () -> closeHandler.run()).unwrap();
+    }
+
+    @SuppressWarnings("resource")
+    @Override
+    public B parallel() {
+        return delegate.parallel().unwrap();
+    }
+
+    @SuppressWarnings("resource")
+    @Override
+    public B sequential() {
+        return delegate.sequential().unwrap();
+    }
+
+    @Override
+    public Spliterator<T> spliterator() {
+        return delegate.spliterator().unwrap();
+    }
+
+    @SuppressWarnings("resource")
+    @Override
+    public B unordered() {
+        return delegate.unordered().unwrap();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/UncheckedIOIterator.java b/src/main/java/org/apache/commons/io/function/UncheckedIOIterator.java
new file mode 100644
index 0000000..4724526
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/UncheckedIOIterator.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Iterator;
+import java.util.Objects;
+
+/**
+ * An {@link Iterator} for a {@link IOIterator} that throws {@link UncheckedIOException} instead of {@link IOException}.
+ *
+ * Keep package-private for now.
+ *
+ * @param <E> the type of elements returned by this iterator.
+ */
+final class UncheckedIOIterator<E> implements Iterator<E> {
+
+    private final IOIterator<E> delegate;
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param delegate The delegate
+     */
+    UncheckedIOIterator(final IOIterator<E> delegate) {
+        this.delegate = Objects.requireNonNull(delegate, "delegate");
+    }
+
+    @Override
+    public boolean hasNext() {
+        return Uncheck.get(delegate::hasNext);
+    }
+
+    @Override
+    public E next() {
+        return Uncheck.get(delegate::next);
+    }
+
+    @Override
+    public void remove() {
+        Uncheck.run(delegate::remove);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/function/UncheckedIOSpliterator.java b/src/main/java/org/apache/commons/io/function/UncheckedIOSpliterator.java
new file mode 100644
index 0000000..fae6fa6
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/UncheckedIOSpliterator.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Comparator;
+import java.util.Objects;
+import java.util.Spliterator;
+import java.util.function.Consumer;
+
+/**
+ * A {@link Spliterator} for an {@link IOSpliterator} that throws {@link UncheckedIOException} instead of
+ * {@link IOException}.
+ *
+ * Keep package-private for now.
+ *
+ * @param <T> the type of elements returned by this iterator.
+ */
+final class UncheckedIOSpliterator<T> implements Spliterator<T> {
+
+    private final IOSpliterator<T> delegate;
+
+    UncheckedIOSpliterator(final IOSpliterator<T> delegate) {
+        this.delegate = Objects.requireNonNull(delegate, "delegate");
+    }
+
+    @Override
+    public int characteristics() {
+        return delegate.characteristics();
+    }
+
+    @Override
+    public long estimateSize() {
+        return delegate.estimateSize();
+    }
+
+    @Override
+    public void forEachRemaining(final Consumer<? super T> action) {
+        Uncheck.accept(delegate::forEachRemaining, action::accept);
+    }
+
+    @Override
+    public Comparator<? super T> getComparator() {
+        return delegate.getComparator().asComparator();
+    }
+
+    @Override
+    public long getExactSizeIfKnown() {
+        return delegate.getExactSizeIfKnown();
+    }
+
+    @Override
+    public boolean hasCharacteristics(final int characteristics) {
+        return delegate.hasCharacteristics(characteristics);
+    }
+
+    @Override
+    public boolean tryAdvance(final Consumer<? super T> action) {
+        return Uncheck.apply(delegate::tryAdvance, action::accept);
+    }
+
+    @Override
+    public Spliterator<T> trySplit() {
+        return Uncheck.get(delegate::trySplit).unwrap();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/function/package-info.java b/src/main/java/org/apache/commons/io/function/package-info.java
new file mode 100644
index 0000000..2ae667a
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/function/package-info.java
@@ -0,0 +1,21 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * This package defines IO-only related functional interfaces for lambda expressions and method references.
+ */
+package org.apache.commons.io.function;
diff --git a/src/main/java/org/apache/commons/io/input/AbstractCharacterFilterReader.java b/src/main/java/org/apache/commons/io/input/AbstractCharacterFilterReader.java
new file mode 100644
index 0000000..bc93cc1
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/AbstractCharacterFilterReader.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.FilterReader;
+import java.io.IOException;
+import java.io.Reader;
+import java.util.function.IntPredicate;
+
+/**
+ * A filter reader that filters out characters where subclasses decide which characters to filter out.
+ */
+public abstract class AbstractCharacterFilterReader extends FilterReader {
+
+    /**
+     * Skips nothing.
+     *
+     * @since 2.9.0
+     */
+    protected static final IntPredicate SKIP_NONE = ch -> false;
+
+    private final IntPredicate skip;
+
+    /**
+     * Constructs a new reader.
+     *
+     * @param reader the reader to filter
+     */
+    protected AbstractCharacterFilterReader(final Reader reader) {
+        this(reader, SKIP_NONE);
+    }
+
+    /**
+     * Constructs a new reader.
+     *
+     * @param reader the reader to filter.
+     * @param skip Skip test.
+     * @since 2.9.0
+     */
+    protected AbstractCharacterFilterReader(final Reader reader, final IntPredicate skip) {
+        super(reader);
+        this.skip = skip == null ? SKIP_NONE : skip;
+    }
+
+    /**
+     * Returns true if the given character should be filtered out, false to keep the character.
+     *
+     * @param ch the character to test.
+     * @return true if the given character should be filtered out, false to keep the character.
+     */
+    protected boolean filter(final int ch) {
+        return skip.test(ch);
+    }
+
+    @Override
+    public int read() throws IOException {
+        int ch;
+        do {
+            ch = in.read();
+        } while (ch != EOF && filter(ch));
+        return ch;
+    }
+
+    @Override
+    public int read(final char[] cbuf, final int off, final int len) throws IOException {
+        final int read = super.read(cbuf, off, len);
+        if (read == EOF) {
+            return EOF;
+        }
+        int pos = off - 1;
+        for (int readPos = off; readPos < off + read; readPos++) {
+            if (filter(cbuf[readPos])) {
+                continue;
+            }
+            pos++;
+            if (pos < readPos) {
+                cbuf[pos] = cbuf[readPos];
+            }
+        }
+        return pos - off + 1;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/AutoCloseInputStream.java b/src/main/java/org/apache/commons/io/input/AutoCloseInputStream.java
new file mode 100644
index 0000000..bf3d317
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/AutoCloseInputStream.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Proxy stream that closes and discards the underlying stream as soon as the
+ * end of input has been reached or when the stream is explicitly closed.
+ * Not even a reference to the underlying stream is kept after it has been
+ * closed, so any allocated in-memory buffers can be freed even if the
+ * client application still keeps a reference to the proxy stream.
+ * <p>
+ * This class is typically used to release any resources related to an open
+ * stream as soon as possible even if the client application (by not explicitly
+ * closing the stream when no longer needed) or the underlying stream (by not
+ * releasing resources once the last byte has been read) do not do that.
+ * </p>
+ *
+ * @since 1.4
+ */
+public class AutoCloseInputStream extends ProxyInputStream {
+
+    /**
+     * Creates an automatically closing proxy for the given input stream.
+     *
+     * @param in underlying input stream
+     */
+    public AutoCloseInputStream(final InputStream in) {
+        super(in);
+    }
+
+    /**
+     * Automatically closes the stream if the end of stream was reached.
+     *
+     * @param n number of bytes read, or -1 if no more bytes are available
+     * @throws IOException if the stream could not be closed
+     * @since 2.0
+     */
+    @Override
+    protected void afterRead(final int n) throws IOException {
+        if (n == EOF) {
+            close();
+        }
+    }
+
+    /**
+     * Closes the underlying input stream and replaces the reference to it
+     * with a {@link ClosedInputStream} instance.
+     * <p>
+     * This method is automatically called by the read methods when the end
+     * of input has been reached.
+     * <p>
+     * Note that it is safe to call this method any number of times. The original
+     * underlying input stream is closed and discarded only once when this
+     * method is first called.
+     *
+     * @throws IOException if the underlying input stream can not be closed
+     */
+    @Override
+    public void close() throws IOException {
+        in.close();
+        in = ClosedInputStream.INSTANCE;
+    }
+
+    /**
+     * Ensures that the stream is closed before it gets garbage-collected.
+     * As mentioned in {@link #close()}, this is a no-op if the stream has
+     * already been closed.
+     * @throws Throwable if an error occurs
+     */
+    @Override
+    protected void finalize() throws Throwable {
+        close();
+        super.finalize();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/BOMInputStream.java b/src/main/java/org/apache/commons/io/input/BOMInputStream.java
new file mode 100644
index 0000000..7098b4c
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/BOMInputStream.java
@@ -0,0 +1,389 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+
+import org.apache.commons.io.ByteOrderMark;
+import org.apache.commons.io.IOUtils;
+
+/**
+ * This class is used to wrap a stream that includes an encoded {@link ByteOrderMark} as its first bytes.
+ * <p>
+ * This class detects these bytes and, if required, can automatically skip them and return the subsequent byte as the
+ * first byte in the stream.
+ * </p>
+ * <p>
+ * The {@link ByteOrderMark} implementation has the following predefined BOMs:
+ * </p>
+ * <ul>
+ * <li>UTF-8 - {@link ByteOrderMark#UTF_8}</li>
+ * <li>UTF-16BE - {@link ByteOrderMark#UTF_16LE}</li>
+ * <li>UTF-16LE - {@link ByteOrderMark#UTF_16BE}</li>
+ * <li>UTF-32BE - {@link ByteOrderMark#UTF_32LE}</li>
+ * <li>UTF-32LE - {@link ByteOrderMark#UTF_32BE}</li>
+ * </ul>
+ *
+ * <h2>Example 1 - Detect and exclude a UTF-8 BOM</h2>
+ *
+ * <pre>
+ * BOMInputStream bomIn = new BOMInputStream(in);
+ * if (bomIn.hasBOM()) {
+ *     // has a UTF-8 BOM
+ * }
+ * </pre>
+ *
+ * <h2>Example 2 - Detect a UTF-8 BOM (but don't exclude it)</h2>
+ *
+ * <pre>
+ * boolean include = true;
+ * BOMInputStream bomIn = new BOMInputStream(in, include);
+ * if (bomIn.hasBOM()) {
+ *     // has a UTF-8 BOM
+ * }
+ * </pre>
+ *
+ * <h2>Example 3 - Detect Multiple BOMs</h2>
+ *
+ * <pre>
+ * BOMInputStream bomIn = new BOMInputStream(in,
+ *   ByteOrderMark.UTF_16LE, ByteOrderMark.UTF_16BE,
+ *   ByteOrderMark.UTF_32LE, ByteOrderMark.UTF_32BE
+ *   );
+ * if (bomIn.hasBOM() == false) {
+ *     // No BOM found
+ * } else if (bomIn.hasBOM(ByteOrderMark.UTF_16LE)) {
+ *     // has a UTF-16LE BOM
+ * } else if (bomIn.hasBOM(ByteOrderMark.UTF_16BE)) {
+ *     // has a UTF-16BE BOM
+ * } else if (bomIn.hasBOM(ByteOrderMark.UTF_32LE)) {
+ *     // has a UTF-32LE BOM
+ * } else if (bomIn.hasBOM(ByteOrderMark.UTF_32BE)) {
+ *     // has a UTF-32BE BOM
+ * }
+ * </pre>
+ *
+ * @see org.apache.commons.io.ByteOrderMark
+ * @see <a href="http://en.wikipedia.org/wiki/Byte_order_mark">Wikipedia - Byte Order Mark</a>
+ * @since 2.0
+ */
+public class BOMInputStream extends ProxyInputStream {
+
+    /**
+     * Compares ByteOrderMark objects in descending length order.
+     */
+    private static final Comparator<ByteOrderMark> ByteOrderMarkLengthComparator = (bom1, bom2) -> Integer.compare(bom2.length(), bom1.length());
+
+    private final boolean include;
+
+    /**
+     * BOMs are sorted from longest to shortest.
+     */
+    private final List<ByteOrderMark> boms;
+    private ByteOrderMark byteOrderMark;
+    private int[] firstBytes;
+    private int fbLength;
+    private int fbIndex;
+    private int markFbIndex;
+    private boolean markedAtStart;
+
+    /**
+     * Constructs a new BOM InputStream that excludes a {@link ByteOrderMark#UTF_8} BOM.
+     *
+     * @param delegate
+     *            the InputStream to delegate to
+     */
+    public BOMInputStream(final InputStream delegate) {
+        this(delegate, false, ByteOrderMark.UTF_8);
+    }
+
+    /**
+     * Constructs a new BOM InputStream that detects a {@link ByteOrderMark#UTF_8} and optionally includes it.
+     *
+     * @param delegate
+     *            the InputStream to delegate to
+     * @param include
+     *            true to include the UTF-8 BOM or false to exclude it
+     */
+    public BOMInputStream(final InputStream delegate, final boolean include) {
+        this(delegate, include, ByteOrderMark.UTF_8);
+    }
+
+    /**
+     * Constructs a new BOM InputStream that detects the specified BOMs and optionally includes them.
+     *
+     * @param delegate
+     *            the InputStream to delegate to
+     * @param include
+     *            true to include the specified BOMs or false to exclude them
+     * @param boms
+     *            The BOMs to detect and optionally exclude
+     */
+    public BOMInputStream(final InputStream delegate, final boolean include, final ByteOrderMark... boms) {
+        super(delegate);
+        if (IOUtils.length(boms) == 0) {
+            throw new IllegalArgumentException("No BOMs specified");
+        }
+        this.include = include;
+        final List<ByteOrderMark> list = Arrays.asList(boms);
+        // Sort the BOMs to match the longest BOM first because some BOMs have the same starting two bytes.
+        list.sort(ByteOrderMarkLengthComparator);
+        this.boms = list;
+
+    }
+
+    /**
+     * Constructs a new BOM InputStream that excludes the specified BOMs.
+     *
+     * @param delegate
+     *            the InputStream to delegate to
+     * @param boms
+     *            The BOMs to detect and exclude
+     */
+    public BOMInputStream(final InputStream delegate, final ByteOrderMark... boms) {
+        this(delegate, false, boms);
+    }
+
+    /**
+     * Find a BOM with the specified bytes.
+     *
+     * @return The matched BOM or null if none matched
+     */
+    private ByteOrderMark find() {
+        return boms.stream().filter(this::matches).findFirst().orElse(null);
+    }
+
+    /**
+     * Gets the BOM (Byte Order Mark).
+     *
+     * @return The BOM or null if none
+     * @throws IOException
+     *             if an error reading the first bytes of the stream occurs
+     */
+    public ByteOrderMark getBOM() throws IOException {
+        if (firstBytes == null) {
+            fbLength = 0;
+            // BOMs are sorted from longest to shortest
+            final int maxBomSize = boms.get(0).length();
+            firstBytes = new int[maxBomSize];
+            // Read first maxBomSize bytes
+            for (int i = 0; i < firstBytes.length; i++) {
+                firstBytes[i] = in.read();
+                fbLength++;
+                if (firstBytes[i] < 0) {
+                    break;
+                }
+            }
+            // match BOM in firstBytes
+            byteOrderMark = find();
+            if (byteOrderMark != null && !include) {
+                if (byteOrderMark.length() < firstBytes.length) {
+                    fbIndex = byteOrderMark.length();
+                } else {
+                    fbLength = 0;
+                }
+            }
+        }
+        return byteOrderMark;
+    }
+
+    /**
+     * Gets the BOM charset Name - {@link ByteOrderMark#getCharsetName()}.
+     *
+     * @return The BOM charset Name or null if no BOM found
+     * @throws IOException
+     *             if an error reading the first bytes of the stream occurs
+     *
+     */
+    public String getBOMCharsetName() throws IOException {
+        getBOM();
+        return byteOrderMark == null ? null : byteOrderMark.getCharsetName();
+    }
+
+    /**
+     * Tests whether the stream contains one of the specified BOMs.
+     *
+     * @return true if the stream has one of the specified BOMs, otherwise false if it does not
+     * @throws IOException
+     *             if an error reading the first bytes of the stream occurs
+     */
+    public boolean hasBOM() throws IOException {
+        return getBOM() != null;
+    }
+
+    /**
+     * Tests whether the stream contains the specified BOM.
+     *
+     * @param bom
+     *            The BOM to check for
+     * @return true if the stream has the specified BOM, otherwise false if it does not
+     * @throws IllegalArgumentException
+     *             if the BOM is not one the stream is configured to detect
+     * @throws IOException
+     *             if an error reading the first bytes of the stream occurs
+     */
+    public boolean hasBOM(final ByteOrderMark bom) throws IOException {
+        if (!boms.contains(bom)) {
+            throw new IllegalArgumentException("Stream not configured to detect " + bom);
+        }
+        return Objects.equals(getBOM(), bom);
+    }
+
+    /**
+     * Invokes the delegate's {@code mark(int)} method.
+     *
+     * @param readlimit
+     *            read ahead limit
+     */
+    @Override
+    public synchronized void mark(final int readlimit) {
+        markFbIndex = fbIndex;
+        markedAtStart = firstBytes == null;
+        in.mark(readlimit);
+    }
+
+    /**
+     * Checks if the bytes match a BOM.
+     *
+     * @param bom
+     *            The BOM
+     * @return true if the bytes match the bom, otherwise false
+     */
+    private boolean matches(final ByteOrderMark bom) {
+        // if (bom.length() != fbLength) {
+        // return false;
+        // }
+        // firstBytes may be bigger than the BOM bytes
+        for (int i = 0; i < bom.length(); i++) {
+            if (bom.get(i) != firstBytes[i]) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Invokes the delegate's {@code read()} method, detecting and optionally skipping BOM.
+     *
+     * @return the byte read (excluding BOM) or -1 if the end of stream
+     * @throws IOException
+     *             if an I/O error occurs
+     */
+    @Override
+    public int read() throws IOException {
+        final int b = readFirstBytes();
+        return b >= 0 ? b : in.read();
+    }
+
+    /**
+     * Invokes the delegate's {@code read(byte[])} method, detecting and optionally skipping BOM.
+     *
+     * @param buf
+     *            the buffer to read the bytes into
+     * @return the number of bytes read (excluding BOM) or -1 if the end of stream
+     * @throws IOException
+     *             if an I/O error occurs
+     */
+    @Override
+    public int read(final byte[] buf) throws IOException {
+        return read(buf, 0, buf.length);
+    }
+
+    /**
+     * Invokes the delegate's {@code read(byte[], int, int)} method, detecting and optionally skipping BOM.
+     *
+     * @param buf
+     *            the buffer to read the bytes into
+     * @param off
+     *            The start offset
+     * @param len
+     *            The number of bytes to read (excluding BOM)
+     * @return the number of bytes read or -1 if the end of stream
+     * @throws IOException
+     *             if an I/O error occurs
+     */
+    @Override
+    public int read(final byte[] buf, int off, int len) throws IOException {
+        int firstCount = 0;
+        int b = 0;
+        while (len > 0 && b >= 0) {
+            b = readFirstBytes();
+            if (b >= 0) {
+                buf[off++] = (byte) (b & 0xFF);
+                len--;
+                firstCount++;
+            }
+        }
+        final int secondCount = in.read(buf, off, len);
+        return secondCount < 0 ? firstCount > 0 ? firstCount : EOF : firstCount + secondCount;
+    }
+
+    /**
+     * This method reads and either preserves or skips the first bytes in the stream. It behaves like the single-byte
+     * {@code read()} method, either returning a valid byte or -1 to indicate that the initial bytes have been
+     * processed already.
+     *
+     * @return the byte read (excluding BOM) or -1 if the end of stream
+     * @throws IOException
+     *             if an I/O error occurs
+     */
+    private int readFirstBytes() throws IOException {
+        getBOM();
+        return fbIndex < fbLength ? firstBytes[fbIndex++] : EOF;
+    }
+
+    /**
+     * Invokes the delegate's {@code reset()} method.
+     *
+     * @throws IOException
+     *             if an I/O error occurs
+     */
+    @Override
+    public synchronized void reset() throws IOException {
+        fbIndex = markFbIndex;
+        if (markedAtStart) {
+            firstBytes = null;
+        }
+
+        in.reset();
+    }
+
+    /**
+     * Invokes the delegate's {@code skip(long)} method, detecting and optionally skipping BOM.
+     *
+     * @param n
+     *            the number of bytes to skip
+     * @return the number of bytes to skipped or -1 if the end of stream
+     * @throws IOException
+     *             if an I/O error occurs
+     */
+    @Override
+    public long skip(final long n) throws IOException {
+        int skipped = 0;
+        while (n > skipped && readFirstBytes() >= 0) {
+            skipped++;
+        }
+        return in.skip(n - skipped) + skipped;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/BoundedInputStream.java b/src/main/java/org/apache/commons/io/input/BoundedInputStream.java
new file mode 100644
index 0000000..829f726
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/BoundedInputStream.java
@@ -0,0 +1,232 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * This is a stream that will only supply bytes up to a certain length - if its
+ * position goes above that, it will stop.
+ * <p>
+ * This is useful to wrap ServletInputStreams. The ServletInputStream will block
+ * if you try to read content from it that isn't there, because it doesn't know
+ * whether the content hasn't arrived yet or whether the content has finished.
+ * So, one of these, initialized with the Content-length sent in the
+ * ServletInputStream's header, will stop it blocking, providing it's been sent
+ * with a correct content length.
+ * </p>
+ *
+ * @since 2.0
+ */
+public class BoundedInputStream extends InputStream {
+
+    /** the wrapped input stream */
+    private final InputStream in;
+
+    /** the max length to provide */
+    private final long max;
+
+    /** the number of bytes already returned */
+    private long pos;
+
+    /** the marked position */
+    private long mark = EOF;
+
+    /** flag if close should be propagated */
+    private boolean propagateClose = true;
+
+    /**
+     * Creates a new {@link BoundedInputStream} that wraps the given input
+     * stream and is unlimited.
+     *
+     * @param in The wrapped input stream
+     */
+    public BoundedInputStream(final InputStream in) {
+        this(in, EOF);
+    }
+
+    /**
+     * Creates a new {@link BoundedInputStream} that wraps the given input
+     * stream and limits it to a certain size.
+     *
+     * @param in The wrapped input stream
+     * @param size The maximum number of bytes to return
+     */
+    public BoundedInputStream(final InputStream in, final long size) {
+        // Some badly designed methods - e.g. the servlet API - overload length
+        // such that "-1" means stream finished
+        this.max = size;
+        this.in = in;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public int available() throws IOException {
+        if (max >= 0 && pos >= max) {
+            return 0;
+        }
+        return in.available();
+    }
+
+    /**
+     * Invokes the delegate's {@code close()} method
+     * if {@link #isPropagateClose()} is {@code true}.
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void close() throws IOException {
+        if (propagateClose) {
+            in.close();
+        }
+    }
+
+    /**
+     * Indicates whether the {@link #close()} method
+     * should propagate to the underling {@link InputStream}.
+     *
+     * @return {@code true} if calling {@link #close()}
+     * propagates to the {@code close()} method of the
+     * underlying stream or {@code false} if it does not.
+     */
+    public boolean isPropagateClose() {
+        return propagateClose;
+    }
+
+    /**
+     * Invokes the delegate's {@code mark(int)} method.
+     * @param readlimit read ahead limit
+     */
+    @Override
+    public synchronized void mark(final int readlimit) {
+        in.mark(readlimit);
+        mark = pos;
+    }
+
+    /**
+     * Invokes the delegate's {@code markSupported()} method.
+     * @return true if mark is supported, otherwise false
+     */
+    @Override
+    public boolean markSupported() {
+        return in.markSupported();
+    }
+
+    /**
+     * Invokes the delegate's {@code read()} method if
+     * the current position is less than the limit.
+     * @return the byte read or -1 if the end of stream or
+     * the limit has been reached.
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int read() throws IOException {
+        if (max >= 0 && pos >= max) {
+            return EOF;
+        }
+        final int result = in.read();
+        pos++;
+        return result;
+    }
+
+    /**
+     * Invokes the delegate's {@code read(byte[])} method.
+     * @param b the buffer to read the bytes into
+     * @return the number of bytes read or -1 if the end of stream or
+     * the limit has been reached.
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int read(final byte[] b) throws IOException {
+        return this.read(b, 0, b.length);
+    }
+
+    /**
+     * Invokes the delegate's {@code read(byte[], int, int)} method.
+     * @param b the buffer to read the bytes into
+     * @param off The start offset
+     * @param len The number of bytes to read
+     * @return the number of bytes read or -1 if the end of stream or
+     * the limit has been reached.
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int read(final byte[] b, final int off, final int len) throws IOException {
+        if (max >= 0 && pos >= max) {
+            return EOF;
+        }
+        final long maxRead = max >= 0 ? Math.min(len, max - pos) : len;
+        final int bytesRead = in.read(b, off, (int) maxRead);
+
+        if (bytesRead == EOF) {
+            return EOF;
+        }
+
+        pos += bytesRead;
+        return bytesRead;
+    }
+
+    /**
+     * Invokes the delegate's {@code reset()} method.
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public synchronized void reset() throws IOException {
+        in.reset();
+        pos = mark;
+    }
+
+    /**
+     * Sets whether the {@link #close()} method
+     * should propagate to the underling {@link InputStream}.
+     *
+     * @param propagateClose {@code true} if calling
+     * {@link #close()} propagates to the {@code close()}
+     * method of the underlying stream or
+     * {@code false} if it does not.
+     */
+    public void setPropagateClose(final boolean propagateClose) {
+        this.propagateClose = propagateClose;
+    }
+
+    /**
+     * Invokes the delegate's {@code skip(long)} method.
+     * @param n the number of bytes to skip
+     * @return the actual number of bytes skipped
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public long skip(final long n) throws IOException {
+        final long toSkip = max >= 0 ? Math.min(n, max - pos) : n;
+        final long skippedBytes = in.skip(toSkip);
+        pos += skippedBytes;
+        return skippedBytes;
+    }
+
+    /**
+     * Invokes the delegate's {@code toString()} method.
+     * @return the delegate's {@code toString()}
+     */
+    @Override
+    public String toString() {
+        return in.toString();
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/BoundedReader.java b/src/main/java/org/apache/commons/io/input/BoundedReader.java
new file mode 100644
index 0000000..6a226e2
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/BoundedReader.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.IOException;
+import java.io.Reader;
+
+/**
+ * A reader that imposes a limit to the number of characters that can be read from an underlying reader, returning EOF
+ * when this limit is reached, regardless of state of underlying reader.
+ *
+ * <p>
+ * One use case is to avoid overrunning the readAheadLimit supplied to {@link java.io.Reader#mark(int)}, since reading
+ * too many characters removes the ability to do a successful reset.
+ * </p>
+ *
+ * @since 2.5
+ */
+public class BoundedReader extends Reader {
+
+    private static final int INVALID = -1;
+
+    private final Reader target;
+
+    private int charsRead;
+
+    private int markedAt = INVALID;
+
+    private int readAheadLimit; // Internally, this value will never exceed the allowed size
+
+    private final int maxCharsFromTargetReader;
+
+    /**
+     * Constructs a bounded reader
+     *
+     * @param target                   The target stream that will be used
+     * @param maxCharsFromTargetReader The maximum number of characters that can be read from target
+     */
+    public BoundedReader(final Reader target, final int maxCharsFromTargetReader) {
+        this.target = target;
+        this.maxCharsFromTargetReader = maxCharsFromTargetReader;
+    }
+
+    /**
+     * Closes the target
+     *
+     * @throws IOException If an I/O error occurs while calling the underlying reader's close method
+     */
+    @Override
+    public void close() throws IOException {
+        target.close();
+    }
+
+    /**
+     * marks the target stream
+     *
+     * @param readAheadLimit The number of characters that can be read while still retaining the ability to do #reset().
+     *                       Note that this parameter is not validated with respect to maxCharsFromTargetReader. There
+     *                       is no way to pass past maxCharsFromTargetReader, even if this value is greater.
+     *
+     * @throws IOException If an I/O error occurs while calling the underlying reader's mark method
+     * @see java.io.Reader#mark(int)
+     */
+    @Override
+    public void mark(final int readAheadLimit) throws IOException {
+        this.readAheadLimit = readAheadLimit - charsRead;
+
+        markedAt = charsRead;
+
+        target.mark(readAheadLimit);
+    }
+
+    /**
+     * Reads a single character
+     *
+     * @return -1 on EOF or the character read
+     * @throws IOException If an I/O error occurs while calling the underlying reader's read method
+     * @see java.io.Reader#read()
+     */
+    @Override
+    public int read() throws IOException {
+
+        if (charsRead >= maxCharsFromTargetReader) {
+            return EOF;
+        }
+
+        if (markedAt >= 0 && charsRead - markedAt >= readAheadLimit) {
+            return EOF;
+        }
+        charsRead++;
+        return target.read();
+    }
+
+    /**
+     * Reads into an array
+     *
+     * @param cbuf The buffer to fill
+     * @param off  The offset
+     * @param len  The number of chars to read
+     * @return the number of chars read
+     * @throws IOException If an I/O error occurs while calling the underlying reader's read method
+     * @see java.io.Reader#read(char[], int, int)
+     */
+    @Override
+    public int read(final char[] cbuf, final int off, final int len) throws IOException {
+        int c;
+        for (int i = 0; i < len; i++) {
+            c = read();
+            if (c == EOF) {
+                return i == 0 ? EOF : i;
+            }
+            cbuf[off + i] = (char) c;
+        }
+        return len;
+    }
+
+    /**
+     * Resets the target to the latest mark,
+     *
+     * @throws IOException If an I/O error occurs while calling the underlying reader's reset method
+     * @see java.io.Reader#reset()
+     */
+    @Override
+    public void reset() throws IOException {
+        charsRead = markedAt;
+        target.reset();
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/BrokenInputStream.java b/src/main/java/org/apache/commons/io/input/BrokenInputStream.java
new file mode 100644
index 0000000..4d84625
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/BrokenInputStream.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.function.Supplier;
+
+/**
+ * Always throws an {@link IOException} from all the {@link InputStream} methods where the exception is declared.
+ * <p>
+ * This class is mostly useful for testing error handling.
+ * </p>
+ *
+ * @since 2.0
+ */
+public class BrokenInputStream extends InputStream {
+
+    /**
+     * The singleton instance.
+     *
+     * @since 2.12.0
+     */
+    public static final BrokenInputStream INSTANCE = new BrokenInputStream();
+
+    /**
+     * A supplier for the exception that is thrown by all methods of this class.
+     */
+    private final Supplier<IOException> exceptionSupplier;
+
+    /**
+     * Creates a new stream that always throws an {@link IOException}.
+     */
+    public BrokenInputStream() {
+        this(() -> new IOException("Broken input stream"));
+    }
+
+    /**
+     * Creates a new stream that always throws the given exception.
+     *
+     * @param exception the exception to be thrown.
+     */
+    public BrokenInputStream(final IOException exception) {
+        this(() -> exception);
+    }
+
+    /**
+     * Creates a new stream that always throws an {@link IOException}.
+     *
+     * @param exceptionSupplier a supplier for the exception to be thrown.
+     * @since 2.12.0
+     */
+    public BrokenInputStream(final Supplier<IOException> exceptionSupplier) {
+        this.exceptionSupplier = exceptionSupplier;
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @return nothing
+     * @throws IOException always thrown
+     */
+    @Override
+    public int available() throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @throws IOException always thrown
+     */
+    @Override
+    public void close() throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @return nothing
+     * @throws IOException always thrown
+     */
+    @Override
+    public int read() throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @throws IOException always thrown
+     */
+    @Override
+    public synchronized void reset() throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @param n ignored
+     * @return nothing
+     * @throws IOException always thrown
+     */
+    @Override
+    public long skip(final long n) throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/BrokenReader.java b/src/main/java/org/apache/commons/io/input/BrokenReader.java
new file mode 100644
index 0000000..9748933
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/BrokenReader.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.function.Supplier;
+
+/**
+ * Always throws an {@link IOException} from all the {@link Reader} methods where the exception is declared.
+ * <p>
+ * This class is mostly useful for testing error handling.
+ * </p>
+ *
+ * @since 2.7
+ */
+public class BrokenReader extends Reader {
+
+    /**
+     * A singleton instance using a default IOException.
+     *
+     * @since 2.12.0
+     */
+    public static final BrokenReader INSTANCE = new BrokenReader();
+
+    /**
+     * A supplier for the exception that is thrown by all methods of this class.
+     */
+    private final Supplier<IOException> exceptionSupplier;
+
+    /**
+     * Creates a new reader that always throws an {@link IOException}.
+     */
+    public BrokenReader() {
+        this(() -> new IOException("Broken reader"));
+    }
+
+    /**
+     * Creates a new reader that always throws the given exception.
+     *
+     * @param exception the exception to be thrown.
+     */
+    public BrokenReader(final IOException exception) {
+        this(() -> exception);
+    }
+
+    /**
+     * Creates a new reader that always throws an {@link IOException}
+     *
+     * @param exceptionSupplier a supplier for the exception to be thrown.
+     * @since 2.12.0
+     */
+    public BrokenReader(final Supplier<IOException> exceptionSupplier) {
+        this.exceptionSupplier = exceptionSupplier;
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @throws IOException always thrown
+     */
+    @Override
+    public void close() throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @param readAheadLimit ignored
+     * @throws IOException always thrown
+     */
+    @Override
+    public void mark(final int readAheadLimit) throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @param cbuf ignored
+     * @param off  ignored
+     * @param len  ignored
+     * @return nothing
+     * @throws IOException always thrown
+     */
+    @Override
+    public int read(final char[] cbuf, final int off, final int len) throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @return nothing
+     * @throws IOException always thrown
+     */
+    @Override
+    public boolean ready() throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @throws IOException always thrown
+     */
+    @Override
+    public synchronized void reset() throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @param n ignored
+     * @return nothing
+     * @throws IOException always thrown
+     */
+    @Override
+    public long skip(final long n) throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/BufferedFileChannelInputStream.java b/src/main/java/org/apache/commons/io/input/BufferedFileChannelInputStream.java
new file mode 100644
index 0000000..80c6306
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/BufferedFileChannelInputStream.java
@@ -0,0 +1,204 @@
+/*
+ * 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.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.Objects;
+
+import org.apache.commons.io.IOUtils;
+
+/**
+ * {@link InputStream} implementation which uses direct buffer to read a file to avoid extra copy of data between Java
+ * and native memory which happens when using {@link java.io.BufferedInputStream}. Unfortunately, this is not something
+ * already available in JDK, {@code sun.nio.ch.ChannelInputStream} supports reading a file using NIO, but does not
+ * support buffering.
+ * <p>
+ * This class was ported and adapted from Apache Spark commit 933dc6cb7b3de1d8ccaf73d124d6eb95b947ed19 where it was
+ * called {@code NioBufferedFileInputStream}.
+ * </p>
+ *
+ * @since 2.9.0
+ */
+public final class BufferedFileChannelInputStream extends InputStream {
+
+    private final ByteBuffer byteBuffer;
+
+    private final FileChannel fileChannel;
+
+    /**
+     * Constructs a new instance for the given File.
+     *
+     * @param file The file to stream.
+     * @throws IOException If an I/O error occurs
+     */
+    public BufferedFileChannelInputStream(final File file) throws IOException {
+        this(file, IOUtils.DEFAULT_BUFFER_SIZE);
+    }
+
+    /**
+     * Constructs a new instance for the given File and buffer size.
+     *
+     * @param file The file to stream.
+     * @param bufferSizeInBytes buffer size.
+     * @throws IOException If an I/O error occurs
+     */
+    public BufferedFileChannelInputStream(final File file, final int bufferSizeInBytes) throws IOException {
+        this(file.toPath(), bufferSizeInBytes);
+    }
+
+    /**
+     * Constructs a new instance for the given Path.
+     *
+     * @param path The path to stream.
+     * @throws IOException If an I/O error occurs
+     */
+    public BufferedFileChannelInputStream(final Path path) throws IOException {
+        this(path, IOUtils.DEFAULT_BUFFER_SIZE);
+    }
+
+    /**
+     * Constructs a new instance for the given Path and buffer size.
+     *
+     * @param path The path to stream.
+     * @param bufferSizeInBytes buffer size.
+     * @throws IOException If an I/O error occurs
+     */
+    public BufferedFileChannelInputStream(final Path path, final int bufferSizeInBytes) throws IOException {
+        Objects.requireNonNull(path, "path");
+        fileChannel = FileChannel.open(path, StandardOpenOption.READ);
+        byteBuffer = ByteBuffer.allocateDirect(bufferSizeInBytes);
+        byteBuffer.flip();
+    }
+
+    @Override
+    public synchronized int available() throws IOException {
+        return byteBuffer.remaining();
+    }
+
+    /**
+     * Attempts to clean up a ByteBuffer if it is direct or memory-mapped. This uses an *unsafe* Sun API that will cause
+     * errors if one attempts to read from the disposed buffer. However, neither the bytes allocated to direct buffers nor
+     * file descriptors opened for memory-mapped buffers put pressure on the garbage collector. Waiting for garbage
+     * collection may lead to the depletion of off-heap memory or huge numbers of open files. There's unfortunately no
+     * standard API to manually dispose of these kinds of buffers.
+     *
+     * @param buffer the buffer to clean.
+     */
+    private void clean(final ByteBuffer buffer) {
+        if (buffer.isDirect()) {
+            cleanDirectBuffer(buffer);
+        }
+    }
+
+    /**
+     * In Java 8, the type of {@code sun.nio.ch.DirectBuffer.cleaner()} was {@code sun.misc.Cleaner}, and it was possible to
+     * access the method {@code sun.misc.Cleaner.clean()} to invoke it. The type changed to {@code jdk.internal.ref.Cleaner}
+     * in later JDKs, and the {@code clean()} method is not accessible even with reflection. However {@code sun.misc.Unsafe}
+     * added an {@code invokeCleaner()} method in JDK 9+ and this is still accessible with reflection.
+     *
+     * @param buffer the buffer to clean. must be a DirectBuffer.
+     */
+    private void cleanDirectBuffer(final ByteBuffer buffer) {
+        if (ByteBufferCleaner.isSupported()) {
+            ByteBufferCleaner.clean(buffer);
+        }
+    }
+
+    @Override
+    public synchronized void close() throws IOException {
+        try {
+            fileChannel.close();
+        } finally {
+            clean(byteBuffer);
+        }
+    }
+
+    @Override
+    public synchronized int read() throws IOException {
+        if (!refill()) {
+            return EOF;
+        }
+        return byteBuffer.get() & 0xFF;
+    }
+
+    @Override
+    public synchronized int read(final byte[] b, final int offset, int len) throws IOException {
+        if (offset < 0 || len < 0 || offset + len < 0 || offset + len > b.length) {
+            throw new IndexOutOfBoundsException();
+        }
+        if (!refill()) {
+            return EOF;
+        }
+        len = Math.min(len, byteBuffer.remaining());
+        byteBuffer.get(b, offset, len);
+        return len;
+    }
+
+    /**
+     * Checks whether data is left to be read from the input stream.
+     *
+     * @return true if data is left, false otherwise
+     * @throws IOException if an I/O error occurs.
+     */
+    private boolean refill() throws IOException {
+        if (!byteBuffer.hasRemaining()) {
+            byteBuffer.clear();
+            int nRead = 0;
+            while (nRead == 0) {
+                nRead = fileChannel.read(byteBuffer);
+            }
+            byteBuffer.flip();
+            return nRead >= 0;
+        }
+        return true;
+    }
+
+    @Override
+    public synchronized long skip(final long n) throws IOException {
+        if (n <= 0L) {
+            return 0L;
+        }
+        if (byteBuffer.remaining() >= n) {
+            // The buffered content is enough to skip
+            byteBuffer.position(byteBuffer.position() + (int) n);
+            return n;
+        }
+        final long skippedFromBuffer = byteBuffer.remaining();
+        final long toSkipFromFileChannel = n - skippedFromBuffer;
+        // Discard everything we have read in the buffer.
+        byteBuffer.position(0);
+        byteBuffer.flip();
+        return skippedFromBuffer + skipFromFileChannel(toSkipFromFileChannel);
+    }
+
+    private long skipFromFileChannel(final long n) throws IOException {
+        final long currentFilePosition = fileChannel.position();
+        final long size = fileChannel.size();
+        if (n > size - currentFilePosition) {
+            fileChannel.position(size);
+            return size - currentFilePosition;
+        }
+        fileChannel.position(currentFilePosition + n);
+        return n;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/ByteBufferCleaner.java b/src/main/java/org/apache/commons/io/input/ByteBufferCleaner.java
new file mode 100644
index 0000000..fc334e9
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/ByteBufferCleaner.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.ByteBuffer;
+
+/**
+ * Cleans a direct {@link ByteBuffer}. Without manual intervention, direct ByteBuffers will be cleaned eventually upon
+ * garbage collection. However, this should not be relied upon since it may not occur in a timely fashion -
+ * especially since off heap ByeBuffers don't put pressure on the garbage collector.
+ * <p>
+ * <b>Warning:</b> Do not attempt to use a direct {@link ByteBuffer} that has been cleaned or bad things will happen.
+ * Don't use this class unless you can ensure that the cleaned buffer will not be accessed anymore.
+ * </p>
+ * <p>
+ * See <a href=https://bugs.openjdk.java.net/browse/JDK-4724038>JDK-4724038</a>
+ * </p>
+ */
+class ByteBufferCleaner {
+
+    private interface Cleaner {
+        void clean(ByteBuffer buffer) throws ReflectiveOperationException;
+    }
+
+    private static class Java8Cleaner implements Cleaner {
+
+        private final Method cleanerMethod;
+        private final Method cleanMethod;
+
+        private Java8Cleaner() throws ReflectiveOperationException, SecurityException {
+            cleanMethod = Class.forName("sun.misc.Cleaner").getMethod("clean");
+            cleanerMethod = Class.forName("sun.nio.ch.DirectBuffer").getMethod("cleaner");
+        }
+
+        @Override
+        public void clean(final ByteBuffer buffer) throws ReflectiveOperationException {
+            final Object cleaner = cleanerMethod.invoke(buffer);
+            if (cleaner != null) {
+                cleanMethod.invoke(cleaner);
+            }
+        }
+    }
+
+    private static class Java9Cleaner implements Cleaner {
+
+        private final Object theUnsafe;
+        private final Method invokeCleaner;
+
+        private Java9Cleaner() throws ReflectiveOperationException, SecurityException {
+            final Class<?> unsafeClass = Class.forName("sun.misc.Unsafe");
+            final Field field = unsafeClass.getDeclaredField("theUnsafe");
+            field.setAccessible(true);
+            theUnsafe = field.get(null);
+            invokeCleaner = unsafeClass.getMethod("invokeCleaner", ByteBuffer.class);
+        }
+
+        @Override
+        public void clean(final ByteBuffer buffer) throws ReflectiveOperationException {
+            invokeCleaner.invoke(theUnsafe, buffer);
+        }
+    }
+
+    private static final Cleaner INSTANCE = getCleaner();
+
+    /**
+     * Releases memory held by the given {@link ByteBuffer}.
+     *
+     * @param buffer to release.
+     * @throws IllegalStateException on internal failure.
+     */
+    static void clean(final ByteBuffer buffer) {
+        try {
+            INSTANCE.clean(buffer);
+        } catch (final Exception e) {
+            throw new IllegalStateException("Failed to clean direct buffer.", e);
+        }
+    }
+
+    private static Cleaner getCleaner() {
+        try {
+            return new Java8Cleaner();
+        } catch (final Exception e) {
+            try {
+                return new Java9Cleaner();
+            } catch (final Exception e1) {
+                throw new IllegalStateException("Failed to initialize a Cleaner.", e);
+            }
+        }
+    }
+
+    /**
+     * Tests if were able to load a suitable cleaner for the current JVM. Attempting to call
+     * {@code ByteBufferCleaner#clean(ByteBuffer)} when this method returns false will result in an exception.
+     *
+     * @return {@code true} if cleaning is supported, {@code false} otherwise.
+     */
+    static boolean isSupported() {
+        return INSTANCE != null;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/CharSequenceInputStream.java b/src/main/java/org/apache/commons/io/input/CharSequenceInputStream.java
new file mode 100644
index 0000000..6fe6d9e
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/CharSequenceInputStream.java
@@ -0,0 +1,273 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetEncoder;
+import java.nio.charset.CoderResult;
+import java.nio.charset.CodingErrorAction;
+import java.util.Objects;
+
+import org.apache.commons.io.Charsets;
+
+/**
+ * Implements an {@link InputStream} to read from String, StringBuffer, StringBuilder or CharBuffer.
+ * <p>
+ * <strong>Note:</strong> Supports {@link #mark(int)} and {@link #reset()}.
+ * </p>
+ *
+ * @since 2.2
+ */
+public class CharSequenceInputStream extends InputStream {
+
+    private static final int BUFFER_SIZE = 2048;
+
+    private static final int NO_MARK = -1;
+
+    private final CharsetEncoder charsetEncoder;
+    private final CharBuffer cBuf;
+    private final ByteBuffer bBuf;
+
+    private int cBufMark; // position in cBuf
+    private int bBufMark; // position in bBuf
+
+    /**
+     * Constructs a new instance with a buffer size of 2048.
+     *
+     * @param cs the input character sequence.
+     * @param charset the character set name to use.
+     * @throws IllegalArgumentException if the buffer is not large enough to hold a complete character.
+     */
+    public CharSequenceInputStream(final CharSequence cs, final Charset charset) {
+        this(cs, charset, BUFFER_SIZE);
+    }
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param cs the input character sequence.
+     * @param charset the character set name to use, null maps to the default Charset.
+     * @param bufferSize the buffer size to use.
+     * @throws IllegalArgumentException if the buffer is not large enough to hold a complete character.
+     */
+    public CharSequenceInputStream(final CharSequence cs, final Charset charset, final int bufferSize) {
+        // @formatter:off
+        this.charsetEncoder = Charsets.toCharset(charset).newEncoder()
+            .onMalformedInput(CodingErrorAction.REPLACE)
+            .onUnmappableCharacter(CodingErrorAction.REPLACE);
+        // @formatter:on
+        // Ensure that buffer is long enough to hold a complete character
+        this.bBuf = ByteBuffer.allocate(ReaderInputStream.checkMinBufferSize(charsetEncoder, bufferSize));
+        this.bBuf.flip();
+        this.cBuf = CharBuffer.wrap(cs);
+        this.cBufMark = NO_MARK;
+        this.bBufMark = NO_MARK;
+    }
+
+    /**
+     * Constructs a new instance with a buffer size of 2048.
+     *
+     * @param cs the input character sequence.
+     * @param charset the character set name to use.
+     * @throws IllegalArgumentException if the buffer is not large enough to hold a complete character.
+     */
+    public CharSequenceInputStream(final CharSequence cs, final String charset) {
+        this(cs, charset, BUFFER_SIZE);
+    }
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param cs the input character sequence.
+     * @param charset the character set name to use, null maps to the default Charset.
+     * @param bufferSize the buffer size to use.
+     * @throws IllegalArgumentException if the buffer is not large enough to hold a complete character.
+     */
+    public CharSequenceInputStream(final CharSequence cs, final String charset, final int bufferSize) {
+        this(cs, Charsets.toCharset(charset), bufferSize);
+    }
+
+    /**
+     * Return an estimate of the number of bytes remaining in the byte stream.
+     * @return the count of bytes that can be read without blocking (or returning EOF).
+     *
+     * @throws IOException if an error occurs (probably not possible).
+     */
+    @Override
+    public int available() throws IOException {
+        // The cached entries are in bBuf; since encoding always creates at least one byte
+        // per character, we can add the two to get a better estimate (e.g. if bBuf is empty)
+        // Note that the previous implementation (2.4) could return zero even though there were
+        // encoded bytes still available.
+        return this.bBuf.remaining() + this.cBuf.remaining();
+    }
+
+    @Override
+    public void close() throws IOException {
+        // noop
+    }
+
+    /**
+     * Fills the byte output buffer from the input char buffer.
+     *
+     * @throws CharacterCodingException
+     *             an error encoding data.
+     */
+    private void fillBuffer() throws CharacterCodingException {
+        this.bBuf.compact();
+        final CoderResult result = this.charsetEncoder.encode(this.cBuf, this.bBuf, true);
+        if (result.isError()) {
+            result.throwException();
+        }
+        this.bBuf.flip();
+    }
+
+    /**
+     * Gets the CharsetEncoder.
+     *
+     * @return the CharsetEncoder.
+     */
+    CharsetEncoder getCharsetEncoder() {
+        return charsetEncoder;
+    }
+
+    /**
+     * {@inheritDoc}
+     * @param readlimit max read limit (ignored).
+     */
+    @Override
+    public synchronized void mark(final int readlimit) {
+        this.cBufMark = this.cBuf.position();
+        this.bBufMark = this.bBuf.position();
+        this.cBuf.mark();
+        this.bBuf.mark();
+        // It would be nice to be able to use mark & reset on the cBuf and bBuf;
+        // however the bBuf is re-used so that won't work
+    }
+
+    @Override
+    public boolean markSupported() {
+        return true;
+    }
+
+    @Override
+    public int read() throws IOException {
+        for (;;) {
+            if (this.bBuf.hasRemaining()) {
+                return this.bBuf.get() & 0xFF;
+            }
+            fillBuffer();
+            if (!this.bBuf.hasRemaining() && !this.cBuf.hasRemaining()) {
+                return EOF;
+            }
+        }
+    }
+
+    @Override
+    public int read(final byte[] b) throws IOException {
+        return read(b, 0, b.length);
+    }
+
+    @Override
+    public int read(final byte[] array, int off, int len) throws IOException {
+        Objects.requireNonNull(array, "array");
+        if (len < 0 || off + len > array.length) {
+            throw new IndexOutOfBoundsException("Array Size=" + array.length + ", offset=" + off + ", length=" + len);
+        }
+        if (len == 0) {
+            return 0; // must return 0 for zero length read
+        }
+        if (!this.bBuf.hasRemaining() && !this.cBuf.hasRemaining()) {
+            return EOF;
+        }
+        int bytesRead = 0;
+        while (len > 0) {
+            if (this.bBuf.hasRemaining()) {
+                final int chunk = Math.min(this.bBuf.remaining(), len);
+                this.bBuf.get(array, off, chunk);
+                off += chunk;
+                len -= chunk;
+                bytesRead += chunk;
+            } else {
+                fillBuffer();
+                if (!this.bBuf.hasRemaining() && !this.cBuf.hasRemaining()) {
+                    break;
+                }
+            }
+        }
+        return bytesRead == 0 && !this.cBuf.hasRemaining() ? EOF : bytesRead;
+    }
+
+    @Override
+    public synchronized void reset() throws IOException {
+        //
+        // This is not the most efficient implementation, as it re-encodes from the beginning.
+        //
+        // Since the bBuf is re-used, in general it's necessary to re-encode the data.
+        //
+        // It should be possible to apply some optimisations however:
+        // + use mark/reset on the cBuf and bBuf. This would only work if the buffer had not been (re)filled since
+        // the mark. The code would have to catch InvalidMarkException - does not seem possible to check if mark is
+        // valid otherwise. + Try saving the state of the cBuf before each fillBuffer; it might be possible to
+        // restart from there.
+        //
+        if (this.cBufMark != NO_MARK) {
+            // if cBuf is at 0, we have not started reading anything, so skip re-encoding
+            if (this.cBuf.position() != 0) {
+                this.charsetEncoder.reset();
+                this.cBuf.rewind();
+                this.bBuf.rewind();
+                this.bBuf.limit(0); // rewind does not clear the buffer
+                while(this.cBuf.position() < this.cBufMark) {
+                    this.bBuf.rewind(); // empty the buffer (we only refill when empty during normal processing)
+                    this.bBuf.limit(0);
+                    fillBuffer();
+                }
+            }
+            if (this.cBuf.position() != this.cBufMark) {
+                throw new IllegalStateException("Unexpected CharBuffer position: actual=" + cBuf.position() + " " +
+                        "expected=" + this.cBufMark);
+            }
+            this.bBuf.position(this.bBufMark);
+            this.cBufMark = NO_MARK;
+            this.bBufMark = NO_MARK;
+        }
+    }
+
+    @Override
+    public long skip(long n) throws IOException {
+        //
+        // This could be made more efficient by using position to skip within the current buffer.
+        //
+        long skipped = 0;
+        while (n > 0 && available() > 0) {
+            this.read();
+            n--;
+            skipped++;
+        }
+        return skipped;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/CharSequenceReader.java b/src/main/java/org/apache/commons/io/input/CharSequenceReader.java
new file mode 100644
index 0000000..203a65f
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/CharSequenceReader.java
@@ -0,0 +1,301 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.Reader;
+import java.io.Serializable;
+import java.util.Objects;
+
+/**
+ * {@link Reader} implementation that can read from String, StringBuffer,
+ * StringBuilder or CharBuffer.
+ * <p>
+ * <strong>Note:</strong> Supports {@link #mark(int)} and {@link #reset()}.
+ * </p>
+ *
+ * @since 1.4
+ */
+public class CharSequenceReader extends Reader implements Serializable {
+
+    private static final long serialVersionUID = 3724187752191401220L;
+    private final CharSequence charSequence;
+    private int idx;
+    private int mark;
+
+    /**
+     * The start index in the character sequence, inclusive.
+     * <p>
+     * When de-serializing a CharSequenceReader that was serialized before
+     * this fields was added, this field will be initialized to 0, which
+     * gives the same behavior as before: start reading from the start.
+     * </p>
+     *
+     * @see #start()
+     * @since 2.7
+     */
+    private final int start;
+
+    /**
+     * The end index in the character sequence, exclusive.
+     * <p>
+     * When de-serializing a CharSequenceReader that was serialized before
+     * this fields was added, this field will be initialized to {@code null},
+     * which gives the same behavior as before: stop reading at the
+     * CharSequence's length.
+     * If this field was an int instead, it would be initialized to 0 when the
+     * CharSequenceReader is de-serialized, causing it to not return any
+     * characters at all.
+     * </p>
+     *
+     * @see #end()
+     * @since 2.7
+     */
+    private final Integer end;
+
+    /**
+     * Constructs a new instance with the specified character sequence.
+     *
+     * @param charSequence The character sequence, may be {@code null}
+     */
+    public CharSequenceReader(final CharSequence charSequence) {
+        this(charSequence, 0);
+    }
+
+    /**
+     * Constructs a new instance with a portion of the specified character sequence.
+     * <p>
+     * The start index is not strictly enforced to be within the bounds of the
+     * character sequence. This allows the character sequence to grow or shrink
+     * in size without risking any {@link IndexOutOfBoundsException} to be thrown.
+     * Instead, if the character sequence grows smaller than the start index, this
+     * instance will act as if all characters have been read.
+     * </p>
+     *
+     * @param charSequence The character sequence, may be {@code null}
+     * @param start The start index in the character sequence, inclusive
+     * @throws IllegalArgumentException if the start index is negative
+     * @since 2.7
+     */
+    public CharSequenceReader(final CharSequence charSequence, final int start) {
+        this(charSequence, start, Integer.MAX_VALUE);
+    }
+
+    /**
+     * Constructs a new instance with a portion of the specified character sequence.
+     * <p>
+     * The start and end indexes are not strictly enforced to be within the bounds
+     * of the character sequence. This allows the character sequence to grow or shrink
+     * in size without risking any {@link IndexOutOfBoundsException} to be thrown.
+     * Instead, if the character sequence grows smaller than the start index, this
+     * instance will act as if all characters have been read; if the character sequence
+     * grows smaller than the end, this instance will use the actual character sequence
+     * length.
+     * </p>
+     *
+     * @param charSequence The character sequence, may be {@code null}
+     * @param start The start index in the character sequence, inclusive
+     * @param end The end index in the character sequence, exclusive
+     * @throws IllegalArgumentException if the start index is negative, or if the end index is smaller than the start index
+     * @since 2.7
+     */
+    public CharSequenceReader(final CharSequence charSequence, final int start, final int end) {
+        if (start < 0) {
+            throw new IllegalArgumentException("Start index is less than zero: " + start);
+        }
+        if (end < start) {
+            throw new IllegalArgumentException("End index is less than start " + start + ": " + end);
+        }
+        // Don't check the start and end indexes against the CharSequence,
+        // to let it grow and shrink without breaking existing behavior.
+
+        this.charSequence = charSequence != null ? charSequence : "";
+        this.start = start;
+        this.end = end;
+
+        this.idx = start;
+        this.mark = start;
+    }
+
+    /**
+     * Close resets the file back to the start and removes any marked position.
+     */
+    @Override
+    public void close() {
+        idx = start;
+        mark = start;
+    }
+
+    /**
+     * Returns the index in the character sequence to end reading at, taking into account its length.
+     *
+     * @return The end index in the character sequence (exclusive).
+     */
+    private int end() {
+        /*
+         * end == null for de-serialized instances that were serialized before start and end were added.
+         * Use Integer.MAX_VALUE to get the same behavior as before - use the entire CharSequence.
+         */
+        return Math.min(charSequence.length(), end == null ? Integer.MAX_VALUE : end);
+    }
+
+    /**
+     * Mark the current position.
+     *
+     * @param readAheadLimit ignored
+     */
+    @Override
+    public void mark(final int readAheadLimit) {
+        mark = idx;
+    }
+
+    /**
+     * Mark is supported (returns true).
+     *
+     * @return {@code true}
+     */
+    @Override
+    public boolean markSupported() {
+        return true;
+    }
+
+    /**
+     * Read a single character.
+     *
+     * @return the next character from the character sequence
+     * or -1 if the end has been reached.
+     */
+    @Override
+    public int read() {
+        if (idx >= end()) {
+            return EOF;
+        }
+        return charSequence.charAt(idx++);
+    }
+
+    /**
+     * Read the specified number of characters into the array.
+     *
+     * @param array The array to store the characters in
+     * @param offset The starting position in the array to store
+     * @param length The maximum number of characters to read
+     * @return The number of characters read or -1 if there are
+     * no more
+     */
+    @Override
+    public int read(final char[] array, final int offset, final int length) {
+        if (idx >= end()) {
+            return EOF;
+        }
+        Objects.requireNonNull(array, "array");
+        if (length < 0 || offset < 0 || offset + length > array.length) {
+            throw new IndexOutOfBoundsException("Array Size=" + array.length +
+                    ", offset=" + offset + ", length=" + length);
+        }
+
+        if (charSequence instanceof String) {
+            final int count = Math.min(length, end() - idx);
+            ((String) charSequence).getChars(idx, idx + count, array, offset);
+            idx += count;
+            return count;
+        }
+        if (charSequence instanceof StringBuilder) {
+            final int count = Math.min(length, end() - idx);
+            ((StringBuilder) charSequence).getChars(idx, idx + count, array, offset);
+            idx += count;
+            return count;
+        }
+        if (charSequence instanceof StringBuffer) {
+            final int count = Math.min(length, end() - idx);
+            ((StringBuffer) charSequence).getChars(idx, idx + count, array, offset);
+            idx += count;
+            return count;
+        }
+
+        int count = 0;
+        for (int i = 0; i < length; i++) {
+            final int c = read();
+            if (c == EOF) {
+                return count;
+            }
+            array[offset + i] = (char)c;
+            count++;
+        }
+        return count;
+    }
+
+    /**
+     * Tells whether this stream is ready to be read.
+     *
+     * @return {@code true} if more characters from the character sequence are available, or {@code false} otherwise.
+     */
+    @Override
+    public boolean ready() {
+        return idx < end();
+    }
+
+    /**
+     * Reset the reader to the last marked position (or the beginning if
+     * mark has not been called).
+     */
+    @Override
+    public void reset() {
+        idx = mark;
+    }
+
+    /**
+     * Skip the specified number of characters.
+     *
+     * @param n The number of characters to skip
+     * @return The actual number of characters skipped
+     */
+    @Override
+    public long skip(final long n) {
+        if (n < 0) {
+            throw new IllegalArgumentException("Number of characters to skip is less than zero: " + n);
+        }
+        if (idx >= end()) {
+            return 0;
+        }
+        final int dest = (int) Math.min(end(), idx + n);
+        final int count = dest - idx;
+        idx = dest;
+        return count;
+    }
+
+    /**
+     * Returns the index in the character sequence to start reading from, taking into account its length.
+     *
+     * @return The start index in the character sequence (inclusive).
+     */
+    private int start() {
+        return Math.min(charSequence.length(), start);
+    }
+
+    /**
+     * Return a String representation of the underlying
+     * character sequence.
+     *
+     * @return The contents of the character sequence
+     */
+    @Override
+    public String toString() {
+        final CharSequence subSequence = charSequence.subSequence(start(), end());
+        return subSequence.toString();
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/CharacterFilterReader.java b/src/main/java/org/apache/commons/io/input/CharacterFilterReader.java
new file mode 100644
index 0000000..c6edd5b
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/CharacterFilterReader.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.Reader;
+import java.util.function.IntPredicate;
+
+/**
+ * A filter reader that filters out a given character represented as an {@code int} code point, handy to remove
+ * known junk characters from CSV files for example. This class is the most efficient way to filter out a single
+ * character, as opposed to using a {@link CharacterSetFilterReader}. You can also nest {@link CharacterFilterReader}s.
+ */
+public class CharacterFilterReader extends AbstractCharacterFilterReader {
+
+    /**
+     * Constructs a new reader.
+     *
+     * @param reader
+     *            the reader to filter.
+     * @param skip
+     *            the character to filter out.
+     */
+    public CharacterFilterReader(final Reader reader, final int skip) {
+        super(reader, c -> c == skip);
+    }
+
+    /**
+     * Constructs a new reader.
+     *
+     * @param reader the reader to filter.
+     * @param skip Skip test.
+     * @since 2.9.0
+     */
+    public CharacterFilterReader(final Reader reader, final IntPredicate skip) {
+        super(reader, skip);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/CharacterSetFilterReader.java b/src/main/java/org/apache/commons/io/input/CharacterSetFilterReader.java
new file mode 100644
index 0000000..a5cb4f8
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/CharacterSetFilterReader.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.Reader;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.function.IntPredicate;
+
+/**
+ * A filter reader that removes a given set of characters represented as {@code int} code points, handy to remove known
+ * junk characters from CSV files for example.
+ * <p>
+ * This class must convert each {@code int} read to an {@link Integer}. You can increase the Integer cache with a system
+ * property, see {@link Integer}.
+ * </p>
+ */
+public class CharacterSetFilterReader extends AbstractCharacterFilterReader {
+
+    private static IntPredicate toIntPredicate(final Set<Integer> skip) {
+        if (skip == null) {
+            return SKIP_NONE;
+        }
+        final Set<Integer> unmodifiableSet = Collections.unmodifiableSet(skip);
+        return c -> unmodifiableSet.contains(Integer.valueOf(c));
+    }
+
+    /**
+     * Constructs a new reader.
+     *
+     * @param reader the reader to filter.
+     * @param skip the set of characters to filter out.
+     * @since 2.9.0
+     */
+    public CharacterSetFilterReader(final Reader reader, final Integer... skip) {
+        this(reader, new HashSet<>(Arrays.asList(skip)));
+    }
+
+    /**
+     * Constructs a new reader.
+     *
+     * @param reader the reader to filter.
+     * @param skip the set of characters to filter out.
+     */
+    public CharacterSetFilterReader(final Reader reader, final Set<Integer> skip) {
+        super(reader, toIntPredicate(skip));
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/CircularInputStream.java b/src/main/java/org/apache/commons/io/input/CircularInputStream.java
new file mode 100644
index 0000000..d7e04a4
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/CircularInputStream.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Objects;
+
+import org.apache.commons.io.IOUtils;
+
+/**
+ * An {@link InputStream} that repeats provided bytes for given target byte count.
+ * <p>
+ * Closing this input stream has no effect. The methods in this class can be called after the stream has been closed
+ * without generating an {@link IOException}.
+ * </p>
+ *
+ * @see InfiniteCircularInputStream
+ * @since 2.8.0
+ */
+public class CircularInputStream extends InputStream {
+
+    /**
+     * Throws an {@link IllegalArgumentException} if the input contains -1.
+     *
+     * @param repeatContent input to validate.
+     * @return the input.
+     */
+    private static byte[] validate(final byte[] repeatContent) {
+        Objects.requireNonNull(repeatContent, "repeatContent");
+        for (final byte b : repeatContent) {
+            if (b == IOUtils.EOF) {
+                throw new IllegalArgumentException("repeatContent contains the end-of-stream marker " + IOUtils.EOF);
+            }
+        }
+        return repeatContent;
+    }
+
+    private long byteCount;
+    private int position = -1;
+    private final byte[] repeatedContent;
+    private final long targetByteCount;
+
+    /**
+     * Creates an instance from the specified array of bytes.
+     *
+     * @param repeatContent Input buffer to be repeated this buffer is not copied.
+     * @param targetByteCount How many bytes the read. A negative number means an infinite target count.
+     */
+    public CircularInputStream(final byte[] repeatContent, final long targetByteCount) {
+        this.repeatedContent = validate(repeatContent);
+        if (repeatContent.length == 0) {
+            throw new IllegalArgumentException("repeatContent is empty.");
+        }
+        this.targetByteCount = targetByteCount;
+    }
+
+    @Override
+    public int read() {
+        if (targetByteCount >= 0) {
+            if (byteCount == targetByteCount) {
+                return IOUtils.EOF;
+            }
+            byteCount++;
+        }
+        position = (position + 1) % repeatedContent.length;
+        return repeatedContent[position] & 0xff;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/ClassLoaderObjectInputStream.java b/src/main/java/org/apache/commons/io/input/ClassLoaderObjectInputStream.java
new file mode 100644
index 0000000..34dae5b
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/ClassLoaderObjectInputStream.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectStreamClass;
+import java.io.StreamCorruptedException;
+import java.lang.reflect.Proxy;
+
+/**
+ * A special ObjectInputStream that loads a class based on a specified
+ * {@link ClassLoader} rather than the system default.
+ * <p>
+ * This is useful in dynamic container environments.
+ * </p>
+ *
+ * @since 1.1
+ */
+public class ClassLoaderObjectInputStream extends ObjectInputStream {
+
+    /** The class loader to use. */
+    private final ClassLoader classLoader;
+
+    /**
+     * Constructs a new ClassLoaderObjectInputStream.
+     *
+     * @param classLoader  the ClassLoader from which classes should be loaded
+     * @param inputStream  the InputStream to work on
+     * @throws IOException in case of an I/O error
+     * @throws StreamCorruptedException if the stream is corrupted
+     */
+    public ClassLoaderObjectInputStream(
+            final ClassLoader classLoader, final InputStream inputStream)
+            throws IOException, StreamCorruptedException {
+        super(inputStream);
+        this.classLoader = classLoader;
+    }
+
+    /**
+     * Resolve a class specified by the descriptor using the
+     * specified ClassLoader or the super ClassLoader.
+     *
+     * @param objectStreamClass  descriptor of the class
+     * @return the Class object described by the ObjectStreamClass
+     * @throws IOException in case of an I/O error
+     * @throws ClassNotFoundException if the Class cannot be found
+     */
+    @Override
+    protected Class<?> resolveClass(final ObjectStreamClass objectStreamClass)
+            throws IOException, ClassNotFoundException {
+
+        try {
+            return Class.forName(objectStreamClass.getName(), false, classLoader);
+        } catch (final ClassNotFoundException cnfe) {
+            // delegate to super class loader which can resolve primitives
+            return super.resolveClass(objectStreamClass);
+        }
+    }
+
+    /**
+     * Create a proxy class that implements the specified interfaces using
+     * the specified ClassLoader or the super ClassLoader.
+     *
+     * @param interfaces the interfaces to implement
+     * @return a proxy class implementing the interfaces
+     * @throws IOException in case of an I/O error
+     * @throws ClassNotFoundException if the Class cannot be found
+     * @see java.io.ObjectInputStream#resolveProxyClass(String[])
+     * @since 2.1
+     */
+    @Override
+    protected Class<?> resolveProxyClass(final String[] interfaces) throws IOException,
+            ClassNotFoundException {
+        final Class<?>[] interfaceClasses = new Class[interfaces.length];
+        for (int i = 0; i < interfaces.length; i++) {
+            interfaceClasses[i] = Class.forName(interfaces[i], false, classLoader);
+        }
+        try {
+            return Proxy.getProxyClass(classLoader, interfaceClasses);
+        } catch (final IllegalArgumentException e) {
+            return super.resolveProxyClass(interfaces);
+        }
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/CloseShieldInputStream.java b/src/main/java/org/apache/commons/io/input/CloseShieldInputStream.java
new file mode 100644
index 0000000..0fd10d5
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/CloseShieldInputStream.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.InputStream;
+
+/**
+ * Proxy stream that prevents the underlying input stream from being closed.
+ * <p>
+ * This class is typically used in cases where an input stream needs to be
+ * passed to a component that wants to explicitly close the stream even if more
+ * input would still be available to other components.
+ * </p>
+ *
+ * @since 1.4
+ */
+public class CloseShieldInputStream extends ProxyInputStream {
+
+    /**
+     * Creates a proxy that shields the given input stream from being closed.
+     *
+     * @param inputStream the input stream to wrap
+     * @return the created proxy
+     * @since 2.9.0
+     */
+    public static CloseShieldInputStream wrap(final InputStream inputStream) {
+        return new CloseShieldInputStream(inputStream);
+    }
+
+    /**
+     * Creates a proxy that shields the given input stream from being closed.
+     *
+     * @param inputStream underlying input stream
+     * @deprecated Using this constructor prevents IDEs from warning if the
+     *             underlying input stream is never closed. Use
+     *             {@link #wrap(InputStream)} instead.
+     */
+    @Deprecated
+    public CloseShieldInputStream(final InputStream inputStream) {
+        super(inputStream);
+    }
+
+    /**
+     * Replaces the underlying input stream with a {@link ClosedInputStream}
+     * sentinel. The original input stream will remain open, but this proxy will
+     * appear closed.
+     */
+    @Override
+    public void close() {
+        in = ClosedInputStream.INSTANCE;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/CloseShieldReader.java b/src/main/java/org/apache/commons/io/input/CloseShieldReader.java
new file mode 100644
index 0000000..375ae20
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/CloseShieldReader.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.Reader;
+
+/**
+ * Proxy reader that prevents the underlying reader from being closed.
+ * <p>
+ * This class is typically used in cases where a reader needs to be passed to a
+ * component that wants to explicitly close the reader even if more input would
+ * still be available to other components.
+ * </p>
+ *
+ * @since 2.7
+ */
+public class CloseShieldReader extends ProxyReader {
+
+    /**
+     * Creates a proxy that shields the given reader from being closed.
+     *
+     * @param reader the reader to wrap
+     * @return the created proxy
+     * @since 2.9.0
+     */
+    public static CloseShieldReader wrap(final Reader reader) {
+        return new CloseShieldReader(reader);
+    }
+
+    /**
+     * Creates a proxy that shields the given reader from being closed.
+     *
+     * @param reader underlying reader
+     * @deprecated Using this constructor prevents IDEs from warning if the
+     *             underlying reader is never closed. Use {@link #wrap(Reader)}
+     *             instead.
+     */
+    @Deprecated
+    public CloseShieldReader(final Reader reader) {
+        super(reader);
+    }
+
+    /**
+     * Replaces the underlying reader with a {@link ClosedReader} sentinel. The
+     * original reader will remain open, but this proxy will appear closed.
+     */
+    @Override
+    public void close() {
+        in = ClosedReader.INSTANCE;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/ClosedInputStream.java b/src/main/java/org/apache/commons/io/input/ClosedInputStream.java
new file mode 100644
index 0000000..fc372e3
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/ClosedInputStream.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.InputStream;
+
+import org.apache.commons.io.IOUtils;
+
+/**
+ * Always returns {@link IOUtils#EOF} to all attempts to read something from the stream.
+ * <p>
+ * Typically uses of this class include testing for corner cases in methods that accept input streams and acting as a
+ * sentinel value instead of a {@code null} input stream.
+ * </p>
+ *
+ * @since 1.4
+ */
+public class ClosedInputStream extends InputStream {
+
+    /**
+     * The singleton instance.
+     *
+     * @since 2.12.0
+     */
+    public static final ClosedInputStream INSTANCE = new ClosedInputStream();
+
+    /**
+     * The singleton instance.
+     *
+     * @deprecated Use {@link #INSTANCE}.
+     */
+    @Deprecated
+    public static final ClosedInputStream CLOSED_INPUT_STREAM = INSTANCE;
+
+    /**
+     * Returns -1 to indicate that the stream is closed.
+     *
+     * @return always -1
+     */
+    @Override
+    public int read() {
+        return EOF;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/ClosedReader.java b/src/main/java/org/apache/commons/io/input/ClosedReader.java
new file mode 100644
index 0000000..71e21d2
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/ClosedReader.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.IOException;
+import java.io.Reader;
+
+import org.apache.commons.io.IOUtils;
+
+/**
+ * Always returns {@link IOUtils#EOF} to all attempts to read something from it.
+ * <p>
+ * Typically uses of this class include testing for corner cases in methods that accept readers and acting as a sentinel
+ * value instead of a {@code null} reader.
+ * </p>
+ *
+ * @since 2.7
+ */
+public class ClosedReader extends Reader {
+
+    /**
+     * The singleton instance.
+     *
+     * @since 2.12.0
+     */
+    public static final ClosedReader INSTANCE = new ClosedReader();
+
+    /**
+     * The singleton instance.
+     *
+     * @deprecated {@link #INSTANCE}.
+     */
+    @Deprecated
+    public static final ClosedReader CLOSED_READER = INSTANCE;
+
+    @Override
+    public void close() throws IOException {
+        // noop
+    }
+
+    /**
+     * Returns -1 to indicate that the stream is closed.
+     *
+     * @param cbuf ignored
+     * @param off ignored
+     * @param len ignored
+     * @return always -1
+     */
+    @Override
+    public int read(final char[] cbuf, final int off, final int len) {
+        return EOF;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/CountingInputStream.java b/src/main/java/org/apache/commons/io/input/CountingInputStream.java
new file mode 100644
index 0000000..4b706f2
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/CountingInputStream.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * A decorating input stream that counts the number of bytes that have passed
+ * through the stream so far.
+ * <p>
+ * A typical use case would be during debugging, to ensure that data is being
+ * read as expected.
+ * </p>
+ */
+public class CountingInputStream extends ProxyInputStream {
+
+    /** The count of bytes that have passed. */
+    private long count;
+
+    /**
+     * Constructs a new CountingInputStream.
+     *
+     * @param in  the InputStream to delegate to
+     */
+    public CountingInputStream(final InputStream in) {
+        super(in);
+    }
+
+
+    /**
+     * Adds the number of read bytes to the count.
+     *
+     * @param n number of bytes read, or -1 if no more bytes are available
+     * @since 2.0
+     */
+    @Override
+    protected synchronized void afterRead(final int n) {
+        if (n != EOF) {
+            this.count += n;
+        }
+    }
+
+    /**
+     * Gets number of bytes that have passed through this stream.
+     * <p>
+     * NOTE: This method is an alternative for {@code getCount()}
+     * and was added because that method returns an integer which will
+     * result in incorrect count for files over 2GB.
+     * </p>
+     *
+     * @return the number of bytes accumulated
+     * @since 1.3
+     */
+    public synchronized long getByteCount() {
+        return this.count;
+    }
+
+    /**
+     * Gets number of bytes that have passed through this stream.
+     * <p>
+     * NOTE: From v1.3 this method throws an ArithmeticException if the
+     * count is greater than can be expressed by an {@code int}.
+     * See {@link #getByteCount()} for a method using a {@code long}.
+     * </p>
+     *
+     * @return the number of bytes accumulated
+     * @throws ArithmeticException if the byte count is too large
+     */
+    public int getCount() {
+        final long result = getByteCount();
+        if (result > Integer.MAX_VALUE) {
+            throw new ArithmeticException("The byte count " + result + " is too large to be converted to an int");
+        }
+        return (int) result;
+    }
+
+    /**
+     * Resets the byte count back to 0.
+     * <p>
+     * NOTE: This method is an alternative for {@code resetCount()}
+     * and was added because that method returns an integer which will
+     * result in incorrect count for files over 2GB.
+     * </p>
+     *
+     * @return the count previous to resetting
+     * @since 1.3
+     */
+    public synchronized long resetByteCount() {
+        final long tmp = this.count;
+        this.count = 0;
+        return tmp;
+    }
+
+    /**
+     * Resets the byte count back to 0.
+     * <p>
+     * NOTE: From v1.3 this method throws an ArithmeticException if the
+     * count is greater than can be expressed by an {@code int}.
+     * See {@link #resetByteCount()} for a method using a {@code long}.
+     * </p>
+     *
+     * @return the count previous to resetting
+     * @throws ArithmeticException if the byte count is too large
+     */
+    public int resetCount() {
+        final long result = resetByteCount();
+        if (result > Integer.MAX_VALUE) {
+            throw new ArithmeticException("The byte count " + result + " is too large to be converted to an int");
+        }
+        return (int) result;
+    }
+
+    /**
+     * Skips the stream over the specified number of bytes, adding the skipped
+     * amount to the count.
+     *
+     * @param length  the number of bytes to skip
+     * @return the actual number of bytes skipped
+     * @throws IOException if an I/O error occurs.
+     * @see java.io.InputStream#skip(long)
+     */
+    @Override
+    public synchronized long skip(final long length) throws IOException {
+        final long skip = super.skip(length);
+        this.count += skip;
+        return skip;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/DemuxInputStream.java b/src/main/java/org/apache/commons/io/input/DemuxInputStream.java
new file mode 100644
index 0000000..c92471d
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/DemuxInputStream.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.apache.commons.io.IOUtils;
+
+/**
+ * Data written to this stream is forwarded to a stream that has been associated with this thread.
+ */
+public class DemuxInputStream extends InputStream {
+    private final InheritableThreadLocal<InputStream> inputStreamLocal = new InheritableThreadLocal<>();
+
+    /**
+     * Binds the specified stream to the current thread.
+     *
+     * @param input the stream to bind
+     * @return the InputStream that was previously active
+     */
+    public InputStream bindStream(final InputStream input) {
+        final InputStream oldValue = inputStreamLocal.get();
+        inputStreamLocal.set(input);
+        return oldValue;
+    }
+
+    /**
+     * Closes stream associated with current thread.
+     *
+     * @throws IOException if an error occurs
+     */
+    @SuppressWarnings("resource") // we actually close the stream here
+    @Override
+    public void close() throws IOException {
+        IOUtils.close(inputStreamLocal.get());
+    }
+
+    /**
+     * Reads byte from stream associated with current thread.
+     *
+     * @return the byte read from stream
+     * @throws IOException if an error occurs
+     */
+    @SuppressWarnings("resource")
+    @Override
+    public int read() throws IOException {
+        final InputStream inputStream = inputStreamLocal.get();
+        if (null != inputStream) {
+            return inputStream.read();
+        }
+        return EOF;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/InfiniteCircularInputStream.java b/src/main/java/org/apache/commons/io/input/InfiniteCircularInputStream.java
new file mode 100644
index 0000000..0514c7e
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/InfiniteCircularInputStream.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ *
+ * An {@link InputStream} that infinitely repeats the provided bytes.
+ * <p>
+ * Closing this input stream has no effect. The methods in this class can be called after the stream has been closed
+ * without generating an {@link IOException}.
+ * </p>
+ *
+ * @since 2.6
+ */
+public class InfiniteCircularInputStream extends CircularInputStream {
+
+    /**
+     * Creates an instance from the specified array of bytes.
+     *
+     * @param repeatContent Input buffer to be repeated this buffer is not copied.
+     */
+    public InfiniteCircularInputStream(final byte[] repeatContent) {
+        // A negative number means an infinite target count.
+        super(repeatContent, -1);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/MarkShieldInputStream.java b/src/main/java/org/apache/commons/io/input/MarkShieldInputStream.java
new file mode 100644
index 0000000..083525b
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/MarkShieldInputStream.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * This is an alternative to {@link java.io.ByteArrayInputStream}
+ * which removes the synchronization overhead for non-concurrent
+ * access; as such this class is not thread-safe.
+ *
+ * Proxy stream that prevents the underlying input stream from being marked/reset.
+ * <p>
+ * This class is typically used in cases where an input stream that supports
+ * marking needs to be passed to a component that wants to explicitly mark
+ * the stream, but it is not desirable to allow marking of the stream.
+ * </p>
+ *
+ * @since 2.8.0
+ */
+public class MarkShieldInputStream extends ProxyInputStream {
+
+    /**
+     * Creates a proxy that shields the given input stream from being
+     * marked or rest.
+     *
+     * @param in underlying input stream
+     */
+    public MarkShieldInputStream(final InputStream in) {
+        super(in);
+    }
+
+    @SuppressWarnings("sync-override")
+    @Override
+    public void mark(final int readlimit) {
+        // no-op
+    }
+
+    @Override
+    public boolean markSupported() {
+        return false;
+    }
+
+    @SuppressWarnings("sync-override")
+    @Override
+    public void reset() throws IOException {
+        throw UnsupportedOperationExceptions.reset();
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/MemoryMappedFileInputStream.java b/src/main/java/org/apache/commons/io/input/MemoryMappedFileInputStream.java
new file mode 100644
index 0000000..797a41e
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/MemoryMappedFileInputStream.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.channels.FileChannel.MapMode;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+
+/**
+ * An {@link InputStream} that utilizes memory mapped files to improve performance. A sliding window of the file is
+ * mapped to memory to avoid mapping the entire file to memory at one time. The size of the sliding buffer is
+ * configurable.
+ * <p>
+ * For most operating systems, mapping a file into memory is more expensive than reading or writing a few tens of
+ * kilobytes of data. From the standpoint of performance. it is generally only worth mapping relatively large files into
+ * memory.
+ * </p>
+ * <p>
+ * Note: Use of this class does not necessarily obviate the need to use a {@link BufferedInputStream}. Depending on the
+ * use case, the use of buffering may still further improve performance. For example:
+ * </p>
+ * <pre>
+ * new BufferedInputStream(new GzipInputStream(new MemoryMappedFileInputStream(path))))
+ * </pre>
+ * <p>
+ * should outperform:
+ * </p>
+ * <pre>
+ * new GzipInputStream(new MemoryMappedFileInputStream(path))
+ * </pre>
+ *
+ * @since 2.12.0
+ */
+public class MemoryMappedFileInputStream extends InputStream {
+
+    /**
+     * Default size of the sliding memory mapped buffer. We use 256K, equal to 65536 pages (given a 4K page size).
+     * Increasing the value beyond the default size will generally not provide any increase in throughput.
+     */
+    private static final int DEFAULT_BUFFER_SIZE = 256 * 1024;
+    private static final ByteBuffer EMPTY_BUFFER = ByteBuffer.wrap(new byte[0]).asReadOnlyBuffer();
+    private final int bufferSize;
+    private final FileChannel channel;
+    private ByteBuffer buffer = EMPTY_BUFFER;
+    private boolean closed;
+
+    /**
+     * The starting position (within the file) of the next sliding buffer.
+     */
+    private long nextBufferPosition;
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param file The path of the file to open.
+     * @throws IOException If an I/O error occurs
+     */
+    public MemoryMappedFileInputStream(final Path file) throws IOException {
+        this(file, DEFAULT_BUFFER_SIZE);
+    }
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param file The path of the file to open.
+     * @param bufferSize Size of the sliding buffer.
+     * @throws IOException If an I/O error occurs.
+     */
+    public MemoryMappedFileInputStream(final Path file, final int bufferSize) throws IOException {
+        this.bufferSize = bufferSize;
+        this.channel = FileChannel.open(file, StandardOpenOption.READ);
+    }
+
+    @Override
+    public int available() throws IOException {
+        return buffer.remaining();
+    }
+
+    private void cleanBuffer() {
+        if (ByteBufferCleaner.isSupported() && buffer.isDirect()) {
+            ByteBufferCleaner.clean(buffer);
+        }
+    }
+
+    @Override
+    public void close() throws IOException {
+        if (!closed) {
+            cleanBuffer();
+            buffer = null;
+            channel.close();
+            closed = true;
+        }
+    }
+
+    private void ensureOpen() throws IOException {
+        if (closed) {
+            throw new IOException("Stream closed");
+        }
+    }
+
+    private void nextBuffer() throws IOException {
+        final long remainingInFile = channel.size() - nextBufferPosition;
+        if (remainingInFile > 0) {
+            final long amountToMap = Math.min(remainingInFile, bufferSize);
+            cleanBuffer();
+            buffer = channel.map(MapMode.READ_ONLY, nextBufferPosition, amountToMap);
+            nextBufferPosition += amountToMap;
+        } else {
+            buffer = EMPTY_BUFFER;
+        }
+    }
+
+    @Override
+    public int read() throws IOException {
+        ensureOpen();
+        if (!buffer.hasRemaining()) {
+            nextBuffer();
+            if (!buffer.hasRemaining()) {
+                return EOF;
+            }
+        }
+        return Short.toUnsignedInt(buffer.get());
+    }
+
+    @Override
+    public int read(final byte[] b, final int off, final int len) throws IOException {
+        ensureOpen();
+        if (!buffer.hasRemaining()) {
+            nextBuffer();
+            if (!buffer.hasRemaining()) {
+                return EOF;
+            }
+        }
+        final int numBytes = Math.min(buffer.remaining(), len);
+        buffer.get(b, off, numBytes);
+        return numBytes;
+    }
+
+    @Override
+    public long skip(final long n) throws IOException {
+        ensureOpen();
+        if (n <= 0) {
+            return 0;
+        }
+        if (n <= buffer.remaining()) {
+            buffer.position((int) (buffer.position() + n));
+            return n;
+        }
+        final long remainingInFile = channel.size() - nextBufferPosition;
+        final long skipped = buffer.remaining() + Math.min(remainingInFile, n - buffer.remaining());
+        nextBufferPosition += skipped - buffer.remaining();
+        nextBuffer();
+        return skipped;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/MessageDigestCalculatingInputStream.java b/src/main/java/org/apache/commons/io/input/MessageDigestCalculatingInputStream.java
new file mode 100644
index 0000000..5062c35
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/MessageDigestCalculatingInputStream.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.Provider;
+
+
+/**
+ * This class is an example for using an {@link ObservableInputStream}. It creates its own
+ * {@link org.apache.commons.io.input.ObservableInputStream.Observer}, which calculates a checksum using a
+ * MessageDigest, for example an MD5 sum.
+ * <p>
+ * See the MessageDigest section in the
+ * <a href= "https://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html#MessageDigest"> Java
+ * Cryptography Architecture Standard Algorithm Name Documentation</a> for information about standard algorithm names.
+ * </p>
+ * <p>
+ * <em>Note</em>: Neither {@link ObservableInputStream}, nor {@link MessageDigest}, are thread safe. So is
+ * {@link MessageDigestCalculatingInputStream}.
+ * </p>
+ */
+public class MessageDigestCalculatingInputStream extends ObservableInputStream {
+
+    /**
+     * Maintains the message digest.
+     */
+    public static class MessageDigestMaintainingObserver extends Observer {
+        private final MessageDigest messageDigest;
+
+        /**
+         * Creates an MessageDigestMaintainingObserver for the given MessageDigest.
+         * @param messageDigest the message digest to use
+         */
+        public MessageDigestMaintainingObserver(final MessageDigest messageDigest) {
+            this.messageDigest = messageDigest;
+        }
+
+        @Override
+        public void data(final byte[] input, final int offset, final int length) throws IOException {
+            messageDigest.update(input, offset, length);
+        }
+
+        @Override
+        public void data(final int input) throws IOException {
+            messageDigest.update((byte) input);
+        }
+    }
+
+    /**
+     * The default message digest algorithm.
+     * <p>
+     * The MD5 cryptographic algorithm is weak and should not be used.
+     * </p>
+     */
+    private static final String DEFAULT_ALGORITHM = "MD5";
+
+    /**
+     * Gets a MessageDigest object that implements the default digest algorithm.
+     *
+     * @return a Message Digest object that implements the default algorithm.
+     * @throws NoSuchAlgorithmException if no Provider supports a MessageDigestSpi implementation.
+     * @see Provider
+     */
+    static MessageDigest getDefaultMessageDigest() throws NoSuchAlgorithmException {
+        return MessageDigest.getInstance(DEFAULT_ALGORITHM);
+    }
+
+    private final MessageDigest messageDigest;
+
+    /**
+     * Creates a new instance, which calculates a signature on the given stream, using a {@link MessageDigest} with the
+     * "MD5" algorithm.
+     * <p>
+     * The MD5 algorithm is weak and should not be used.
+     * </p>
+     *
+     * @param inputStream the stream to calculate the message digest for
+     * @throws NoSuchAlgorithmException if no Provider supports a MessageDigestSpi implementation for the specified
+     *         algorithm.
+     */
+    public MessageDigestCalculatingInputStream(final InputStream inputStream) throws NoSuchAlgorithmException {
+        this(inputStream, getDefaultMessageDigest());
+    }
+
+    /**
+     * Creates a new instance, which calculates a signature on the given stream,
+     * using the given {@link MessageDigest}.
+     * @param inputStream the stream to calculate the message digest for
+     * @param messageDigest the message digest to use
+     */
+    public MessageDigestCalculatingInputStream(final InputStream inputStream, final MessageDigest messageDigest) {
+        super(inputStream, new MessageDigestMaintainingObserver(messageDigest));
+        this.messageDigest = messageDigest;
+    }
+
+    /**
+     * Creates a new instance, which calculates a signature on the given stream, using a {@link MessageDigest} with the
+     * given algorithm.
+     *
+     * @param inputStream the stream to calculate the message digest for
+     * @param algorithm the name of the algorithm requested. See the MessageDigest section in the
+     *        <a href= "https://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html#MessageDigest">
+     *        Java Cryptography Architecture Standard Algorithm Name Documentation</a> for information about standard
+     *        algorithm names.
+     * @throws NoSuchAlgorithmException if no Provider supports a MessageDigestSpi implementation for the specified
+     *         algorithm.
+     */
+    public MessageDigestCalculatingInputStream(final InputStream inputStream, final String algorithm)
+        throws NoSuchAlgorithmException {
+        this(inputStream, MessageDigest.getInstance(algorithm));
+    }
+
+    /**
+     * Gets the {@link MessageDigest}, which is being used for generating the
+     * checksum.
+     * <p>
+     * <em>Note</em>: The checksum will only reflect the data, which has been read so far.
+     * This is probably not, what you expect. Make sure, that the complete data has been
+     * read, if that is what you want. The easiest way to do so is by invoking
+     * {@link #consume()}.
+     * </p>
+     *
+     * @return the message digest used
+     */
+    public MessageDigest getMessageDigest() {
+        return messageDigest;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/NullInputStream.java b/src/main/java/org/apache/commons/io/input/NullInputStream.java
new file mode 100644
index 0000000..9c5745f
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/NullInputStream.java
@@ -0,0 +1,360 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * A functional, light weight {@link InputStream} that emulates
+ * a stream of a specified size.
+ * <p>
+ * This implementation provides a light weight
+ * object for testing with an {@link InputStream}
+ * where the contents don't matter.
+ * </p>
+ * <p>
+ * One use case would be for testing the handling of
+ * large {@link InputStream} as it can emulate that
+ * scenario without the overhead of actually processing
+ * large numbers of bytes - significantly speeding up
+ * test execution times.
+ * </p>
+ * <p>
+ * This implementation returns zero from the method that
+ * reads a byte and leaves the array unchanged in the read
+ * methods that are passed a byte array.
+ * If alternative data is required the {@code processByte()} and
+ * {@code processBytes()} methods can be implemented to generate
+ * data, for example:
+ * </p>
+ *
+ * <pre>
+ *  public class TestInputStream extends NullInputStream {
+ *      public TestInputStream(int size) {
+ *          super(size);
+ *      }
+ *      protected int processByte() {
+ *          return ... // return required value here
+ *      }
+ *      protected void processBytes(byte[] bytes, int offset, int length) {
+ *          for (int i = offset; i &lt; length; i++) {
+ *              bytes[i] = ... // set array value here
+ *          }
+ *      }
+ *  }
+ * </pre>
+ *
+ * @since 1.3
+ *
+ */
+public class NullInputStream extends InputStream {
+
+    /**
+     * The singleton instance.
+     *
+     * @since 2.12.0
+     */
+    public static final NullInputStream INSTANCE = new NullInputStream();
+
+    private final long size;
+    private long position;
+    private long mark = -1;
+    private long readlimit;
+    private boolean eof;
+    private final boolean throwEofException;
+    private final boolean markSupported;
+
+    /**
+     * Create an {@link InputStream} that emulates a size 0 stream
+     * which supports marking and does not throw EOFException.
+     *
+     * @since 2.7
+     */
+    public NullInputStream() {
+       this(0, true, false);
+    }
+
+    /**
+     * Create an {@link InputStream} that emulates a specified size
+     * which supports marking and does not throw EOFException.
+     *
+     * @param size The size of the input stream to emulate.
+     */
+    public NullInputStream(final long size) {
+       this(size, true, false);
+    }
+
+    /**
+     * Create an {@link InputStream} that emulates a specified
+     * size with option settings.
+     *
+     * @param size The size of the input stream to emulate.
+     * @param markSupported Whether this instance will support
+     * the {@code mark()} functionality.
+     * @param throwEofException Whether this implementation
+     * will throw an {@link EOFException} or return -1 when the
+     * end of file is reached.
+     */
+    public NullInputStream(final long size, final boolean markSupported, final boolean throwEofException) {
+       this.size = size;
+       this.markSupported = markSupported;
+       this.throwEofException = throwEofException;
+    }
+
+    /**
+     * Return the number of bytes that can be read.
+     *
+     * @return The number of bytes that can be read.
+     */
+    @Override
+    public int available() {
+        final long avail = size - position;
+        if (avail <= 0) {
+            return 0;
+        }
+        if (avail > Integer.MAX_VALUE) {
+            return Integer.MAX_VALUE;
+        }
+        return (int) avail;
+    }
+
+    /**
+     * Close this input stream - resets the internal state to
+     * the initial values.
+     *
+     * @throws IOException If an error occurs.
+     */
+    @Override
+    public void close() throws IOException {
+        eof = false;
+        position = 0;
+        mark = -1;
+    }
+
+    /**
+     * Handle End of File.
+     *
+     * @return {@code -1} if {@code throwEofException} is
+     * set to {@code false}
+     * @throws EOFException if {@code throwEofException} is set
+     * to {@code true}.
+     */
+    private int doEndOfFile() throws EOFException {
+        eof = true;
+        if (throwEofException) {
+            throw new EOFException();
+        }
+        return EOF;
+    }
+
+    /**
+     * Return the current position.
+     *
+     * @return the current position.
+     */
+    public long getPosition() {
+        return position;
+    }
+
+    /**
+     * Return the size this {@link InputStream} emulates.
+     *
+     * @return The size of the input stream to emulate.
+     */
+    public long getSize() {
+        return size;
+    }
+
+    /**
+     * Mark the current position.
+     *
+     * @param readlimit The number of bytes before this marked position
+     * is invalid.
+     * @throws UnsupportedOperationException if mark is not supported.
+     */
+    @Override
+    public synchronized void mark(final int readlimit) {
+        if (!markSupported) {
+            throw UnsupportedOperationExceptions.mark();
+        }
+        mark = position;
+        this.readlimit = readlimit;
+    }
+
+    /**
+     * Indicates whether <i>mark</i> is supported.
+     *
+     * @return Whether <i>mark</i> is supported or not.
+     */
+    @Override
+    public boolean markSupported() {
+        return markSupported;
+    }
+
+    /**
+     * Return a byte value for the  {@code read()} method.
+     * <p>
+     * This implementation returns zero.
+     *
+     * @return This implementation always returns zero.
+     */
+    protected int processByte() {
+        // do nothing - overridable by subclass
+        return 0;
+    }
+
+    /**
+     * Process the bytes for the {@code read(byte[], offset, length)}
+     * method.
+     * <p>
+     * This implementation leaves the byte array unchanged.
+     *
+     * @param bytes The byte array
+     * @param offset The offset to start at.
+     * @param length The number of bytes.
+     */
+    protected void processBytes(final byte[] bytes, final int offset, final int length) {
+        // do nothing - overridable by subclass
+    }
+
+    /**
+     * Read a byte.
+     *
+     * @return Either The byte value returned by {@code processByte()}
+     * or {@code -1} if the end of file has been reached and
+     * {@code throwEofException} is set to {@code false}.
+     * @throws EOFException if the end of file is reached and
+     * {@code throwEofException} is set to {@code true}.
+     * @throws IOException if trying to read past the end of file.
+     */
+    @Override
+    public int read() throws IOException {
+        if (eof) {
+            throw new IOException("Read after end of file");
+        }
+        if (position == size) {
+            return doEndOfFile();
+        }
+        position++;
+        return processByte();
+    }
+
+    /**
+     * Read some bytes into the specified array.
+     *
+     * @param bytes The byte array to read into
+     * @return The number of bytes read or {@code -1}
+     * if the end of file has been reached and
+     * {@code throwEofException} is set to {@code false}.
+     * @throws EOFException if the end of file is reached and
+     * {@code throwEofException} is set to {@code true}.
+     * @throws IOException if trying to read past the end of file.
+     */
+    @Override
+    public int read(final byte[] bytes) throws IOException {
+        return read(bytes, 0, bytes.length);
+    }
+
+    /**
+     * Read the specified number bytes into an array.
+     *
+     * @param bytes The byte array to read into.
+     * @param offset The offset to start reading bytes into.
+     * @param length The number of bytes to read.
+     * @return The number of bytes read or {@code -1}
+     * if the end of file has been reached and
+     * {@code throwEofException} is set to {@code false}.
+     * @throws EOFException if the end of file is reached and
+     * {@code throwEofException} is set to {@code true}.
+     * @throws IOException if trying to read past the end of file.
+     */
+    @Override
+    public int read(final byte[] bytes, final int offset, final int length) throws IOException {
+        if (eof) {
+            throw new IOException("Read after end of file");
+        }
+        if (position == size) {
+            return doEndOfFile();
+        }
+        position += length;
+        int returnLength = length;
+        if (position > size) {
+            returnLength = length - (int)(position - size);
+            position = size;
+        }
+        processBytes(bytes, offset, returnLength);
+        return returnLength;
+    }
+
+    /**
+     * Reset the stream to the point when mark was last called.
+     *
+     * @throws UnsupportedOperationException if mark is not supported.
+     * @throws IOException If no position has been marked
+     * or the read limit has been exceeded since the last position was
+     * marked.
+     */
+    @Override
+    public synchronized void reset() throws IOException {
+        if (!markSupported) {
+            throw UnsupportedOperationExceptions.reset();
+        }
+        if (mark < 0) {
+            throw new IOException("No position has been marked");
+        }
+        if (position > mark + readlimit) {
+            throw new IOException("Marked position [" + mark +
+                    "] is no longer valid - passed the read limit [" +
+                    readlimit + "]");
+        }
+        position = mark;
+        eof = false;
+    }
+
+    /**
+     * Skip a specified number of bytes.
+     *
+     * @param numberOfBytes The number of bytes to skip.
+     * @return The number of bytes skipped or {@code -1}
+     * if the end of file has been reached and
+     * {@code throwEofException} is set to {@code false}.
+     * @throws EOFException if the end of file is reached and
+     * {@code throwEofException} is set to {@code true}.
+     * @throws IOException if trying to read past the end of file.
+     */
+    @Override
+    public long skip(final long numberOfBytes) throws IOException {
+        if (eof) {
+            throw new IOException("Skip after end of file");
+        }
+        if (position == size) {
+            return doEndOfFile();
+        }
+        position += numberOfBytes;
+        long returnLength = numberOfBytes;
+        if (position > size) {
+            returnLength = numberOfBytes - (position - size);
+            position = size;
+        }
+        return returnLength;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/NullReader.java b/src/main/java/org/apache/commons/io/input/NullReader.java
new file mode 100644
index 0000000..194d16f
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/NullReader.java
@@ -0,0 +1,344 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.Reader;
+
+/**
+ * A functional, light weight {@link Reader} that emulates
+ * a reader of a specified size.
+ * <p>
+ * This implementation provides a light weight
+ * object for testing with an {@link Reader}
+ * where the contents don't matter.
+ * </p>
+ * <p>
+ * One use case would be for testing the handling of
+ * large {@link Reader} as it can emulate that
+ * scenario without the overhead of actually processing
+ * large numbers of characters - significantly speeding up
+ * test execution times.
+ * </p>
+ * <p>
+ * This implementation returns a space from the method that
+ * reads a character and leaves the array unchanged in the read
+ * methods that are passed a character array.
+ * If alternative data is required the {@code processChar()} and
+ * {@code processChars()} methods can be implemented to generate
+ * data, for example:
+ * </p>
+ *
+ * <pre>
+ *  public class TestReader extends NullReader {
+ *      public TestReader(int size) {
+ *          super(size);
+ *      }
+ *      protected char processChar() {
+ *          return ... // return required value here
+ *      }
+ *      protected void processChars(char[] chars, int offset, int length) {
+ *          for (int i = offset; i &lt; length; i++) {
+ *              chars[i] = ... // set array value here
+ *          }
+ *      }
+ *  }
+ * </pre>
+ *
+ * @since 1.3
+ */
+public class NullReader extends Reader {
+
+    /**
+     * The singleton instance.
+     *
+     * @since 2.12.0
+     */
+    public static final NullReader INSTANCE = new NullReader();
+
+    private final long size;
+    private long position;
+    private long mark = -1;
+    private long readlimit;
+    private boolean eof;
+    private final boolean throwEofException;
+    private final boolean markSupported;
+
+    /**
+     * Creates a {@link Reader} that emulates a size 0 reader
+     * which supports marking and does not throw EOFException.
+     *
+     * @since 2.7
+     */
+    public NullReader() {
+       this(0, true, false);
+    }
+
+    /**
+     * Creates a {@link Reader} that emulates a specified size
+     * which supports marking and does not throw EOFException.
+     *
+     * @param size The size of the reader to emulate.
+     */
+    public NullReader(final long size) {
+       this(size, true, false);
+    }
+
+    /**
+     * Creates a {@link Reader} that emulates a specified
+     * size with option settings.
+     *
+     * @param size The size of the reader to emulate.
+     * @param markSupported Whether this instance will support
+     * the {@code mark()} functionality.
+     * @param throwEofException Whether this implementation
+     * will throw an {@link EOFException} or return -1 when the
+     * end of file is reached.
+     */
+    public NullReader(final long size, final boolean markSupported, final boolean throwEofException) {
+       this.size = size;
+       this.markSupported = markSupported;
+       this.throwEofException = throwEofException;
+    }
+
+    /**
+     * Closes this Reader - resets the internal state to
+     * the initial values.
+     *
+     * @throws IOException If an error occurs.
+     */
+    @Override
+    public void close() throws IOException {
+        eof = false;
+        position = 0;
+        mark = -1;
+    }
+
+    /**
+     * Handles End of File.
+     *
+     * @return {@code -1} if {@code throwEofException} is
+     * set to {@code false}
+     * @throws EOFException if {@code throwEofException} is set
+     * to {@code true}.
+     */
+    private int doEndOfFile() throws EOFException {
+        eof = true;
+        if (throwEofException) {
+            throw new EOFException();
+        }
+        return EOF;
+    }
+
+    /**
+     * Returns the current position.
+     *
+     * @return the current position.
+     */
+    public long getPosition() {
+        return position;
+    }
+
+    /**
+     * Returns the size this {@link Reader} emulates.
+     *
+     * @return The size of the reader to emulate.
+     */
+    public long getSize() {
+        return size;
+    }
+
+    /**
+     * Marks the current position.
+     *
+     * @param readlimit The number of characters before this marked position
+     * is invalid.
+     * @throws UnsupportedOperationException if mark is not supported.
+     */
+    @Override
+    public synchronized void mark(final int readlimit) {
+        if (!markSupported) {
+            throw UnsupportedOperationExceptions.mark();
+        }
+        mark = position;
+        this.readlimit = readlimit;
+    }
+
+    /**
+     * Indicates whether <i>mark</i> is supported.
+     *
+     * @return Whether <i>mark</i> is supported or not.
+     */
+    @Override
+    public boolean markSupported() {
+        return markSupported;
+    }
+
+    /**
+     * Returns a character value for the  {@code read()} method.
+     * <p>
+     * This implementation returns zero.
+     * </p>
+     *
+     * @return This implementation always returns zero.
+     */
+    protected int processChar() {
+        // do nothing - overridable by subclass
+        return 0;
+    }
+
+    /**
+     * Process the characters for the {@code read(char[], offset, length)}
+     * method.
+     * <p>
+     * This implementation leaves the character array unchanged.
+     * </p>
+     *
+     * @param chars The character array
+     * @param offset The offset to start at.
+     * @param length The number of characters.
+     */
+    protected void processChars(final char[] chars, final int offset, final int length) {
+        // do nothing - overridable by subclass
+    }
+
+    /**
+     * Reads a character.
+     *
+     * @return Either The character value returned by {@code processChar()}
+     * or {@code -1} if the end of file has been reached and
+     * {@code throwEofException} is set to {@code false}.
+     * @throws EOFException if the end of file is reached and
+     * {@code throwEofException} is set to {@code true}.
+     * @throws IOException if trying to read past the end of file.
+     */
+    @Override
+    public int read() throws IOException {
+        if (eof) {
+            throw new IOException("Read after end of file");
+        }
+        if (position == size) {
+            return doEndOfFile();
+        }
+        position++;
+        return processChar();
+    }
+
+    /**
+     * Reads some characters into the specified array.
+     *
+     * @param chars The character array to read into
+     * @return The number of characters read or {@code -1}
+     * if the end of file has been reached and
+     * {@code throwEofException} is set to {@code false}.
+     * @throws EOFException if the end of file is reached and
+     * {@code throwEofException} is set to {@code true}.
+     * @throws IOException if trying to read past the end of file.
+     */
+    @Override
+    public int read(final char[] chars) throws IOException {
+        return read(chars, 0, chars.length);
+    }
+
+    /**
+     * Reads the specified number characters into an array.
+     *
+     * @param chars The character array to read into.
+     * @param offset The offset to start reading characters into.
+     * @param length The number of characters to read.
+     * @return The number of characters read or {@code -1}
+     * if the end of file has been reached and
+     * {@code throwEofException} is set to {@code false}.
+     * @throws EOFException if the end of file is reached and
+     * {@code throwEofException} is set to {@code true}.
+     * @throws IOException if trying to read past the end of file.
+     */
+    @Override
+    public int read(final char[] chars, final int offset, final int length) throws IOException {
+        if (eof) {
+            throw new IOException("Read after end of file");
+        }
+        if (position == size) {
+            return doEndOfFile();
+        }
+        position += length;
+        int returnLength = length;
+        if (position > size) {
+            returnLength = length - (int)(position - size);
+            position = size;
+        }
+        processChars(chars, offset, returnLength);
+        return returnLength;
+    }
+
+    /**
+     * Resets the stream to the point when mark was last called.
+     *
+     * @throws UnsupportedOperationException if mark is not supported.
+     * @throws IOException If no position has been marked
+     * or the read limit has been exceeded since the last position was
+     * marked.
+     */
+    @Override
+    public synchronized void reset() throws IOException {
+        if (!markSupported) {
+            throw UnsupportedOperationExceptions.reset();
+        }
+        if (mark < 0) {
+            throw new IOException("No position has been marked");
+        }
+        if (position > mark + readlimit) {
+            throw new IOException("Marked position [" + mark +
+                    "] is no longer valid - passed the read limit [" +
+                    readlimit + "]");
+        }
+        position = mark;
+        eof = false;
+    }
+
+    /**
+     * Skips a specified number of characters.
+     *
+     * @param numberOfChars The number of characters to skip.
+     * @return The number of characters skipped or {@code -1}
+     * if the end of file has been reached and
+     * {@code throwEofException} is set to {@code false}.
+     * @throws EOFException if the end of file is reached and
+     * {@code throwEofException} is set to {@code true}.
+     * @throws IOException if trying to read past the end of file.
+     */
+    @Override
+    public long skip(final long numberOfChars) throws IOException {
+        if (eof) {
+            throw new IOException("Skip after end of file");
+        }
+        if (position == size) {
+            return doEndOfFile();
+        }
+        position += numberOfChars;
+        long returnLength = numberOfChars;
+        if (position > size) {
+            returnLength = numberOfChars - (position - size);
+            position = size;
+        }
+        return returnLength;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/ObservableInputStream.java b/src/main/java/org/apache/commons/io/input/ObservableInputStream.java
new file mode 100644
index 0000000..259f64c
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/ObservableInputStream.java
@@ -0,0 +1,316 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.function.IOConsumer;
+
+/**
+ * The {@link ObservableInputStream} allows, that an InputStream may be consumed by other receivers, apart from the
+ * thread, which is reading it. The other consumers are implemented as instances of {@link Observer}.
+ * <p>
+ * A typical application may be the generation of a {@link java.security.MessageDigest} on the fly.
+ * </p>
+ * <p>
+ * <em>Note</em>: The {@link ObservableInputStream} is <em>not</em> thread safe, as instances of InputStream usually
+ * aren't. If you must access the stream from multiple threads, then synchronization, locking, or a similar means must
+ * be used.
+ * </p>
+ *
+ * @see MessageDigestCalculatingInputStream
+ */
+public class ObservableInputStream extends ProxyInputStream {
+
+    /**
+     * Abstracts observer callback for {@link ObservableInputStream}s.
+     */
+    public static abstract class Observer {
+
+        /**
+         * Called to indicate that the {@link ObservableInputStream} has been closed.
+         *
+         * @throws IOException if an I/O error occurs.
+         */
+        @SuppressWarnings("unused") // Possibly thrown from subclasses.
+        public void closed() throws IOException {
+            // noop
+        }
+
+        /**
+         * Called to indicate that {@link InputStream#read(byte[])}, or {@link InputStream#read(byte[], int, int)} have
+         * been called, and are about to invoke data.
+         *
+         * @param buffer The byte array, which has been passed to the read call, and where data has been stored.
+         * @param offset The offset within the byte array, where data has been stored.
+         * @param length The number of bytes, which have been stored in the byte array.
+         * @throws IOException if an I/O error occurs.
+         */
+        @SuppressWarnings("unused") // Possibly thrown from subclasses.
+        public void data(final byte[] buffer, final int offset, final int length) throws IOException {
+            // noop
+        }
+
+        /**
+         * Called to indicate, that {@link InputStream#read()} has been invoked on the {@link ObservableInputStream},
+         * and will return a value.
+         *
+         * @param value The value, which is being returned. This will never be -1 (EOF), because, in that case,
+         *        {@link #finished()} will be invoked instead.
+         * @throws IOException if an I/O error occurs.
+         */
+        @SuppressWarnings("unused") // Possibly thrown from subclasses.
+        public void data(final int value) throws IOException {
+            // noop
+        }
+
+        /**
+         * Called to indicate that an error occurred on the underlying stream.
+         *
+         * @param exception the exception to throw
+         * @throws IOException if an I/O error occurs.
+         */
+        public void error(final IOException exception) throws IOException {
+            throw exception;
+        }
+
+        /**
+         * Called to indicate that EOF has been seen on the underlying stream. This method may be called multiple times,
+         * if the reader keeps invoking either of the read methods, and they will consequently keep returning EOF.
+         *
+         * @throws IOException if an I/O error occurs.
+         */
+        @SuppressWarnings("unused") // Possibly thrown from subclasses.
+        public void finished() throws IOException {
+            // noop
+        }
+    }
+
+    private final List<Observer> observers;
+
+    /**
+     * Creates a new ObservableInputStream for the given InputStream.
+     *
+     * @param inputStream the input stream to observe.
+     */
+    public ObservableInputStream(final InputStream inputStream) {
+        this(inputStream, new ArrayList<>());
+    }
+
+    /**
+     * Creates a new ObservableInputStream for the given InputStream.
+     *
+     * @param inputStream the input stream to observe.
+     * @param observers List of observer callbacks.
+     */
+    private ObservableInputStream(final InputStream inputStream, final List<Observer> observers) {
+        super(inputStream);
+        this.observers = observers;
+    }
+
+    /**
+     * Creates a new ObservableInputStream for the given InputStream.
+     *
+     * @param inputStream the input stream to observe.
+     * @param observers List of observer callbacks.
+     * @since 2.9.0
+     */
+    public ObservableInputStream(final InputStream inputStream, final Observer... observers) {
+        this(inputStream, Arrays.asList(observers));
+    }
+
+    /**
+     * Adds an Observer.
+     *
+     * @param observer the observer to add.
+     */
+    public void add(final Observer observer) {
+        observers.add(observer);
+    }
+
+    @Override
+    public void close() throws IOException {
+        IOException ioe = null;
+        try {
+            super.close();
+        } catch (final IOException e) {
+            ioe = e;
+        }
+        if (ioe == null) {
+            noteClosed();
+        } else {
+            noteError(ioe);
+        }
+    }
+
+    /**
+     * Reads all data from the underlying {@link InputStream}, while notifying the observers.
+     *
+     * @throws IOException The underlying {@link InputStream}, or either of the observers has thrown an exception.
+     */
+    public void consume() throws IOException {
+        IOUtils.consume(this);
+    }
+
+    private void forEachObserver(final IOConsumer<Observer> action) throws IOException {
+        IOConsumer.forAll(action, observers);
+    }
+
+    /**
+     * Gets all currently registered observers.
+     *
+     * @return a list of the currently registered observers.
+     * @since 2.9.0
+     */
+    public List<Observer> getObservers() {
+        return observers;
+    }
+
+    /**
+     * Notifies the observers by invoking {@link Observer#finished()}.
+     *
+     * @throws IOException Some observer has thrown an exception, which is being passed down.
+     */
+    protected void noteClosed() throws IOException {
+        forEachObserver(Observer::closed);
+    }
+
+    /**
+     * Notifies the observers by invoking {@link Observer#data(int)} with the given arguments.
+     *
+     * @param value Passed to the observers.
+     * @throws IOException Some observer has thrown an exception, which is being passed down.
+     */
+    protected void noteDataByte(final int value) throws IOException {
+        forEachObserver(observer -> observer.data(value));
+    }
+
+    /**
+     * Notifies the observers by invoking {@link Observer#data(byte[],int,int)} with the given arguments.
+     *
+     * @param buffer Passed to the observers.
+     * @param offset Passed to the observers.
+     * @param length Passed to the observers.
+     * @throws IOException Some observer has thrown an exception, which is being passed down.
+     */
+    protected void noteDataBytes(final byte[] buffer, final int offset, final int length) throws IOException {
+        forEachObserver(observer -> observer.data(buffer, offset, length));
+    }
+
+    /**
+     * Notifies the observers by invoking {@link Observer#error(IOException)} with the given argument.
+     *
+     * @param exception Passed to the observers.
+     * @throws IOException Some observer has thrown an exception, which is being passed down. This may be the same
+     *         exception, which has been passed as an argument.
+     */
+    protected void noteError(final IOException exception) throws IOException {
+        forEachObserver(observer -> observer.error(exception));
+    }
+
+    /**
+     * Notifies the observers by invoking {@link Observer#finished()}.
+     *
+     * @throws IOException Some observer has thrown an exception, which is being passed down.
+     */
+    protected void noteFinished() throws IOException {
+        forEachObserver(Observer::finished);
+    }
+
+    private void notify(final byte[] buffer, final int offset, final int result, final IOException ioe) throws IOException {
+        if (ioe != null) {
+            noteError(ioe);
+            throw ioe;
+        }
+        if (result == EOF) {
+            noteFinished();
+        } else if (result > 0) {
+            noteDataBytes(buffer, offset, result);
+        }
+    }
+
+    @Override
+    public int read() throws IOException {
+        int result = 0;
+        IOException ioe = null;
+        try {
+            result = super.read();
+        } catch (final IOException ex) {
+            ioe = ex;
+        }
+        if (ioe != null) {
+            noteError(ioe);
+            throw ioe;
+        }
+        if (result == EOF) {
+            noteFinished();
+        } else {
+            noteDataByte(result);
+        }
+        return result;
+    }
+
+    @Override
+    public int read(final byte[] buffer) throws IOException {
+        int result = 0;
+        IOException ioe = null;
+        try {
+            result = super.read(buffer);
+        } catch (final IOException ex) {
+            ioe = ex;
+        }
+        notify(buffer, 0, result, ioe);
+        return result;
+    }
+
+    @Override
+    public int read(final byte[] buffer, final int offset, final int length) throws IOException {
+        int result = 0;
+        IOException ioe = null;
+        try {
+            result = super.read(buffer, offset, length);
+        } catch (final IOException ex) {
+            ioe = ex;
+        }
+        notify(buffer, offset, result, ioe);
+        return result;
+    }
+
+    /**
+     * Removes an Observer.
+     *
+     * @param observer the observer to remove
+     */
+    public void remove(final Observer observer) {
+        observers.remove(observer);
+    }
+
+    /**
+     * Removes all Observers.
+     */
+    public void removeAllObservers() {
+        observers.clear();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/ProxyInputStream.java b/src/main/java/org/apache/commons/io/input/ProxyInputStream.java
new file mode 100644
index 0000000..4344cba
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/ProxyInputStream.java
@@ -0,0 +1,243 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.apache.commons.io.IOUtils;
+
+/**
+ * A Proxy stream which acts as expected, that is it passes the method
+ * calls on to the proxied stream and doesn't change which methods are
+ * being called.
+ * <p>
+ * It is an alternative base class to FilterInputStream
+ * to increase reusability, because FilterInputStream changes the
+ * methods being called, such as read(byte[]) to read(byte[], int, int).
+ * </p>
+ * <p>
+ * See the protected methods for ways in which a subclass can easily decorate
+ * a stream with custom pre-, post- or error processing functionality.
+ * </p>
+ */
+public abstract class ProxyInputStream extends FilterInputStream {
+
+    /**
+     * Constructs a new ProxyInputStream.
+     *
+     * @param proxy  the InputStream to delegate to
+     */
+    public ProxyInputStream(final InputStream proxy) {
+        super(proxy);
+        // the proxy is stored in a protected superclass variable named 'in'
+    }
+
+    /**
+     * Invoked by the read methods after the proxied call has returned
+     * successfully. The number of bytes returned to the caller (or -1 if
+     * the end of stream was reached) is given as an argument.
+     * <p>
+     * Subclasses can override this method to add common post-processing
+     * functionality without having to override all the read methods.
+     * The default implementation does nothing.
+     * </p>
+     * <p>
+     * Note this method is <em>not</em> called from {@link #skip(long)} or
+     * {@link #reset()}. You need to explicitly override those methods if
+     * you want to add post-processing steps also to them.
+     * </p>
+     * @since 2.0
+     * @param n number of bytes read, or -1 if the end of stream was reached
+     * @throws IOException if the post-processing fails
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected void afterRead(final int n) throws IOException {
+        // no-op
+    }
+
+    /**
+     * Invokes the delegate's {@code available()} method.
+     * @return the number of available bytes
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int available() throws IOException {
+        try {
+            return super.available();
+        } catch (final IOException e) {
+            handleIOException(e);
+            return 0;
+        }
+    }
+
+    /**
+     * Invoked by the read methods before the call is proxied. The number
+     * of bytes that the caller wanted to read (1 for the {@link #read()}
+     * method, buffer length for {@link #read(byte[])}, etc.) is given as
+     * an argument.
+     * <p>
+     * Subclasses can override this method to add common pre-processing
+     * functionality without having to override all the read methods.
+     * The default implementation does nothing.
+     * </p>
+     * <p>
+     * Note this method is <em>not</em> called from {@link #skip(long)} or
+     * {@link #reset()}. You need to explicitly override those methods if
+     * you want to add pre-processing steps also to them.
+     * </p>
+     * @since 2.0
+     * @param n number of bytes that the caller asked to be read
+     * @throws IOException if the pre-processing fails
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected void beforeRead(final int n) throws IOException {
+        // no-op
+    }
+
+    /**
+     * Invokes the delegate's {@code close()} method.
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void close() throws IOException {
+        IOUtils.close(in, this::handleIOException);
+    }
+
+    /**
+     * Handle any IOExceptions thrown; by default, throws the given exception.
+     * <p>
+     * This method provides a point to implement custom exception
+     * handling. The default behavior is to re-throw the exception.
+     * </p>
+     * @param e The IOException thrown
+     * @throws IOException if an I/O error occurs.
+     * @since 2.0
+     */
+    protected void handleIOException(final IOException e) throws IOException {
+        throw e;
+    }
+
+    /**
+     * Invokes the delegate's {@code mark(int)} method.
+     * @param readlimit read ahead limit
+     */
+    @Override
+    public synchronized void mark(final int readlimit) {
+        in.mark(readlimit);
+    }
+
+    /**
+     * Invokes the delegate's {@code markSupported()} method.
+     * @return true if mark is supported, otherwise false
+     */
+    @Override
+    public boolean markSupported() {
+        return in.markSupported();
+    }
+
+    /**
+     * Invokes the delegate's {@code read()} method.
+     * @return the byte read or -1 if the end of stream
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int read() throws IOException {
+        try {
+            beforeRead(1);
+            final int b = in.read();
+            afterRead(b != EOF ? 1 : EOF);
+            return b;
+        } catch (final IOException e) {
+            handleIOException(e);
+            return EOF;
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code read(byte[])} method.
+     * @param bts the buffer to read the bytes into
+     * @return the number of bytes read or EOF if the end of stream
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int read(final byte[] bts) throws IOException {
+        try {
+            beforeRead(IOUtils.length(bts));
+            final int n = in.read(bts);
+            afterRead(n);
+            return n;
+        } catch (final IOException e) {
+            handleIOException(e);
+            return EOF;
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code read(byte[], int, int)} method.
+     * @param bts the buffer to read the bytes into
+     * @param off The start offset
+     * @param len The number of bytes to read
+     * @return the number of bytes read or -1 if the end of stream
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int read(final byte[] bts, final int off, final int len) throws IOException {
+        try {
+            beforeRead(len);
+            final int n = in.read(bts, off, len);
+            afterRead(n);
+            return n;
+        } catch (final IOException e) {
+            handleIOException(e);
+            return EOF;
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code reset()} method.
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public synchronized void reset() throws IOException {
+        try {
+            in.reset();
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code skip(long)} method.
+     * @param ln the number of bytes to skip
+     * @return the actual number of bytes skipped
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public long skip(final long ln) throws IOException {
+        try {
+            return in.skip(ln);
+        } catch (final IOException e) {
+            handleIOException(e);
+            return 0;
+        }
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/ProxyReader.java b/src/main/java/org/apache/commons/io/input/ProxyReader.java
new file mode 100644
index 0000000..f05f5b0
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/ProxyReader.java
@@ -0,0 +1,266 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.FilterReader;
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.CharBuffer;
+
+import org.apache.commons.io.IOUtils;
+
+/**
+ * A Proxy stream which acts as expected, that is it passes the method
+ * calls on to the proxied stream and doesn't change which methods are
+ * being called.
+ * <p>
+ * It is an alternative base class to FilterReader
+ * to increase reusability, because FilterReader changes the
+ * methods being called, such as read(char[]) to read(char[], int, int).
+ * </p>
+ */
+public abstract class ProxyReader extends FilterReader {
+
+    /**
+     * Constructs a new ProxyReader.
+     *
+     * @param proxy  the Reader to delegate to
+     */
+    public ProxyReader(final Reader proxy) {
+        super(proxy);
+        // the proxy is stored in a protected superclass variable named 'in'
+    }
+
+    /**
+     * Invoked by the read methods after the proxied call has returned
+     * successfully. The number of chars returned to the caller (or -1 if
+     * the end of stream was reached) is given as an argument.
+     * <p>
+     * Subclasses can override this method to add common post-processing
+     * functionality without having to override all the read methods.
+     * The default implementation does nothing.
+     * <p>
+     * Note this method is <em>not</em> called from {@link #skip(long)} or
+     * {@link #reset()}. You need to explicitly override those methods if
+     * you want to add post-processing steps also to them.
+     *
+     * @since 2.0
+     * @param n number of chars read, or -1 if the end of stream was reached
+     * @throws IOException if the post-processing fails
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected void afterRead(final int n) throws IOException {
+        // noop
+    }
+
+    /**
+     * Invoked by the read methods before the call is proxied. The number
+     * of chars that the caller wanted to read (1 for the {@link #read()}
+     * method, buffer length for {@link #read(char[])}, etc.) is given as
+     * an argument.
+     * <p>
+     * Subclasses can override this method to add common pre-processing
+     * functionality without having to override all the read methods.
+     * The default implementation does nothing.
+     * <p>
+     * Note this method is <em>not</em> called from {@link #skip(long)} or
+     * {@link #reset()}. You need to explicitly override those methods if
+     * you want to add pre-processing steps also to them.
+     *
+     * @since 2.0
+     * @param n number of chars that the caller asked to be read
+     * @throws IOException if the pre-processing fails
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected void beforeRead(final int n) throws IOException {
+        // noop
+    }
+
+    /**
+     * Invokes the delegate's {@code close()} method.
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void close() throws IOException {
+        try {
+            in.close();
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Handle any IOExceptions thrown.
+     * <p>
+     * This method provides a point to implement custom exception
+     * handling. The default behavior is to re-throw the exception.
+     * @param e The IOException thrown
+     * @throws IOException if an I/O error occurs.
+     * @since 2.0
+     */
+    protected void handleIOException(final IOException e) throws IOException {
+        throw e;
+    }
+
+    /**
+     * Invokes the delegate's {@code mark(int)} method.
+     * @param idx read ahead limit
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public synchronized void mark(final int idx) throws IOException {
+        try {
+            in.mark(idx);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code markSupported()} method.
+     * @return true if mark is supported, otherwise false
+     */
+    @Override
+    public boolean markSupported() {
+        return in.markSupported();
+    }
+
+    /**
+     * Invokes the delegate's {@code read()} method.
+     * @return the character read or -1 if the end of stream
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int read() throws IOException {
+        try {
+            beforeRead(1);
+            final int c = in.read();
+            afterRead(c != EOF ? 1 : EOF);
+            return c;
+        } catch (final IOException e) {
+            handleIOException(e);
+            return EOF;
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code read(char[])} method.
+     * @param chr the buffer to read the characters into
+     * @return the number of characters read or -1 if the end of stream
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int read(final char[] chr) throws IOException {
+        try {
+            beforeRead(IOUtils.length(chr));
+            final int n = in.read(chr);
+            afterRead(n);
+            return n;
+        } catch (final IOException e) {
+            handleIOException(e);
+            return EOF;
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code read(char[], int, int)} method.
+     * @param chr the buffer to read the characters into
+     * @param st The start offset
+     * @param len The number of bytes to read
+     * @return the number of characters read or -1 if the end of stream
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int read(final char[] chr, final int st, final int len) throws IOException {
+        try {
+            beforeRead(len);
+            final int n = in.read(chr, st, len);
+            afterRead(n);
+            return n;
+        } catch (final IOException e) {
+            handleIOException(e);
+            return EOF;
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code read(CharBuffer)} method.
+     * @param target the char buffer to read the characters into
+     * @return the number of characters read or -1 if the end of stream
+     * @throws IOException if an I/O error occurs.
+     * @since 2.0
+     */
+    @Override
+    public int read(final CharBuffer target) throws IOException {
+        try {
+            beforeRead(IOUtils.length(target));
+            final int n = in.read(target);
+            afterRead(n);
+            return n;
+        } catch (final IOException e) {
+            handleIOException(e);
+            return EOF;
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code ready()} method.
+     * @return true if the stream is ready to be read
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public boolean ready() throws IOException {
+        try {
+            return in.ready();
+        } catch (final IOException e) {
+            handleIOException(e);
+            return false;
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code reset()} method.
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public synchronized void reset() throws IOException {
+        try {
+            in.reset();
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code skip(long)} method.
+     * @param ln the number of bytes to skip
+     * @return the number of bytes to skipped or EOF if the end of stream
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public long skip(final long ln) throws IOException {
+        try {
+            return in.skip(ln);
+        } catch (final IOException e) {
+            handleIOException(e);
+            return 0;
+        }
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/QueueInputStream.java b/src/main/java/org/apache/commons/io/input/QueueInputStream.java
new file mode 100644
index 0000000..48cdadd
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/QueueInputStream.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import java.util.Objects;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+
+import org.apache.commons.io.output.QueueOutputStream;
+
+/**
+ * Simple alternative to JDK {@link java.io.PipedInputStream}; queue input stream provides what's written in queue
+ * output stream.
+ *
+ * <p>
+ * Example usage:
+ * </p>
+ * <pre>
+ * QueueInputStream inputStream = new QueueInputStream();
+ * QueueOutputStream outputStream = inputStream.newQueueOutputStream();
+ *
+ * outputStream.write("hello world".getBytes(UTF_8));
+ * inputStream.read();
+ * </pre>
+ * <p>
+ * Unlike JDK {@link PipedInputStream} and {@link PipedOutputStream}, queue input/output streams may be used safely in a
+ * single thread or multiple threads. Also, unlike JDK classes, no special meaning is attached to initial or current
+ * thread. Instances can be used longer after initial threads exited.
+ * </p>
+ * <p>
+ * Closing a {@link QueueInputStream} has no effect. The methods in this class can be called after the stream has been
+ * closed without generating an {@link IOException}.
+ * </p>
+ *
+ * @see QueueOutputStream
+ * @since 2.9.0
+ */
+public class QueueInputStream extends InputStream {
+
+    private final BlockingQueue<Integer> blockingQueue;
+
+    /**
+     * Constructs a new instance with no limit to its internal buffer size.
+     */
+    public QueueInputStream() {
+        this(new LinkedBlockingQueue<>());
+    }
+
+    /**
+     * Constructs a new instance with given buffer
+     *
+     * @param blockingQueue backing queue for the stream
+     */
+    public QueueInputStream(final BlockingQueue<Integer> blockingQueue) {
+        this.blockingQueue = Objects.requireNonNull(blockingQueue, "blockingQueue");
+    }
+
+    /**
+     * Creates a new QueueOutputStream instance connected to this. Writes to the output stream will be visible to this
+     * input stream.
+     *
+     * @return QueueOutputStream connected to this stream
+     */
+    public QueueOutputStream newQueueOutputStream() {
+        return new QueueOutputStream(blockingQueue);
+    }
+
+    /**
+     * Reads and returns a single byte.
+     *
+     * @return either the byte read or {@code -1} if the end of the stream has been reached
+     */
+    @Override
+    public int read() {
+        final Integer value = blockingQueue.poll();
+        return value == null ? EOF : 0xFF & value;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/RandomAccessFileInputStream.java b/src/main/java/org/apache/commons/io/input/RandomAccessFileInputStream.java
new file mode 100644
index 0000000..584d8f3
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/RandomAccessFileInputStream.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.input;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.RandomAccessFile;
+import java.util.Objects;
+
+/**
+ * Streams data from a {@link RandomAccessFile} starting at its current position.
+ *
+ * @since 2.8.0
+ */
+public class RandomAccessFileInputStream extends InputStream {
+
+    private final boolean closeOnClose;
+    private final RandomAccessFile randomAccessFile;
+
+    /**
+     * Constructs a new instance configured to leave the underlying file open when this stream is closed.
+     *
+     * @param file The file to stream.
+     */
+    public RandomAccessFileInputStream(final RandomAccessFile file) {
+        this(file, false);
+    }
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param file The file to stream.
+     * @param closeOnClose Whether to close the underlying file when this stream is closed.
+     */
+    public RandomAccessFileInputStream(final RandomAccessFile file, final boolean closeOnClose) {
+        this.randomAccessFile = Objects.requireNonNull(file, "file");
+        this.closeOnClose = closeOnClose;
+    }
+
+    /**
+     * Returns an estimate of the number of bytes that can be read (or skipped over) from this input stream.
+     *
+     * If there are more than {@link Integer#MAX_VALUE} bytes available, return {@link Integer#MAX_VALUE}.
+     *
+     * @return An estimate of the number of bytes that can be read.
+     * @throws IOException If an I/O error occurs.
+     */
+    @Override
+    public int available() throws IOException {
+        final long avail = availableLong();
+        if (avail > Integer.MAX_VALUE) {
+            return Integer.MAX_VALUE;
+        }
+        return (int) avail;
+    }
+
+    /**
+     * Returns the number of bytes that can be read (or skipped over) from this input stream.
+     *
+     * @return The number of bytes that can be read.
+     * @throws IOException If an I/O error occurs.
+     */
+    public long availableLong() throws IOException {
+        return randomAccessFile.length() - randomAccessFile.getFilePointer();
+    }
+
+    @Override
+    public void close() throws IOException {
+        super.close();
+        if (closeOnClose) {
+            randomAccessFile.close();
+        }
+    }
+
+    /**
+     * Gets the underlying file.
+     *
+     * @return the underlying file.
+     */
+    public RandomAccessFile getRandomAccessFile() {
+        return randomAccessFile;
+    }
+
+    /**
+     * Returns whether to close the underlying file when this stream is closed.
+     *
+     * @return Whether to close the underlying file when this stream is closed.
+     */
+    public boolean isCloseOnClose() {
+        return closeOnClose;
+    }
+
+    @Override
+    public int read() throws IOException {
+        return randomAccessFile.read();
+    }
+
+    @Override
+    public int read(final byte[] bytes) throws IOException {
+        return randomAccessFile.read(bytes);
+    }
+
+    @Override
+    public int read(final byte[] bytes, final int offset, final int length) throws IOException {
+        return randomAccessFile.read(bytes, offset, length);
+    }
+
+    /**
+     * Delegates to the underlying file.
+     *
+     * @param position See {@link RandomAccessFile#seek(long)}.
+     * @throws IOException See {@link RandomAccessFile#seek(long)}.
+     * @see RandomAccessFile#seek(long)
+     */
+    private void seek(final long position) throws IOException {
+        randomAccessFile.seek(position);
+    }
+
+    @Override
+    public long skip(final long skipCount) throws IOException {
+        if (skipCount <= 0) {
+            return 0;
+        }
+        final long filePointer = randomAccessFile.getFilePointer();
+        final long fileLength = randomAccessFile.length();
+        if (filePointer >= fileLength) {
+            return 0;
+        }
+        final long targetPos = filePointer + skipCount;
+        final long newPos = targetPos > fileLength ? fileLength - 1 : targetPos;
+        if (newPos > 0) {
+            seek(newPos);
+        }
+        return randomAccessFile.getFilePointer() - filePointer;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/ReadAheadInputStream.java b/src/main/java/org/apache/commons/io/input/ReadAheadInputStream.java
new file mode 100644
index 0000000..1939f26
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/ReadAheadInputStream.java
@@ -0,0 +1,468 @@
+/*
+ * 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.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+// import javax.annotation.concurrent.GuardedBy;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.nio.ByteBuffer;
+import java.util.Objects;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * Implements {@link InputStream} to asynchronously read ahead from an underlying input stream when a specified amount
+ * of data has been read from the current buffer. It does so by maintaining two buffers: an active buffer and a read
+ * ahead buffer. The active buffer contains data which should be returned when a read() call is issued. The read ahead
+ * buffer is used to asynchronously read from the underlying input stream. When the current active buffer is exhausted,
+ * we flip the two buffers so that we can start reading from the read ahead buffer without being blocked by disk I/O.
+ * <p>
+ * This class was ported and adapted from Apache Spark commit 933dc6cb7b3de1d8ccaf73d124d6eb95b947ed19.
+ * </p>
+ *
+ * @since 2.9.0
+ */
+public class ReadAheadInputStream extends InputStream {
+
+    private static final ThreadLocal<byte[]> BYTE_ARRAY_1 = ThreadLocal.withInitial(() -> new byte[1]);
+
+    /**
+     * Creates a new daemon executor service.
+     *
+     * @return a new daemon executor service.
+     */
+    private static ExecutorService newExecutorService() {
+        return Executors.newSingleThreadExecutor(ReadAheadInputStream::newThread);
+    }
+
+    /**
+     * Creates a new daemon thread.
+     *
+     * @param r the thread's runnable.
+     * @return a new daemon thread.
+     */
+    private static Thread newThread(final Runnable r) {
+        final Thread thread = new Thread(r, "commons-io-read-ahead");
+        thread.setDaemon(true);
+        return thread;
+    }
+
+    private final ReentrantLock stateChangeLock = new ReentrantLock();
+
+    // @GuardedBy("stateChangeLock")
+    private ByteBuffer activeBuffer;
+
+    // @GuardedBy("stateChangeLock")
+    private ByteBuffer readAheadBuffer;
+
+    // @GuardedBy("stateChangeLock")
+    private boolean endOfStream;
+
+    // @GuardedBy("stateChangeLock")
+    // true if async read is in progress
+    private boolean readInProgress;
+
+    // @GuardedBy("stateChangeLock")
+    // true if read is aborted due to an exception in reading from underlying input stream.
+    private boolean readAborted;
+
+    // @GuardedBy("stateChangeLock")
+    private Throwable readException;
+
+    // @GuardedBy("stateChangeLock")
+    // whether the close method is called.
+    private boolean isClosed;
+
+    // @GuardedBy("stateChangeLock")
+    // true when the close method will close the underlying input stream. This is valid only if
+    // `isClosed` is true.
+    private boolean isUnderlyingInputStreamBeingClosed;
+
+    // @GuardedBy("stateChangeLock")
+    // whether there is a read ahead task running,
+    private boolean isReading;
+
+    // Whether there is a reader waiting for data.
+    private final AtomicBoolean isWaiting = new AtomicBoolean(false);
+
+    private final InputStream underlyingInputStream;
+
+    private final ExecutorService executorService;
+
+    private final boolean shutdownExecutorService;
+
+    private final Condition asyncReadComplete = stateChangeLock.newCondition();
+
+    /**
+     * Creates an instance with the specified buffer size and read-ahead threshold
+     *
+     * @param inputStream The underlying input stream.
+     * @param bufferSizeInBytes The buffer size.
+     */
+    public ReadAheadInputStream(final InputStream inputStream, final int bufferSizeInBytes) {
+        this(inputStream, bufferSizeInBytes, newExecutorService(), true);
+    }
+
+    /**
+     * Creates an instance with the specified buffer size and read-ahead threshold
+     *
+     * @param inputStream The underlying input stream.
+     * @param bufferSizeInBytes The buffer size.
+     * @param executorService An executor service for the read-ahead thread.
+     */
+    public ReadAheadInputStream(final InputStream inputStream, final int bufferSizeInBytes,
+        final ExecutorService executorService) {
+        this(inputStream, bufferSizeInBytes, executorService, false);
+    }
+
+    /**
+     * Creates an instance with the specified buffer size and read-ahead threshold
+     *
+     * @param inputStream The underlying input stream.
+     * @param bufferSizeInBytes The buffer size.
+     * @param executorService An executor service for the read-ahead thread.
+     * @param shutdownExecutorService Whether or not to shut down the given ExecutorService on close.
+     */
+    private ReadAheadInputStream(final InputStream inputStream, final int bufferSizeInBytes,
+        final ExecutorService executorService, final boolean shutdownExecutorService) {
+        if (bufferSizeInBytes <= 0) {
+            throw new IllegalArgumentException("bufferSizeInBytes should be greater than 0, but the value is " + bufferSizeInBytes);
+        }
+        this.executorService = Objects.requireNonNull(executorService, "executorService");
+        this.underlyingInputStream = Objects.requireNonNull(inputStream, "inputStream");
+        this.shutdownExecutorService = shutdownExecutorService;
+        this.activeBuffer = ByteBuffer.allocate(bufferSizeInBytes);
+        this.readAheadBuffer = ByteBuffer.allocate(bufferSizeInBytes);
+        this.activeBuffer.flip();
+        this.readAheadBuffer.flip();
+    }
+
+    @Override
+    public int available() throws IOException {
+        stateChangeLock.lock();
+        // Make sure we have no integer overflow.
+        try {
+            return (int) Math.min(Integer.MAX_VALUE, (long) activeBuffer.remaining() + readAheadBuffer.remaining());
+        } finally {
+            stateChangeLock.unlock();
+        }
+    }
+
+    private void checkReadException() throws IOException {
+        if (readAborted) {
+            if (readException instanceof IOException) {
+                throw (IOException) readException;
+            }
+            throw new IOException(readException);
+        }
+    }
+
+    @Override
+    public void close() throws IOException {
+        boolean isSafeToCloseUnderlyingInputStream = false;
+        stateChangeLock.lock();
+        try {
+            if (isClosed) {
+                return;
+            }
+            isClosed = true;
+            if (!isReading) {
+                // Nobody is reading, so we can close the underlying input stream in this method.
+                isSafeToCloseUnderlyingInputStream = true;
+                // Flip this to make sure the read ahead task will not close the underlying input stream.
+                isUnderlyingInputStreamBeingClosed = true;
+            }
+        } finally {
+            stateChangeLock.unlock();
+        }
+
+        if (shutdownExecutorService) {
+            try {
+                executorService.shutdownNow();
+                executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
+            } catch (final InterruptedException e) {
+                final InterruptedIOException iio = new InterruptedIOException(e.getMessage());
+                iio.initCause(e);
+                throw iio;
+            } finally {
+                if (isSafeToCloseUnderlyingInputStream) {
+                    underlyingInputStream.close();
+                }
+            }
+        }
+    }
+
+    private void closeUnderlyingInputStreamIfNecessary() {
+        boolean needToCloseUnderlyingInputStream = false;
+        stateChangeLock.lock();
+        try {
+            isReading = false;
+            if (isClosed && !isUnderlyingInputStreamBeingClosed) {
+                // close method cannot close underlyingInputStream because we were reading.
+                needToCloseUnderlyingInputStream = true;
+            }
+        } finally {
+            stateChangeLock.unlock();
+        }
+        if (needToCloseUnderlyingInputStream) {
+            try {
+                underlyingInputStream.close();
+            } catch (final IOException ignored) {
+                // TODO Rethrow as UncheckedIOException?
+            }
+        }
+    }
+
+    private boolean isEndOfStream() {
+        return !activeBuffer.hasRemaining() && !readAheadBuffer.hasRemaining() && endOfStream;
+    }
+
+    @Override
+    public int read() throws IOException {
+        if (activeBuffer.hasRemaining()) {
+            // short path - just get one byte.
+            return activeBuffer.get() & 0xFF;
+        }
+        final byte[] oneByteArray = BYTE_ARRAY_1.get();
+        return read(oneByteArray, 0, 1) == EOF ? EOF : oneByteArray[0] & 0xFF;
+    }
+
+    @Override
+    public int read(final byte[] b, final int offset, int len) throws IOException {
+        if (offset < 0 || len < 0 || len > b.length - offset) {
+            throw new IndexOutOfBoundsException();
+        }
+        if (len == 0) {
+            return 0;
+        }
+
+        if (!activeBuffer.hasRemaining()) {
+            // No remaining in active buffer - lock and switch to write ahead buffer.
+            stateChangeLock.lock();
+            try {
+                waitForAsyncReadComplete();
+                if (!readAheadBuffer.hasRemaining()) {
+                    // The first read.
+                    readAsync();
+                    waitForAsyncReadComplete();
+                    if (isEndOfStream()) {
+                        return EOF;
+                    }
+                }
+                // Swap the newly read ahead buffer in place of empty active buffer.
+                swapBuffers();
+                // After swapping buffers, trigger another async read for read ahead buffer.
+                readAsync();
+            } finally {
+                stateChangeLock.unlock();
+            }
+        }
+        len = Math.min(len, activeBuffer.remaining());
+        activeBuffer.get(b, offset, len);
+
+        return len;
+    }
+
+    /**
+     * Read data from underlyingInputStream to readAheadBuffer asynchronously.
+     *
+     * @throws IOException if an I/O error occurs.
+     */
+    private void readAsync() throws IOException {
+        stateChangeLock.lock();
+        final byte[] arr;
+        try {
+            arr = readAheadBuffer.array();
+            if (endOfStream || readInProgress) {
+                return;
+            }
+            checkReadException();
+            readAheadBuffer.position(0);
+            readAheadBuffer.flip();
+            readInProgress = true;
+        } finally {
+            stateChangeLock.unlock();
+        }
+        executorService.execute(() -> {
+            stateChangeLock.lock();
+            try {
+                if (isClosed) {
+                    readInProgress = false;
+                    return;
+                }
+                // Flip this so that the close method will not close the underlying input stream when we
+                // are reading.
+                isReading = true;
+            } finally {
+                stateChangeLock.unlock();
+            }
+
+            // Please note that it is safe to release the lock and read into the read ahead buffer
+            // because either of following two conditions will hold:
+            //
+            // 1. The active buffer has data available to read so the reader will not read from the read ahead buffer.
+            //
+            // 2. This is the first time read is called or the active buffer is exhausted, in that case the reader waits
+            // for this async read to complete.
+            //
+            // So there is no race condition in both the situations.
+            int read = 0;
+            int off = 0, len = arr.length;
+            Throwable exception = null;
+            try {
+                // try to fill the read ahead buffer.
+                // if a reader is waiting, possibly return early.
+                do {
+                    read = underlyingInputStream.read(arr, off, len);
+                    if (read <= 0) {
+                        break;
+                    }
+                    off += read;
+                    len -= read;
+                } while (len > 0 && !isWaiting.get());
+            } catch (final Throwable ex) {
+                exception = ex;
+                if (ex instanceof Error) {
+                    // `readException` may not be reported to the user. Rethrow Error to make sure at least
+                    // The user can see Error in UncaughtExceptionHandler.
+                    throw (Error) ex;
+                }
+            } finally {
+                stateChangeLock.lock();
+                try {
+                    readAheadBuffer.limit(off);
+                    if (read < 0 || exception instanceof EOFException) {
+                        endOfStream = true;
+                    } else if (exception != null) {
+                        readAborted = true;
+                        readException = exception;
+                    }
+                    readInProgress = false;
+                    signalAsyncReadComplete();
+                } finally {
+                    stateChangeLock.unlock();
+                }
+                closeUnderlyingInputStreamIfNecessary();
+            }
+        });
+    }
+
+    private void signalAsyncReadComplete() {
+        stateChangeLock.lock();
+        try {
+            asyncReadComplete.signalAll();
+        } finally {
+            stateChangeLock.unlock();
+        }
+    }
+
+    @Override
+    public long skip(final long n) throws IOException {
+        if (n <= 0L) {
+            return 0L;
+        }
+        if (n <= activeBuffer.remaining()) {
+            // Only skipping from active buffer is sufficient
+            activeBuffer.position((int) n + activeBuffer.position());
+            return n;
+        }
+        stateChangeLock.lock();
+        final long skipped;
+        try {
+            skipped = skipInternal(n);
+        } finally {
+            stateChangeLock.unlock();
+        }
+        return skipped;
+    }
+
+    /**
+     * Internal skip function which should be called only from skip(). The assumption is that the stateChangeLock is
+     * already acquired in the caller before calling this function.
+     *
+     * @param n the number of bytes to be skipped.
+     * @return the actual number of bytes skipped.
+     * @throws IOException if an I/O error occurs.
+     */
+    private long skipInternal(final long n) throws IOException {
+        assert stateChangeLock.isLocked();
+        waitForAsyncReadComplete();
+        if (isEndOfStream()) {
+            return 0;
+        }
+        if (available() >= n) {
+            // we can skip from the internal buffers
+            int toSkip = (int) n;
+            // We need to skip from both active buffer and read ahead buffer
+            toSkip -= activeBuffer.remaining();
+            assert toSkip > 0; // skipping from activeBuffer already handled.
+            activeBuffer.position(0);
+            activeBuffer.flip();
+            readAheadBuffer.position(toSkip + readAheadBuffer.position());
+            swapBuffers();
+            // Trigger async read to emptied read ahead buffer.
+            readAsync();
+            return n;
+        }
+        final int skippedBytes = available();
+        final long toSkip = n - skippedBytes;
+        activeBuffer.position(0);
+        activeBuffer.flip();
+        readAheadBuffer.position(0);
+        readAheadBuffer.flip();
+        final long skippedFromInputStream = underlyingInputStream.skip(toSkip);
+        readAsync();
+        return skippedBytes + skippedFromInputStream;
+    }
+
+    /**
+     * Flips the active and read ahead buffer
+     */
+    private void swapBuffers() {
+        final ByteBuffer temp = activeBuffer;
+        activeBuffer = readAheadBuffer;
+        readAheadBuffer = temp;
+    }
+
+    private void waitForAsyncReadComplete() throws IOException {
+        stateChangeLock.lock();
+        try {
+            isWaiting.set(true);
+            // There is only one reader, and one writer, so the writer should signal only once,
+            // but a while loop checking the wake-up condition is still needed to avoid spurious wakeups.
+            while (readInProgress) {
+                asyncReadComplete.await();
+            }
+        } catch (final InterruptedException e) {
+            final InterruptedIOException iio = new InterruptedIOException(e.getMessage());
+            iio.initCause(e);
+            throw iio;
+        } finally {
+            try {
+                isWaiting.set(false);
+            } finally {
+                stateChangeLock.unlock();
+            }
+        }
+        checkReadException();
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/ReaderInputStream.java b/src/main/java/org/apache/commons/io/input/ReaderInputStream.java
new file mode 100644
index 0000000..a9e479c
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/ReaderInputStream.java
@@ -0,0 +1,353 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Reader;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetEncoder;
+import java.nio.charset.CoderResult;
+import java.nio.charset.CodingErrorAction;
+import java.util.Objects;
+
+import org.apache.commons.io.Charsets;
+import org.apache.commons.io.charset.CharsetEncoders;
+
+/**
+ * {@link InputStream} implementation that reads a character stream from a {@link Reader} and transforms it to a byte
+ * stream using a specified charset encoding. The stream is transformed using a {@link CharsetEncoder} object,
+ * guaranteeing that all charset encodings supported by the JRE are handled correctly. In particular for charsets such
+ * as UTF-16, the implementation ensures that one and only one byte order marker is produced.
+ * <p>
+ * Since in general it is not possible to predict the number of characters to be read from the {@link Reader} to satisfy
+ * a read request on the {@link ReaderInputStream}, all reads from the {@link Reader} are buffered. There is therefore
+ * no well defined correlation between the current position of the {@link Reader} and that of the
+ * {@link ReaderInputStream}. This also implies that in general there is no need to wrap the underlying {@link Reader}
+ * in a {@link java.io.BufferedReader}.
+ * </p>
+ * <p>
+ * {@link ReaderInputStream} implements the inverse transformation of {@link java.io.InputStreamReader}; in the
+ * following example, reading from {@code in2} would return the same byte sequence as reading from {@code in} (provided
+ * that the initial byte sequence is legal with respect to the charset encoding):
+ * </p>
+ *
+ * <pre>
+ * InputStream inputStream = ...
+ * Charset cs = ...
+ * InputStreamReader reader = new InputStreamReader(inputStream, cs);
+ * ReaderInputStream in2 = new ReaderInputStream(reader, cs);
+ * </pre>
+ * <p>
+ * {@link ReaderInputStream} implements the same transformation as {@link java.io.OutputStreamWriter}, except that the
+ * control flow is reversed: both classes transform a character stream into a byte stream, but
+ * {@link java.io.OutputStreamWriter} pushes data to the underlying stream, while {@link ReaderInputStream} pulls it
+ * from the underlying stream.
+ * </p>
+ * <p>
+ * Note that while there are use cases where there is no alternative to using this class, very often the need to use
+ * this class is an indication of a flaw in the design of the code. This class is typically used in situations where an
+ * existing API only accepts an {@link InputStream}, but where the most natural way to produce the data is as a
+ * character stream, i.e. by providing a {@link Reader} instance. An example of a situation where this problem may
+ * appear is when implementing the {@code javax.activation.DataSource} interface from the Java Activation Framework.
+ * </p>
+ * <p>
+ * The {@link #available()} method of this class always returns 0. The methods {@link #mark(int)} and {@link #reset()}
+ * are not supported.
+ * </p>
+ * <p>
+ * Instances of {@link ReaderInputStream} are not thread safe.
+ * </p>
+ *
+ * @see org.apache.commons.io.output.WriterOutputStream
+ * @since 2.0
+ */
+public class ReaderInputStream extends InputStream {
+    private static final int DEFAULT_BUFFER_SIZE = 1024;
+
+    static int checkMinBufferSize(final CharsetEncoder charsetEncoder, final int bufferSize) {
+        final float minRequired = minBufferSize(charsetEncoder);
+        if (bufferSize < minRequired) {
+            throw new IllegalArgumentException(
+                String.format("Buffer size %,d must be at least %s for a CharsetEncoder %s.", bufferSize, minRequired, charsetEncoder.charset().displayName()));
+        }
+        return bufferSize;
+    }
+
+    static float minBufferSize(final CharsetEncoder charsetEncoder) {
+        return charsetEncoder.maxBytesPerChar() * 2;
+    }
+
+    private final Reader reader;
+
+    private final CharsetEncoder charsetEncoder;
+
+    /**
+     * CharBuffer used as input for the decoder. It should be reasonably large as we read data from the underlying Reader
+     * into this buffer.
+     */
+    private final CharBuffer encoderIn;
+    /**
+     * ByteBuffer used as output for the decoder. This buffer can be small as it is only used to transfer data from the
+     * decoder to the buffer provided by the caller.
+     */
+    private final ByteBuffer encoderOut;
+
+    private CoderResult lastCoderResult;
+
+    private boolean endOfInput;
+
+    /**
+     * Constructs a new {@link ReaderInputStream} that uses the default character encoding with a default input buffer size
+     * of {@value #DEFAULT_BUFFER_SIZE} characters.
+     *
+     * @param reader the target {@link Reader}
+     * @deprecated 2.5 use {@link #ReaderInputStream(Reader, Charset)} instead
+     */
+    @Deprecated
+    public ReaderInputStream(final Reader reader) {
+        this(reader, Charset.defaultCharset());
+    }
+
+    /**
+     * Constructs a new {@link ReaderInputStream} with a default input buffer size of {@value #DEFAULT_BUFFER_SIZE}
+     * characters.
+     *
+     * <p>
+     * The encoder created for the specified charset will use {@link CodingErrorAction#REPLACE} for malformed input
+     * and unmappable characters.
+     * </p>
+     *
+     * @param reader the target {@link Reader}
+     * @param charset the charset encoding
+     */
+    public ReaderInputStream(final Reader reader, final Charset charset) {
+        this(reader, charset, DEFAULT_BUFFER_SIZE);
+    }
+
+    /**
+     * Constructs a new {@link ReaderInputStream}.
+     *
+     * <p>
+     * The encoder created for the specified charset will use {@link CodingErrorAction#REPLACE} for malformed input
+     * and unmappable characters.
+     * </p>
+     *
+     * @param reader the target {@link Reader}.
+     * @param charset the charset encoding.
+     * @param bufferSize the size of the input buffer in number of characters.
+     */
+    public ReaderInputStream(final Reader reader, final Charset charset, final int bufferSize) {
+        // @formatter:off
+        this(reader,
+            Charsets.toCharset(charset).newEncoder()
+                    .onMalformedInput(CodingErrorAction.REPLACE)
+                    .onUnmappableCharacter(CodingErrorAction.REPLACE),
+             bufferSize);
+        // @formatter:on
+    }
+
+    /**
+     * Constructs a new {@link ReaderInputStream}.
+     *
+     * <p>
+     * This constructor does not call {@link CharsetEncoder#reset() reset} on the provided encoder. The caller
+     * of this constructor should do this when providing an encoder which had already been in use.
+     * </p>
+     *
+     * @param reader the target {@link Reader}
+     * @param charsetEncoder the charset encoder
+     * @since 2.1
+     */
+    public ReaderInputStream(final Reader reader, final CharsetEncoder charsetEncoder) {
+        this(reader, charsetEncoder, DEFAULT_BUFFER_SIZE);
+    }
+
+    /**
+     * Constructs a new {@link ReaderInputStream}.
+     *
+     * <p>
+     * This constructor does not call {@link CharsetEncoder#reset() reset} on the provided encoder. The caller
+     * of this constructor should do this when providing an encoder which had already been in use.
+     * </p>
+     *
+     * @param reader the target {@link Reader}
+     * @param charsetEncoder the charset encoder, null defaults to the default Charset encoder.
+     * @param bufferSize the size of the input buffer in number of characters
+     * @since 2.1
+     */
+    public ReaderInputStream(final Reader reader, final CharsetEncoder charsetEncoder, final int bufferSize) {
+        this.reader = reader;
+        this.charsetEncoder = CharsetEncoders.toCharsetEncoder(charsetEncoder);
+        this.encoderIn = CharBuffer.allocate(checkMinBufferSize(this.charsetEncoder, bufferSize));
+        this.encoderIn.flip();
+        this.encoderOut = ByteBuffer.allocate(128);
+        this.encoderOut.flip();
+    }
+
+    /**
+     * Constructs a new {@link ReaderInputStream} with a default input buffer size of {@value #DEFAULT_BUFFER_SIZE}
+     * characters.
+     *
+     * <p>
+     * The encoder created for the specified charset will use {@link CodingErrorAction#REPLACE} for malformed input
+     * and unmappable characters.
+     * </p>
+     *
+     * @param reader the target {@link Reader}
+     * @param charsetName the name of the charset encoding
+     */
+    public ReaderInputStream(final Reader reader, final String charsetName) {
+        this(reader, charsetName, DEFAULT_BUFFER_SIZE);
+    }
+
+    /**
+     * Constructs a new {@link ReaderInputStream}.
+     *
+     * <p>
+     * The encoder created for the specified charset will use {@link CodingErrorAction#REPLACE} for malformed input
+     * and unmappable characters.
+     * </p>
+     *
+     * @param reader the target {@link Reader}
+     * @param charsetName the name of the charset encoding, null maps to the default Charset.
+     * @param bufferSize the size of the input buffer in number of characters
+     */
+    public ReaderInputStream(final Reader reader, final String charsetName, final int bufferSize) {
+        this(reader, Charsets.toCharset(charsetName), bufferSize);
+    }
+
+    /**
+     * Close the stream. This method will cause the underlying {@link Reader} to be closed.
+     *
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void close() throws IOException {
+        reader.close();
+    }
+
+    /**
+     * Fills the internal char buffer from the reader.
+     *
+     * @throws IOException If an I/O error occurs
+     */
+    private void fillBuffer() throws IOException {
+        if (!endOfInput && (lastCoderResult == null || lastCoderResult.isUnderflow())) {
+            encoderIn.compact();
+            final int position = encoderIn.position();
+            // We don't use Reader#read(CharBuffer) here because it is more efficient
+            // to write directly to the underlying char array (the default implementation
+            // copies data to a temporary char array).
+            final int c = reader.read(encoderIn.array(), position, encoderIn.remaining());
+            if (c == EOF) {
+                endOfInput = true;
+            } else {
+                encoderIn.position(position + c);
+            }
+            encoderIn.flip();
+        }
+        encoderOut.compact();
+        lastCoderResult = charsetEncoder.encode(encoderIn, encoderOut, endOfInput);
+        if (endOfInput) {
+            lastCoderResult = charsetEncoder.flush(encoderOut);
+        }
+        if (lastCoderResult.isError()) {
+            lastCoderResult.throwException();
+        }
+        encoderOut.flip();
+    }
+
+    /**
+     * Gets the CharsetEncoder.
+     *
+     * @return the CharsetEncoder.
+     */
+    CharsetEncoder getCharsetEncoder() {
+        return charsetEncoder;
+    }
+
+    /**
+     * Read a single byte.
+     *
+     * @return either the byte read or {@code -1} if the end of the stream has been reached
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int read() throws IOException {
+        for (;;) {
+            if (encoderOut.hasRemaining()) {
+                return encoderOut.get() & 0xFF;
+            }
+            fillBuffer();
+            if (endOfInput && !encoderOut.hasRemaining()) {
+                return EOF;
+            }
+        }
+    }
+
+    /**
+     * Read the specified number of bytes into an array.
+     *
+     * @param b the byte array to read into
+     * @return the number of bytes read or {@code -1} if the end of the stream has been reached
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int read(final byte[] b) throws IOException {
+        return read(b, 0, b.length);
+    }
+
+    /**
+     * Read the specified number of bytes into an array.
+     *
+     * @param array the byte array to read into
+     * @param off the offset to start reading bytes into
+     * @param len the number of bytes to read
+     * @return the number of bytes read or {@code -1} if the end of the stream has been reached
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int read(final byte[] array, int off, int len) throws IOException {
+        Objects.requireNonNull(array, "array");
+        if (len < 0 || off < 0 || off + len > array.length) {
+            throw new IndexOutOfBoundsException("Array size=" + array.length + ", offset=" + off + ", length=" + len);
+        }
+        int read = 0;
+        if (len == 0) {
+            return 0; // Always return 0 if len == 0
+        }
+        while (len > 0) {
+            if (encoderOut.hasRemaining()) { // Data from the last read not fully copied
+                final int c = Math.min(encoderOut.remaining(), len);
+                encoderOut.get(array, off, c);
+                off += c;
+                len -= c;
+                read += c;
+            } else if (endOfInput) { // Already reach EOF in the last read
+                break;
+            } else { // Read again
+                fillBuffer();
+            }
+        }
+        return read == 0 && endOfInput ? EOF : read;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/ReversedLinesFileReader.java b/src/main/java/org/apache/commons/io/input/ReversedLinesFileReader.java
new file mode 100644
index 0000000..e6a4fce
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/ReversedLinesFileReader.java
@@ -0,0 +1,469 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.nio.ByteBuffer;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetEncoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.io.Charsets;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.StandardLineSeparator;
+
+/**
+ * Reads lines in a file reversely (similar to a BufferedReader, but starting at
+ * the last line). Useful for e.g. searching in log files.
+ *
+ * @since 2.2
+ */
+public class ReversedLinesFileReader implements Closeable {
+
+    private class FilePart {
+        private final long no;
+
+        private final byte[] data;
+
+        private byte[] leftOver;
+
+        private int currentLastBytePos;
+
+        /**
+         * Constructs a new instance.
+         *
+         * @param no                     the part number
+         * @param length                 its length
+         * @param leftOverOfLastFilePart remainder
+         * @throws IOException if there is a problem reading the file
+         */
+        private FilePart(final long no, final int length, final byte[] leftOverOfLastFilePart) throws IOException {
+            this.no = no;
+            final int dataLength = length + (leftOverOfLastFilePart != null ? leftOverOfLastFilePart.length : 0);
+            this.data = new byte[dataLength];
+            final long off = (no - 1) * blockSize;
+
+            // read data
+            if (no > 0 /* file not empty */) {
+                channel.position(off);
+                final int countRead = channel.read(ByteBuffer.wrap(data, 0, length));
+                if (countRead != length) {
+                    throw new IllegalStateException("Count of requested bytes and actually read bytes don't match");
+                }
+            }
+            // copy left over part into data arr
+            if (leftOverOfLastFilePart != null) {
+                System.arraycopy(leftOverOfLastFilePart, 0, data, length, leftOverOfLastFilePart.length);
+            }
+            this.currentLastBytePos = data.length - 1;
+            this.leftOver = null;
+        }
+
+        /**
+         * Creates the buffer containing any leftover bytes.
+         */
+        private void createLeftOver() {
+            final int lineLengthBytes = currentLastBytePos + 1;
+            if (lineLengthBytes > 0) {
+                // create left over for next block
+                leftOver = Arrays.copyOf(data, lineLengthBytes);
+            } else {
+                leftOver = null;
+            }
+            currentLastBytePos = -1;
+        }
+
+        /**
+         * Finds the new-line sequence and return its length.
+         *
+         * @param data buffer to scan
+         * @param i    start offset in buffer
+         * @return length of newline sequence or 0 if none found
+         */
+        private int getNewLineMatchByteCount(final byte[] data, final int i) {
+            for (final byte[] newLineSequence : newLineSequences) {
+                boolean match = true;
+                for (int j = newLineSequence.length - 1; j >= 0; j--) {
+                    final int k = i + j - (newLineSequence.length - 1);
+                    match &= k >= 0 && data[k] == newLineSequence[j];
+                }
+                if (match) {
+                    return newLineSequence.length;
+                }
+            }
+            return 0;
+        }
+
+        /**
+         * Reads a line.
+         *
+         * @return the line or null
+         */
+        private String readLine() { //NOPMD Bug in PMD
+
+            String line = null;
+            int newLineMatchByteCount;
+
+            final boolean isLastFilePart = no == 1;
+
+            int i = currentLastBytePos;
+            while (i > -1) {
+
+                if (!isLastFilePart && i < avoidNewlineSplitBufferSize) {
+                    // avoidNewlineSplitBuffer: for all except the last file part we
+                    // take a few bytes to the next file part to avoid splitting of newlines
+                    createLeftOver();
+                    break; // skip last few bytes and leave it to the next file part
+                }
+
+                // --- check for newline ---
+                if ((newLineMatchByteCount = getNewLineMatchByteCount(data, i)) > 0 /* found newline */) {
+                    final int lineStart = i + 1;
+                    final int lineLengthBytes = currentLastBytePos - lineStart + 1;
+
+                    if (lineLengthBytes < 0) {
+                        throw new IllegalStateException("Unexpected negative line length=" + lineLengthBytes);
+                    }
+                    final byte[] lineData = Arrays.copyOfRange(data, lineStart, lineStart + lineLengthBytes);
+
+                    line = new String(lineData, charset);
+
+                    currentLastBytePos = i - newLineMatchByteCount;
+                    break; // found line
+                }
+
+                // --- move cursor ---
+                i -= byteDecrement;
+
+                // --- end of file part handling ---
+                if (i < 0) {
+                    createLeftOver();
+                    break; // end of file part
+                }
+            }
+
+            // --- last file part handling ---
+            if (isLastFilePart && leftOver != null) {
+                // there will be no line break anymore, this is the first line of the file
+                line = new String(leftOver, charset);
+                leftOver = null;
+            }
+
+            return line;
+        }
+
+        /**
+         * Handles block rollover
+         *
+         * @return the new FilePart or null
+         * @throws IOException if there was a problem reading the file
+         */
+        private FilePart rollOver() throws IOException {
+
+            if (currentLastBytePos > -1) {
+                throw new IllegalStateException("Current currentLastCharPos unexpectedly positive... "
+                        + "last readLine() should have returned something! currentLastCharPos=" + currentLastBytePos);
+            }
+
+            if (no > 1) {
+                return new FilePart(no - 1, blockSize, leftOver);
+            }
+            // NO 1 was the last FilePart, we're finished
+            if (leftOver != null) {
+                throw new IllegalStateException("Unexpected leftover of the last block: leftOverOfThisFilePart="
+                        + new String(leftOver, charset));
+            }
+            return null;
+        }
+    }
+
+    private static final String EMPTY_STRING = "";
+    private static final int DEFAULT_BLOCK_SIZE = IOUtils.DEFAULT_BUFFER_SIZE;
+
+    private final int blockSize;
+    private final Charset charset;
+    private final SeekableByteChannel channel;
+    private final long totalByteLength;
+    private final long totalBlockCount;
+    private final byte[][] newLineSequences;
+    private final int avoidNewlineSplitBufferSize;
+    private final int byteDecrement;
+    private FilePart currentFilePart;
+    private boolean trailingNewlineOfFileSkipped;
+
+    /**
+     * Creates a ReversedLinesFileReader with default block size of 4KB and the
+     * platform's default encoding.
+     *
+     * @param file the file to be read
+     * @throws IOException if an I/O error occurs.
+     * @deprecated 2.5 use {@link #ReversedLinesFileReader(File, Charset)} instead
+     */
+    @Deprecated
+    public ReversedLinesFileReader(final File file) throws IOException {
+        this(file, DEFAULT_BLOCK_SIZE, Charset.defaultCharset());
+    }
+
+    /**
+     * Creates a ReversedLinesFileReader with default block size of 4KB and the
+     * specified encoding.
+     *
+     * @param file    the file to be read
+     * @param charset the charset to use, null uses the default Charset.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.5
+     */
+    public ReversedLinesFileReader(final File file, final Charset charset) throws IOException {
+        this(file.toPath(), charset);
+    }
+
+    /**
+     * Creates a ReversedLinesFileReader with the given block size and encoding.
+     *
+     * @param file      the file to be read
+     * @param blockSize size of the internal buffer (for ideal performance this
+     *                  should match with the block size of the underlying file
+     *                  system).
+     * @param charset  the encoding of the file, null uses the default Charset.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.3
+     */
+    public ReversedLinesFileReader(final File file, final int blockSize, final Charset charset) throws IOException {
+        this(file.toPath(), blockSize, charset);
+    }
+
+    /**
+     * Creates a ReversedLinesFileReader with the given block size and encoding.
+     *
+     * @param file      the file to be read
+     * @param blockSize size of the internal buffer (for ideal performance this
+     *                  should match with the block size of the underlying file
+     *                  system).
+     * @param charsetName  the encoding of the file, null uses the default Charset.
+     * @throws IOException                                  if an I/O error occurs
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of
+     *                                                      {@link UnsupportedEncodingException}
+     *                                                      in version 2.2 if the
+     *                                                      encoding is not
+     *                                                      supported.
+     */
+    public ReversedLinesFileReader(final File file, final int blockSize, final String charsetName) throws IOException {
+        this(file.toPath(), blockSize, charsetName);
+    }
+
+    /**
+     * Creates a ReversedLinesFileReader with default block size of 4KB and the
+     * specified encoding.
+     *
+     * @param file    the file to be read
+     * @param charset the charset to use, null uses the default Charset.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.7
+     */
+    public ReversedLinesFileReader(final Path file, final Charset charset) throws IOException {
+        this(file, DEFAULT_BLOCK_SIZE, charset);
+    }
+
+    /**
+     * Creates a ReversedLinesFileReader with the given block size and encoding.
+     *
+     * @param file      the file to be read
+     * @param blockSize size of the internal buffer (for ideal performance this
+     *                  should match with the block size of the underlying file
+     *                  system).
+     * @param charset  the encoding of the file, null uses the default Charset.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.7
+     */
+    public ReversedLinesFileReader(final Path file, final int blockSize, final Charset charset) throws IOException {
+        this.blockSize = blockSize;
+        this.charset = Charsets.toCharset(charset);
+
+        // --- check & prepare encoding ---
+        final CharsetEncoder charsetEncoder = this.charset.newEncoder();
+        final float maxBytesPerChar = charsetEncoder.maxBytesPerChar();
+        if (maxBytesPerChar == 1f) {
+            // all one byte encodings are no problem
+            byteDecrement = 1;
+        } else if (this.charset == StandardCharsets.UTF_8) {
+            // UTF-8 works fine out of the box, for multibyte sequences a second UTF-8 byte
+            // can never be a newline byte
+            // http://en.wikipedia.org/wiki/UTF-8
+            byteDecrement = 1;
+        } else if (this.charset == Charset.forName("Shift_JIS") || // Same as for UTF-8
+        // http://www.herongyang.com/Unicode/JIS-Shift-JIS-Encoding.html
+                this.charset == Charset.forName("windows-31j") || // Windows code page 932 (Japanese)
+                this.charset == Charset.forName("x-windows-949") || // Windows code page 949 (Korean)
+                this.charset == Charset.forName("gbk") || // Windows code page 936 (Simplified Chinese)
+                this.charset == Charset.forName("x-windows-950")) { // Windows code page 950 (Traditional Chinese)
+            byteDecrement = 1;
+        } else if (this.charset == StandardCharsets.UTF_16BE || this.charset == StandardCharsets.UTF_16LE) {
+            // UTF-16 new line sequences are not allowed as second tuple of four byte
+            // sequences,
+            // however byte order has to be specified
+            byteDecrement = 2;
+        } else if (this.charset == StandardCharsets.UTF_16) {
+            throw new UnsupportedEncodingException(
+                    "For UTF-16, you need to specify the byte order (use UTF-16BE or " + "UTF-16LE)");
+        } else {
+            throw new UnsupportedEncodingException(
+                    "Encoding " + charset + " is not supported yet (feel free to " + "submit a patch)");
+        }
+
+        // NOTE: The new line sequences are matched in the order given, so it is
+        // important that \r\n is BEFORE \n
+        this.newLineSequences = new byte[][] {
+            StandardLineSeparator.CRLF.getBytes(this.charset),
+            StandardLineSeparator.LF.getBytes(this.charset),
+            StandardLineSeparator.CR.getBytes(this.charset)
+        };
+
+        this.avoidNewlineSplitBufferSize = newLineSequences[0].length;
+
+        // Open file
+        this.channel = Files.newByteChannel(file, StandardOpenOption.READ);
+        this.totalByteLength = channel.size();
+        int lastBlockLength = (int) (this.totalByteLength % blockSize);
+        if (lastBlockLength > 0) {
+            this.totalBlockCount = this.totalByteLength / blockSize + 1;
+        } else {
+            this.totalBlockCount = this.totalByteLength / blockSize;
+            if (this.totalByteLength > 0) {
+                lastBlockLength = blockSize;
+            }
+        }
+        this.currentFilePart = new FilePart(totalBlockCount, lastBlockLength, null);
+
+    }
+
+    /**
+     * Creates a ReversedLinesFileReader with the given block size and encoding.
+     *
+     * @param file        the file to be read
+     * @param blockSize   size of the internal buffer (for ideal performance this
+     *                    should match with the block size of the underlying file
+     *                    system).
+     * @param charsetName the encoding of the file, null uses the default Charset.
+     * @throws IOException                                  if an I/O error occurs
+     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of
+     *                                                      {@link UnsupportedEncodingException}
+     *                                                      in version 2.2 if the
+     *                                                      encoding is not
+     *                                                      supported.
+     * @since 2.7
+     */
+    public ReversedLinesFileReader(final Path file, final int blockSize, final String charsetName) throws IOException {
+        this(file, blockSize, Charsets.toCharset(charsetName));
+    }
+
+    /**
+     * Closes underlying resources.
+     *
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void close() throws IOException {
+        channel.close();
+    }
+
+    /**
+     * Returns the lines of the file from bottom to top.
+     *
+     * @return the next line or null if the start of the file is reached
+     * @throws IOException if an I/O error occurs.
+     */
+    public String readLine() throws IOException {
+
+        String line = currentFilePart.readLine();
+        while (line == null) {
+            currentFilePart = currentFilePart.rollOver();
+            if (currentFilePart == null) {
+                // no more FileParts: we're done, leave line set to null
+                break;
+            }
+            line = currentFilePart.readLine();
+        }
+
+        // aligned behavior with BufferedReader that doesn't return a last, empty line
+        if (EMPTY_STRING.equals(line) && !trailingNewlineOfFileSkipped) {
+            trailingNewlineOfFileSkipped = true;
+            line = readLine();
+        }
+
+        return line;
+    }
+
+    /**
+     * Returns {@code lineCount} lines of the file from bottom to top.
+     * <p>
+     * If there are less than {@code lineCount} lines in the file, then that's what
+     * you get.
+     * </p>
+     * <p>
+     * Note: You can easily flip the result with {@link Collections#reverse(List)}.
+     * </p>
+     *
+     * @param lineCount How many lines to read.
+     * @return A new list
+     * @throws IOException if an I/O error occurs.
+     * @since 2.8.0
+     */
+    public List<String> readLines(final int lineCount) throws IOException {
+        if (lineCount < 0) {
+            throw new IllegalArgumentException("lineCount < 0");
+        }
+        final ArrayList<String> arrayList = new ArrayList<>(lineCount);
+        for (int i = 0; i < lineCount; i++) {
+            final String line = readLine();
+            if (line == null) {
+                return arrayList;
+            }
+            arrayList.add(line);
+        }
+        return arrayList;
+    }
+
+    /**
+     * Returns the last {@code lineCount} lines of the file.
+     * <p>
+     * If there are less than {@code lineCount} lines in the file, then that's what
+     * you get.
+     * </p>
+     *
+     * @param lineCount How many lines to read.
+     * @return A String.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.8.0
+     */
+    public String toString(final int lineCount) throws IOException {
+        final List<String> lines = readLines(lineCount);
+        Collections.reverse(lines);
+        return lines.isEmpty() ? EMPTY_STRING : String.join(System.lineSeparator(), lines) + System.lineSeparator();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/SequenceReader.java b/src/main/java/org/apache/commons/io/input/SequenceReader.java
new file mode 100644
index 0000000..514569d
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/SequenceReader.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.SequenceInputStream;
+import java.io.UncheckedIOException;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.Objects;
+
+/**
+ * Provides the contents of multiple {@link Reader}s in sequence.
+ * <p>
+ * Like {@link SequenceInputStream} but for {@link Reader} arguments.
+ * </p>
+ *
+ * @since 2.7
+ */
+public class SequenceReader extends Reader {
+
+    private Reader reader;
+    private final Iterator<? extends Reader> readers;
+
+    /**
+     * Constructs a new instance with readers
+     *
+     * @param readers the readers to read
+     */
+    public SequenceReader(final Iterable<? extends Reader> readers) {
+        this.readers = Objects.requireNonNull(readers, "readers").iterator();
+        try {
+            this.reader = nextReader();
+        } catch (final IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    /**
+     * Constructs a new instance with readers
+     *
+     * @param readers the readers to read
+     */
+    public SequenceReader(final Reader... readers) {
+        this(Arrays.asList(readers));
+    }
+
+    /*
+     * (non-Javadoc)
+     *
+     * @see java.io.Reader#close()
+     */
+    @Override
+    public void close() throws IOException {
+        do { // NOPMD
+             // empty
+        } while (nextReader() != null);
+    }
+
+    /**
+     * Returns the next available reader or null if done.
+     *
+     * @return the next available reader or null.
+     * @throws IOException IOException  If an I/O error occurs.
+     */
+    private Reader nextReader() throws IOException {
+        if (reader != null) {
+            reader.close();
+        }
+        if (readers.hasNext()) {
+            reader = readers.next();
+        } else {
+            reader = null;
+        }
+        return reader;
+    }
+
+    /*
+     * (non-Javadoc)
+     *
+     * @see java.io.Reader#read(char[], int, int)
+     */
+    @Override
+    public int read() throws IOException {
+        int c = EOF;
+        while (reader != null) {
+            c = reader.read();
+            if (c != EOF) {
+                break;
+            }
+            nextReader();
+        }
+        return c;
+    }
+
+    /*
+     * (non-Javadoc)
+     *
+     * @see java.io.Reader#read()
+     */
+    @Override
+    public int read(final char[] cbuf, int off, int len) throws IOException {
+        Objects.requireNonNull(cbuf, "cbuf");
+        if (len < 0 || off < 0 || off + len > cbuf.length) {
+            throw new IndexOutOfBoundsException("Array Size=" + cbuf.length + ", offset=" + off + ", length=" + len);
+        }
+        int count = 0;
+        while (reader != null) {
+            final int readLen = reader.read(cbuf, off, len);
+            if (readLen == EOF) {
+                nextReader();
+            } else {
+                count += readLen;
+                off += readLen;
+                len -= readLen;
+                if (len <= 0) {
+                    break;
+                }
+            }
+        }
+        if (count > 0) {
+            return count;
+        }
+        return EOF;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/SwappedDataInputStream.java b/src/main/java/org/apache/commons/io/input/SwappedDataInputStream.java
new file mode 100644
index 0000000..3df793a
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/SwappedDataInputStream.java
@@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.DataInput;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.apache.commons.io.EndianUtils;
+
+/**
+ * DataInput for systems relying on little endian data formats. When read, values will be changed from little endian to
+ * big endian formats for internal usage.
+ * <p>
+ * <b>Origin of code: </b>Avalon Excalibur (IO)
+ * </p>
+ *
+ */
+public class SwappedDataInputStream extends ProxyInputStream implements DataInput {
+
+    /**
+     * Constructs a SwappedDataInputStream.
+     *
+     * @param input InputStream to read from
+     */
+    public SwappedDataInputStream(final InputStream input) {
+        super(input);
+    }
+
+    /**
+     * Return <code>{@link #readByte()} != 0</code>
+     *
+     * @return false if the byte read is zero, otherwise true
+     * @throws IOException if an I/O error occurs.
+     * @throws EOFException if an end of file is reached unexpectedly
+     */
+    @Override
+    public boolean readBoolean() throws IOException, EOFException {
+        return 0 != readByte();
+    }
+
+    /**
+     * Invokes the delegate's {@code read()} method.
+     *
+     * @return the byte read or -1 if the end of stream
+     * @throws IOException if an I/O error occurs.
+     * @throws EOFException if an end of file is reached unexpectedly
+     */
+    @Override
+    public byte readByte() throws IOException, EOFException {
+        return (byte) in.read();
+    }
+
+    /**
+     * Reads a character delegating to {@link #readShort()}.
+     *
+     * @return the byte read or -1 if the end of stream
+     * @throws IOException if an I/O error occurs.
+     * @throws EOFException if an end of file is reached unexpectedly
+     */
+    @Override
+    public char readChar() throws IOException, EOFException {
+        return (char) readShort();
+    }
+
+    /**
+     * Delegates to {@link EndianUtils#readSwappedDouble(InputStream)}.
+     *
+     * @return the read long
+     * @throws IOException if an I/O error occurs.
+     * @throws EOFException if an end of file is reached unexpectedly
+     */
+    @Override
+    public double readDouble() throws IOException, EOFException {
+        return EndianUtils.readSwappedDouble(in);
+    }
+
+    /**
+     * Delegates to {@link EndianUtils#readSwappedFloat(InputStream)}.
+     *
+     * @return the read long
+     * @throws IOException if an I/O error occurs.
+     * @throws EOFException if an end of file is reached unexpectedly
+     */
+    @Override
+    public float readFloat() throws IOException, EOFException {
+        return EndianUtils.readSwappedFloat(in);
+    }
+
+    /**
+     * Invokes the delegate's {@code read(byte[] data, int, int)} method.
+     *
+     * @param data the buffer to read the bytes into
+     * @throws EOFException if an end of file is reached unexpectedly
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void readFully(final byte[] data) throws IOException, EOFException {
+        readFully(data, 0, data.length);
+    }
+
+    /**
+     * Invokes the delegate's {@code read(byte[] data, int, int)} method.
+     *
+     * @param data the buffer to read the bytes into
+     * @param offset The start offset
+     * @param length The number of bytes to read
+     * @throws EOFException if an end of file is reached unexpectedly
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void readFully(final byte[] data, final int offset, final int length) throws IOException, EOFException {
+        int remaining = length;
+
+        while (remaining > 0) {
+            final int location = offset + length - remaining;
+            final int count = read(data, location, remaining);
+
+            if (EOF == count) {
+                throw new EOFException();
+            }
+
+            remaining -= count;
+        }
+    }
+
+    /**
+     * Delegates to {@link EndianUtils#readSwappedInteger(InputStream)}.
+     *
+     * @return the read long
+     * @throws EOFException if an end of file is reached unexpectedly
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int readInt() throws IOException, EOFException {
+        return EndianUtils.readSwappedInteger(in);
+    }
+
+    /**
+     * Not currently supported - throws {@link UnsupportedOperationException}.
+     *
+     * @return the line read
+     * @throws EOFException if an end of file is reached unexpectedly
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public String readLine() throws IOException, EOFException {
+        throw UnsupportedOperationExceptions.method("readLine");
+    }
+
+    /**
+     * Delegates to {@link EndianUtils#readSwappedLong(InputStream)}.
+     *
+     * @return the read long
+     * @throws EOFException if an end of file is reached unexpectedly
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public long readLong() throws IOException, EOFException {
+        return EndianUtils.readSwappedLong(in);
+    }
+
+    /**
+     * Delegates to {@link EndianUtils#readSwappedShort(InputStream)}.
+     *
+     * @return the read long
+     * @throws EOFException if an end of file is reached unexpectedly
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public short readShort() throws IOException, EOFException {
+        return EndianUtils.readSwappedShort(in);
+    }
+
+    /**
+     * Invokes the delegate's {@code read()} method.
+     *
+     * @return the byte read or -1 if the end of stream
+     * @throws EOFException if an end of file is reached unexpectedly
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int readUnsignedByte() throws IOException, EOFException {
+        return in.read();
+    }
+
+    /**
+     * Delegates to {@link EndianUtils#readSwappedUnsignedShort(InputStream)}.
+     *
+     * @return the read long
+     * @throws EOFException if an end of file is reached unexpectedly
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int readUnsignedShort() throws IOException, EOFException {
+        return EndianUtils.readSwappedUnsignedShort(in);
+    }
+
+    /**
+     * Not currently supported - throws {@link UnsupportedOperationException}.
+     *
+     * @return UTF String read
+     * @throws EOFException if an end of file is reached unexpectedly
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public String readUTF() throws IOException, EOFException {
+        throw UnsupportedOperationExceptions.method("readUTF");
+    }
+
+    /**
+     * Invokes the delegate's {@code skip(int)} method.
+     *
+     * @param count the number of bytes to skip
+     * @return the number of bytes to skipped or -1 if the end of stream
+     * @throws EOFException if an end of file is reached unexpectedly
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int skipBytes(final int count) throws IOException, EOFException {
+        return (int) in.skip(count);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/TaggedInputStream.java b/src/main/java/org/apache/commons/io/input/TaggedInputStream.java
new file mode 100644
index 0000000..add4eb8
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/TaggedInputStream.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Serializable;
+import java.util.UUID;
+
+import org.apache.commons.io.TaggedIOException;
+
+/**
+ * An input stream decorator that tags potential exceptions so that the
+ * stream that caused the exception can easily be identified. This is
+ * done by using the {@link TaggedIOException} class to wrap all thrown
+ * {@link IOException}s. See below for an example of using this class.
+ * <pre>
+ * TaggedInputStream stream = new TaggedInputStream(...);
+ * try {
+ *     // Processing that may throw an IOException either from this stream
+ *     // or from some other IO activity like temporary files, etc.
+ *     processStream(stream);
+ * } catch (IOException e) {
+ *     if (stream.isCauseOf(e)) {
+ *         // The exception was caused by this stream.
+ *         // Use e.getCause() to get the original exception.
+ *     } else {
+ *         // The exception was caused by something else.
+ *     }
+ * }
+ * </pre>
+ * <p>
+ * Alternatively, the {@link #throwIfCauseOf(Throwable)} method can be
+ * used to let higher levels of code handle the exception caused by this
+ * stream while other processing errors are being taken care of at this
+ * lower level.
+ * </p>
+ * <pre>
+ * TaggedInputStream stream = new TaggedInputStream(...);
+ * try {
+ *     processStream(stream);
+ * } catch (IOException e) {
+ *     stream.throwIfCauseOf(e);
+ *     // ... or process the exception that was caused by something else
+ * }
+ * </pre>
+ *
+ * @see TaggedIOException
+ * @since 2.0
+ */
+public class TaggedInputStream extends ProxyInputStream {
+
+    /**
+     * The unique tag associated with exceptions from stream.
+     */
+    private final Serializable tag = UUID.randomUUID();
+
+    /**
+     * Creates a tagging decorator for the given input stream.
+     *
+     * @param proxy input stream to be decorated
+     */
+    public TaggedInputStream(final InputStream proxy) {
+        super(proxy);
+    }
+
+    /**
+     * Tags any IOExceptions thrown, wrapping and re-throwing.
+     *
+     * @param e The IOException thrown
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    protected void handleIOException(final IOException e) throws IOException {
+        throw new TaggedIOException(e, tag);
+    }
+
+    /**
+     * Tests if the given exception was caused by this stream.
+     *
+     * @param exception an exception
+     * @return {@code true} if the exception was thrown by this stream,
+     *         {@code false} otherwise
+     */
+    public boolean isCauseOf(final Throwable exception) {
+        return TaggedIOException.isTaggedWith(exception, tag);
+    }
+
+    /**
+     * Re-throws the original exception thrown by this stream. This method
+     * first checks whether the given exception is a {@link TaggedIOException}
+     * wrapper created by this decorator, and then unwraps and throws the
+     * original wrapped exception. Returns normally if the exception was
+     * not thrown by this stream.
+     *
+     * @param throwable an exception
+     * @throws IOException original exception, if any, thrown by this stream
+     */
+    public void throwIfCauseOf(final Throwable throwable) throws IOException {
+        TaggedIOException.throwCauseIfTaggedWith(throwable, tag);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/TaggedReader.java b/src/main/java/org/apache/commons/io/input/TaggedReader.java
new file mode 100644
index 0000000..fff91e5
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/TaggedReader.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.Serializable;
+import java.util.UUID;
+
+import org.apache.commons.io.TaggedIOException;
+
+/**
+ * A reader decorator that tags potential exceptions so that the reader that caused the exception can easily be
+ * identified. This is done by using the {@link TaggedIOException} class to wrap all thrown {@link IOException}s. See
+ * below for an example of using this class.
+ *
+ * <pre>
+ * TaggedReader reader = new TaggedReader(...);
+ * try {
+ *     // Processing that may throw an IOException either from this reader
+ *     // or from some other IO activity like temporary files, etc.
+ *     processReader(reader);
+ * } catch (IOException e) {
+ *     if (reader.isCauseOf(e)) {
+ *         // The exception was caused by this reader.
+ *         // Use e.getCause() to get the original exception.
+ *     } else {
+ *         // The exception was caused by something else.
+ *     }
+ * }
+ * </pre>
+ * <p>
+ * Alternatively, the {@link #throwIfCauseOf(Throwable)} method can be used to let higher levels of code handle the
+ * exception caused by this reader while other processing errors are being taken care of at this lower level.
+ * </p>
+ *
+ * <pre>
+ * TaggedReader reader = new TaggedReader(...);
+ * try {
+ *     processReader(reader);
+ * } catch (IOException e) {
+ *     reader.throwIfCauseOf(e);
+ *     // ... or process the exception that was caused by something else
+ * }
+ * </pre>
+ *
+ * @see TaggedIOException
+ * @since 2.7
+ */
+public class TaggedReader extends ProxyReader {
+
+    /**
+     * The unique tag associated with exceptions from reader.
+     */
+    private final Serializable tag = UUID.randomUUID();
+
+    /**
+     * Creates a tagging decorator for the given reader.
+     *
+     * @param proxy reader to be decorated
+     */
+    public TaggedReader(final Reader proxy) {
+        super(proxy);
+    }
+
+    /**
+     * Tags any IOExceptions thrown, wrapping and re-throwing.
+     *
+     * @param e The IOException thrown
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    protected void handleIOException(final IOException e) throws IOException {
+        throw new TaggedIOException(e, tag);
+    }
+
+    /**
+     * Tests if the given exception was caused by this reader.
+     *
+     * @param exception an exception
+     * @return {@code true} if the exception was thrown by this reader, {@code false} otherwise
+     */
+    public boolean isCauseOf(final Throwable exception) {
+        return TaggedIOException.isTaggedWith(exception, tag);
+    }
+
+    /**
+     * Re-throws the original exception thrown by this reader. This method first checks whether the given exception is a
+     * {@link TaggedIOException} wrapper created by this decorator, and then unwraps and throws the original wrapped
+     * exception. Returns normally if the exception was not thrown by this reader.
+     *
+     * @param throwable an exception
+     * @throws IOException original exception, if any, thrown by this reader
+     */
+    public void throwIfCauseOf(final Throwable throwable) throws IOException {
+        TaggedIOException.throwCauseIfTaggedWith(throwable, tag);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/Tailer.java b/src/main/java/org/apache/commons/io/input/Tailer.java
new file mode 100644
index 0000000..7212b69
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/Tailer.java
@@ -0,0 +1,988 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.CR;
+import static org.apache.commons.io.IOUtils.EOF;
+import static org.apache.commons.io.IOUtils.LF;
+
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Objects;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.ThreadUtils;
+import org.apache.commons.io.file.PathUtils;
+import org.apache.commons.io.file.attribute.FileTimes;
+
+/**
+ * Simple implementation of the UNIX "tail -f" functionality.
+ *
+ * <h2>1. Create a TailerListener implementation</h2>
+ * <p>
+ * First you need to create a {@link TailerListener} implementation; ({@link TailerListenerAdapter} is provided for
+ * convenience so that you don't have to implement every method).
+ * </p>
+ *
+ * <p>
+ * For example:
+ * </p>
+ *
+ * <pre>
+ * public class MyTailerListener extends TailerListenerAdapter {
+ *     public void handle(String line) {
+ *         System.out.println(line);
+ *     }
+ * }
+ * </pre>
+ *
+ * <h2>2. Using a Tailer</h2>
+ *
+ * <p>
+ * You can create and use a Tailer in one of four ways:
+ * </p>
+ * <ul>
+ * <li>Using a {@link Builder}</li>
+ * <li>Using one of the static helper methods:
+ * <ul>
+ * <li>{@link Tailer#create(File, TailerListener)}</li>
+ * <li>{@link Tailer#create(File, TailerListener, long)}</li>
+ * <li>{@link Tailer#create(File, TailerListener, long, boolean)}</li>
+ * </ul>
+ * </li>
+ * <li>Using an {@link java.util.concurrent.Executor}</li>
+ * <li>Using a {@link Thread}</li>
+ * </ul>
+ *
+ * <p>
+ * An example of each is shown below.
+ * </p>
+ *
+ * <h3>2.1 Using a Builder</h3>
+ *
+ * <pre>
+ * TailerListener listener = new MyTailerListener();
+ * Tailer tailer = new Tailer.Builder(file, listener).withDelayDuration(delay).build();
+ * </pre>
+ *
+ * <h3>2.2 Using the static helper method</h3>
+ *
+ * <pre>
+ * TailerListener listener = new MyTailerListener();
+ * Tailer tailer = Tailer.create(file, listener, delay);
+ * </pre>
+ *
+ * <h3>2.3 Using an Executor</h3>
+ *
+ * <pre>
+ * TailerListener listener = new MyTailerListener();
+ * Tailer tailer = new Tailer(file, listener, delay);
+ *
+ * // stupid executor impl. for demo purposes
+ * Executor executor = new Executor() {
+ *     public void execute(Runnable command) {
+ *         command.run();
+ *     }
+ * };
+ *
+ * executor.execute(tailer);
+ * </pre>
+ *
+ *
+ * <h3>2.4 Using a Thread</h3>
+ *
+ * <pre>
+ * TailerListener listener = new MyTailerListener();
+ * Tailer tailer = new Tailer(file, listener, delay);
+ * Thread thread = new Thread(tailer);
+ * thread.setDaemon(true); // optional
+ * thread.start();
+ * </pre>
+ *
+ * <h2>3. Stopping a Tailer</h2>
+ * <p>
+ * Remember to stop the tailer when you have done with it:
+ * </p>
+ *
+ * <pre>
+ * tailer.stop();
+ * </pre>
+ *
+ * <h2>4. Interrupting a Tailer</h2>
+ * <p>
+ * You can interrupt the thread a tailer is running on by calling {@link Thread#interrupt()}.
+ * </p>
+ *
+ * <pre>
+ * thread.interrupt();
+ * </pre>
+ * <p>
+ * If you interrupt a tailer, the tailer listener is called with the {@link InterruptedException}.
+ * </p>
+ * <p>
+ * The file is read using the default Charset; this can be overridden if necessary.
+ * </p>
+ *
+ * @see TailerListener
+ * @see TailerListenerAdapter
+ * @since 2.0
+ * @since 2.5 Updated behavior and documentation for {@link Thread#interrupt()}.
+ * @since 2.12.0 Add {@link Tailable} and {@link RandomAccessResourceBridge} interfaces to tail of files accessed using
+ *        alternative libraries such as jCIFS or <a href="https://commons.apache.org/proper/commons-vfs/">Apache Commons
+ *        VFS</a>.
+ */
+public class Tailer implements Runnable, AutoCloseable {
+
+    /**
+     * Builds a {@link Tailer} with default values.
+     *
+     * @since 2.12.0
+     */
+    public static class Builder {
+
+        private final Tailable tailable;
+        private final TailerListener tailerListener;
+        private Charset charset = DEFAULT_CHARSET;
+        private int bufferSize = IOUtils.DEFAULT_BUFFER_SIZE;
+        private Duration delayDuration = Duration.ofMillis(DEFAULT_DELAY_MILLIS);
+        private boolean end;
+        private boolean reOpen;
+        private boolean startThread = true;
+
+        /**
+         * Creates a builder.
+         *
+         * @param file the file to follow.
+         * @param listener the TailerListener to use.
+         */
+        public Builder(final File file, final TailerListener listener) {
+            this(file.toPath(), listener);
+        }
+
+        /**
+         * Creates a builder.
+         *
+         * @param file the file to follow.
+         * @param listener the TailerListener to use.
+         */
+        public Builder(final Path file, final TailerListener listener) {
+            this(new TailablePath(file), listener);
+        }
+
+        /**
+         * Creates a builder.
+         *
+         * @param tailable the tailable to follow.
+         * @param tailerListener the TailerListener to use.
+         */
+        public Builder(final Tailable tailable, final TailerListener tailerListener) {
+            this.tailable = Objects.requireNonNull(tailable, "tailable");
+            this.tailerListener = Objects.requireNonNull(tailerListener, "tailerListener");
+        }
+
+        /**
+         * Builds and starts a new configured instance.
+         *
+         * @return a new configured instance.
+         */
+        public Tailer build() {
+            final Tailer tailer = new Tailer(tailable, charset, tailerListener, delayDuration, end, reOpen, bufferSize);
+            if (startThread) {
+                final Thread thread = new Thread(tailer);
+                thread.setDaemon(true);
+                thread.start();
+            }
+            return tailer;
+        }
+
+        /**
+         * Sets the buffer size.
+         *
+         * @param bufferSize Buffer size.
+         * @return Builder with specific buffer size.
+         */
+        public Builder withBufferSize(final int bufferSize) {
+            this.bufferSize = bufferSize;
+            return this;
+        }
+
+        /**
+         * Sets the Charset.
+         *
+         * @param charset the Charset to be used for reading the file.
+         * @return Builder with specific Charset.
+         */
+        public Builder withCharset(final Charset charset) {
+            this.charset = Objects.requireNonNull(charset, "charset");
+            return this;
+        }
+
+        /**
+         * Sets the delay duration.
+         *
+         * @param delayDuration the delay between checks of the file for new content.
+         * @return Builder with specific delay duration.
+         */
+        public Builder withDelayDuration(final Duration delayDuration) {
+            this.delayDuration = Objects.requireNonNull(delayDuration, "delayDuration");
+            return this;
+        }
+
+        /**
+         * Sets the re-open behavior.
+         *
+         * @param reOpen whether to close/reopen the file between chunks
+         * @return Builder with specific re-open behavior
+         */
+        public Builder withReOpen(final boolean reOpen) {
+            this.reOpen = reOpen;
+            return this;
+        }
+
+        /**
+         * Sets the daemon thread startup behavior.
+         *
+         * @param startThread whether to create a daemon thread automatically.
+         * @return Builder with specific daemon thread startup behavior.
+         */
+        public Builder withStartThread(final boolean startThread) {
+            this.startThread = startThread;
+            return this;
+        }
+
+        /**
+         * Sets the tail start behavior.
+         *
+         * @param end Set to true to tail from the end of the file, false to tail from the beginning of the file.
+         * @return Builder with specific tail start behavior.
+         */
+        public Builder withTailFromEnd(final boolean end) {
+            this.end = end;
+            return this;
+        }
+    }
+
+    /**
+     * Bridges random access to a {@link RandomAccessFile}.
+     */
+    private static final class RandomAccessFileBridge implements RandomAccessResourceBridge {
+
+        private final RandomAccessFile randomAccessFile;
+
+        private RandomAccessFileBridge(final File file, final String mode) throws FileNotFoundException {
+            randomAccessFile = new RandomAccessFile(file, mode);
+        }
+
+        @Override
+        public void close() throws IOException {
+            randomAccessFile.close();
+        }
+
+        @Override
+        public long getPointer() throws IOException {
+            return randomAccessFile.getFilePointer();
+        }
+
+        @Override
+        public int read(final byte[] b) throws IOException {
+            return randomAccessFile.read(b);
+        }
+
+        @Override
+        public void seek(final long position) throws IOException {
+            randomAccessFile.seek(position);
+        }
+
+    }
+
+    /**
+     * Bridges access to a resource for random access, normally a file. Allows substitution of remote files for example
+     * using jCIFS.
+     *
+     * @since 2.12.0
+     */
+    public interface RandomAccessResourceBridge extends Closeable {
+
+        /**
+         * Gets the current offset in this tailable.
+         *
+         * @return the offset from the beginning of the tailable, in bytes, at which the next read or write occurs.
+         * @throws IOException if an I/O error occurs.
+         */
+        long getPointer() throws IOException;
+
+        /**
+         * Reads up to {@code b.length} bytes of data from this tailable into an array of bytes. This method blocks until at
+         * least one byte of input is available.
+         *
+         * @param b the buffer into which the data is read.
+         * @return the total number of bytes read into the buffer, or {@code -1} if there is no more data because the end of
+         *         this tailable has been reached.
+         * @throws IOException If the first byte cannot be read for any reason other than end of tailable, or if the random
+         *         access tailable has been closed, or if some other I/O error occurs.
+         */
+        int read(final byte[] b) throws IOException;
+
+        /**
+         * Sets the file-pointer offset, measured from the beginning of this tailable, at which the next read or write occurs.
+         * The offset may be set beyond the end of the tailable. Setting the offset beyond the end of the tailable does not
+         * change the tailable length. The tailable length will change only by writing after the offset has been set beyond the
+         * end of the tailable.
+         *
+         * @param pos the offset position, measured in bytes from the beginning of the tailable, at which to set the tailable
+         *        pointer.
+         * @throws IOException if {@code pos} is less than {@code 0} or if an I/O error occurs.
+         */
+        void seek(final long pos) throws IOException;
+    }
+
+    /**
+     * A tailable resource like a file.
+     *
+     * @since 2.12.0
+     */
+    public interface Tailable {
+
+        /**
+         * Creates a random access file stream to read.
+         *
+         * @param mode the access mode, by default this is for {@link RandomAccessFile}.
+         * @return a random access file stream to read.
+         * @throws FileNotFoundException if the tailable object does not exist.
+         */
+        RandomAccessResourceBridge getRandomAccess(final String mode) throws FileNotFoundException;
+
+        /**
+         * Tests if this tailable is newer than the specified {@link FileTime}.
+         *
+         * @param fileTime the file time reference.
+         * @return true if the {@link File} exists and has been modified after the given {@link FileTime}.
+         * @throws IOException if an I/O error occurs.
+         */
+        boolean isNewer(final FileTime fileTime) throws IOException;
+
+        /**
+         * Gets the last modification {@link FileTime}.
+         *
+         * @return See {@link java.nio.file.Files#getLastModifiedTime(Path, LinkOption...)}.
+         * @throws IOException if an I/O error occurs.
+         */
+        FileTime lastModifiedFileTime() throws IOException;
+
+        /**
+         * Gets the size of this tailable.
+         *
+         * @return The size, in bytes, of this tailable, or {@code 0} if the file does not exist. Some operating systems may
+         *         return {@code 0} for path names denoting system-dependent entities such as devices or pipes.
+         * @throws IOException if an I/O error occurs.
+         */
+        long size() throws IOException;
+    }
+
+    /**
+     * A tailable for a file {@link Path}.
+     */
+    private static final class TailablePath implements Tailable {
+
+        private final Path path;
+        private final LinkOption[] linkOptions;
+
+        private TailablePath(final Path path, final LinkOption... linkOptions) {
+            this.path = Objects.requireNonNull(path, "path");
+            this.linkOptions = linkOptions;
+        }
+
+        Path getPath() {
+            return path;
+        }
+
+        @Override
+        public RandomAccessResourceBridge getRandomAccess(final String mode) throws FileNotFoundException {
+            return new RandomAccessFileBridge(path.toFile(), mode);
+        }
+
+        @Override
+        public boolean isNewer(final FileTime fileTime) throws IOException {
+            return PathUtils.isNewer(path, fileTime, linkOptions);
+        }
+
+        @Override
+        public FileTime lastModifiedFileTime() throws IOException {
+            return Files.getLastModifiedTime(path, linkOptions);
+        }
+
+        @Override
+        public long size() throws IOException {
+            return Files.size(path);
+        }
+
+        @Override
+        public String toString() {
+            return "TailablePath [file=" + path + ", linkOptions=" + Arrays.toString(linkOptions) + "]";
+        }
+    }
+
+    private static final int DEFAULT_DELAY_MILLIS = 1000;
+
+    private static final String RAF_READ_ONLY_MODE = "r";
+
+    // The default charset used for reading files
+    private static final Charset DEFAULT_CHARSET = Charset.defaultCharset();
+
+    /**
+     * Creates and starts a Tailer for the given file.
+     *
+     * @param file the file to follow.
+     * @param charset the character set to use for reading the file.
+     * @param listener the TailerListener to use.
+     * @param delayMillis the delay between checks of the file for new content in milliseconds.
+     * @param end Set to true to tail from the end of the file, false to tail from the beginning of the file.
+     * @param reOpen whether to close/reopen the file between chunks.
+     * @param bufferSize buffer size.
+     * @return The new tailer.
+     * @deprecated Use {@link Builder}.
+     */
+    @Deprecated
+    public static Tailer create(final File file, final Charset charset, final TailerListener listener, final long delayMillis, final boolean end,
+        final boolean reOpen, final int bufferSize) {
+        //@formatter:off
+        return new Builder(file, listener)
+                .withCharset(charset)
+                .withDelayDuration(Duration.ofMillis(delayMillis))
+                .withTailFromEnd(end)
+                .withReOpen(reOpen)
+                .withBufferSize(bufferSize)
+                .build();
+        //@formatter:on
+    }
+
+    /**
+     * Creates and starts a Tailer for the given file, starting at the beginning of the file with the default delay of 1.0s
+     *
+     * @param file the file to follow.
+     * @param listener the TailerListener to use.
+     * @return The new tailer.
+     * @deprecated Use {@link Builder}.
+     */
+    @Deprecated
+    public static Tailer create(final File file, final TailerListener listener) {
+        return new Builder(file, listener).build();
+    }
+
+    /**
+     * Creates and starts a Tailer for the given file, starting at the beginning of the file
+     *
+     * @param file the file to follow.
+     * @param listener the TailerListener to use.
+     * @param delayMillis the delay between checks of the file for new content in milliseconds.
+     * @return The new tailer.
+     * @deprecated Use {@link Builder}.
+     */
+    @Deprecated
+    public static Tailer create(final File file, final TailerListener listener, final long delayMillis) {
+        //@formatter:off
+        return new Builder(file, listener)
+                .withDelayDuration(Duration.ofMillis(delayMillis))
+                .build();
+        //@formatter:on
+    }
+
+    /**
+     * Creates and starts a Tailer for the given file with default buffer size.
+     *
+     * @param file the file to follow.
+     * @param listener the TailerListener to use.
+     * @param delayMillis the delay between checks of the file for new content in milliseconds.
+     * @param end Set to true to tail from the end of the file, false to tail from the beginning of the file.
+     * @return The new tailer.
+     * @deprecated Use {@link Builder}.
+     */
+    @Deprecated
+    public static Tailer create(final File file, final TailerListener listener, final long delayMillis, final boolean end) {
+        //@formatter:off
+        return new Builder(file, listener)
+                .withDelayDuration(Duration.ofMillis(delayMillis))
+                .withTailFromEnd(end)
+                .build();
+        //@formatter:on
+    }
+
+    /**
+     * Creates and starts a Tailer for the given file with default buffer size.
+     *
+     * @param file the file to follow.
+     * @param listener the TailerListener to use.
+     * @param delayMillis the delay between checks of the file for new content in milliseconds.
+     * @param end Set to true to tail from the end of the file, false to tail from the beginning of the file.
+     * @param reOpen whether to close/reopen the file between chunks.
+     * @return The new tailer.
+     * @deprecated Use {@link Builder}.
+     */
+    @Deprecated
+    public static Tailer create(final File file, final TailerListener listener, final long delayMillis, final boolean end, final boolean reOpen) {
+        //@formatter:off
+        return new Builder(file, listener)
+                .withDelayDuration(Duration.ofMillis(delayMillis))
+                .withTailFromEnd(end)
+                .withReOpen(reOpen)
+                .build();
+        //@formatter:on
+    }
+
+    /**
+     * Creates and starts a Tailer for the given file.
+     *
+     * @param file the file to follow.
+     * @param listener the TailerListener to use.
+     * @param delayMillis the delay between checks of the file for new content in milliseconds.
+     * @param end Set to true to tail from the end of the file, false to tail from the beginning of the file.
+     * @param reOpen whether to close/reopen the file between chunks.
+     * @param bufferSize buffer size.
+     * @return The new tailer.
+     * @deprecated Use {@link Builder}.
+     */
+    @Deprecated
+    public static Tailer create(final File file, final TailerListener listener, final long delayMillis, final boolean end, final boolean reOpen,
+        final int bufferSize) {
+        //@formatter:off
+        return new Builder(file, listener)
+                .withDelayDuration(Duration.ofMillis(delayMillis))
+                .withTailFromEnd(end)
+                .withReOpen(reOpen)
+                .withBufferSize(bufferSize)
+                .build();
+        //@formatter:on
+    }
+
+    /**
+     * Creates and starts a Tailer for the given file.
+     *
+     * @param file the file to follow.
+     * @param listener the TailerListener to use.
+     * @param delayMillis the delay between checks of the file for new content in milliseconds.
+     * @param end Set to true to tail from the end of the file, false to tail from the beginning of the file.
+     * @param bufferSize buffer size.
+     * @return The new tailer.
+     * @deprecated Use {@link Builder}.
+     */
+    @Deprecated
+    public static Tailer create(final File file, final TailerListener listener, final long delayMillis, final boolean end, final int bufferSize) {
+        //@formatter:off
+        return new Builder(file, listener)
+                .withDelayDuration(Duration.ofMillis(delayMillis))
+                .withTailFromEnd(end)
+                .withBufferSize(bufferSize)
+                .build();
+        //@formatter:on
+    }
+
+    /**
+     * Buffer on top of RandomAccessResourceBridge.
+     */
+    private final byte[] inbuf;
+
+    /**
+     * The file which will be tailed.
+     */
+    private final Tailable tailable;
+
+    /**
+     * The character set that will be used to read the file.
+     */
+    private final Charset charset;
+
+    /**
+     * The amount of time to wait for the file to be updated.
+     */
+    private final Duration delayDuration;
+
+    /**
+     * Whether to tail from the end or start of file
+     */
+    private final boolean tailAtEnd;
+
+    /**
+     * The listener to notify of events when tailing.
+     */
+    private final TailerListener listener;
+
+    /**
+     * Whether to close and reopen the file whilst waiting for more input.
+     */
+    private final boolean reOpen;
+
+    /**
+     * The tailer will run as long as this value is true.
+     */
+    private volatile boolean run = true;
+
+    /**
+     * Creates a Tailer for the given file, with a specified buffer size.
+     *
+     * @param file the file to follow.
+     * @param charset the Charset to be used for reading the file
+     * @param listener the TailerListener to use.
+     * @param delayMillis the delay between checks of the file for new content in milliseconds.
+     * @param end Set to true to tail from the end of the file, false to tail from the beginning of the file.
+     * @param reOpen if true, close and reopen the file between reading chunks
+     * @param bufSize Buffer size
+     * @deprecated Use {@link Builder}.
+     */
+    @Deprecated
+    public Tailer(final File file, final Charset charset, final TailerListener listener, final long delayMillis, final boolean end, final boolean reOpen,
+        final int bufSize) {
+        this(new TailablePath(file.toPath()), charset, listener, Duration.ofMillis(delayMillis), end, reOpen, bufSize);
+    }
+
+    /**
+     * Creates a Tailer for the given file, starting from the beginning, with the default delay of 1.0s.
+     *
+     * @param file The file to follow.
+     * @param listener the TailerListener to use.
+     * @deprecated Use {@link Builder}.
+     */
+    @Deprecated
+    public Tailer(final File file, final TailerListener listener) {
+        this(file, listener, DEFAULT_DELAY_MILLIS);
+    }
+
+    /**
+     * Creates a Tailer for the given file, starting from the beginning.
+     *
+     * @param file the file to follow.
+     * @param listener the TailerListener to use.
+     * @param delayMillis the delay between checks of the file for new content in milliseconds.
+     * @deprecated Use {@link Builder}.
+     */
+    @Deprecated
+    public Tailer(final File file, final TailerListener listener, final long delayMillis) {
+        this(file, listener, delayMillis, false);
+    }
+
+    /**
+     * Creates a Tailer for the given file, with a delay other than the default 1.0s.
+     *
+     * @param file the file to follow.
+     * @param listener the TailerListener to use.
+     * @param delayMillis the delay between checks of the file for new content in milliseconds.
+     * @param end Set to true to tail from the end of the file, false to tail from the beginning of the file.
+     * @deprecated Use {@link Builder}.
+     */
+    @Deprecated
+    public Tailer(final File file, final TailerListener listener, final long delayMillis, final boolean end) {
+        this(file, listener, delayMillis, end, IOUtils.DEFAULT_BUFFER_SIZE);
+    }
+
+    /**
+     * Creates a Tailer for the given file, with a delay other than the default 1.0s.
+     *
+     * @param file the file to follow.
+     * @param listener the TailerListener to use.
+     * @param delayMillis the delay between checks of the file for new content in milliseconds.
+     * @param end Set to true to tail from the end of the file, false to tail from the beginning of the file.
+     * @param reOpen if true, close and reopen the file between reading chunks
+     * @deprecated Use {@link Builder}.
+     */
+    @Deprecated
+    public Tailer(final File file, final TailerListener listener, final long delayMillis, final boolean end, final boolean reOpen) {
+        this(file, listener, delayMillis, end, reOpen, IOUtils.DEFAULT_BUFFER_SIZE);
+    }
+
+    /**
+     * Creates a Tailer for the given file, with a specified buffer size.
+     *
+     * @param file the file to follow.
+     * @param listener the TailerListener to use.
+     * @param delayMillis the delay between checks of the file for new content in milliseconds.
+     * @param end Set to true to tail from the end of the file, false to tail from the beginning of the file.
+     * @param reOpen if true, close and reopen the file between reading chunks
+     * @param bufferSize Buffer size
+     * @deprecated Use {@link Builder}.
+     */
+    @Deprecated
+    public Tailer(final File file, final TailerListener listener, final long delayMillis, final boolean end, final boolean reOpen, final int bufferSize) {
+        this(file, DEFAULT_CHARSET, listener, delayMillis, end, reOpen, bufferSize);
+    }
+
+    /**
+     * Creates a Tailer for the given file, with a specified buffer size.
+     *
+     * @param file the file to follow.
+     * @param listener the TailerListener to use.
+     * @param delayMillis the delay between checks of the file for new content in milliseconds.
+     * @param end Set to true to tail from the end of the file, false to tail from the beginning of the file.
+     * @param bufferSize Buffer size
+     * @deprecated Use {@link Builder}.
+     */
+    @Deprecated
+    public Tailer(final File file, final TailerListener listener, final long delayMillis, final boolean end, final int bufferSize) {
+        this(file, listener, delayMillis, end, false, bufferSize);
+    }
+
+    /**
+     * Creates a Tailer for the given file, with a specified buffer size.
+     *
+     * @param tailable the file to follow.
+     * @param charset the Charset to be used for reading the file
+     * @param listener the TailerListener to use.
+     * @param delayDuration the delay between checks of the file for new content in milliseconds.
+     * @param end Set to true to tail from the end of the file, false to tail from the beginning of the file.
+     * @param reOpen if true, close and reopen the file between reading chunks
+     * @param bufferSize Buffer size
+     */
+    private Tailer(final Tailable tailable, final Charset charset, final TailerListener listener, final Duration delayDuration, final boolean end,
+        final boolean reOpen, final int bufferSize) {
+        this.tailable = tailable;
+        this.delayDuration = delayDuration;
+        this.tailAtEnd = end;
+        this.inbuf = IOUtils.byteArray(bufferSize);
+
+        // Save and prepare the listener
+        this.listener = listener;
+        listener.init(this);
+        this.reOpen = reOpen;
+        this.charset = charset;
+    }
+
+    /**
+     * Requests the tailer to complete its current loop and return.
+     */
+    @Override
+    public void close() {
+        this.run = false;
+    }
+
+    /**
+     * Gets the delay in milliseconds.
+     *
+     * @return the delay in milliseconds.
+     * @deprecated Use {@link #getDelayDuration()}.
+     */
+    @Deprecated
+    public long getDelay() {
+        return delayDuration.toMillis();
+    }
+
+    /**
+     * Gets the delay Duration.
+     *
+     * @return the delay Duration.
+     * @since 2.12.0
+     */
+    public Duration getDelayDuration() {
+        return delayDuration;
+    }
+
+    /**
+     * Gets the file.
+     *
+     * @return the file
+     * @throws IllegalStateException if constructed using a user provided {@link Tailable} implementation
+     */
+    public File getFile() {
+        if (tailable instanceof TailablePath) {
+            return ((TailablePath) tailable).getPath().toFile();
+        }
+        throw new IllegalStateException("Cannot extract java.io.File from " + tailable.getClass().getName());
+    }
+
+    /**
+     * Gets whether to keep on running.
+     *
+     * @return whether to keep on running.
+     * @since 2.5
+     */
+    protected boolean getRun() {
+        return run;
+    }
+
+    /**
+     * Gets the Tailable.
+     *
+     * @return the Tailable
+     * @since 2.12.0
+     */
+    public Tailable getTailable() {
+        return tailable;
+    }
+
+    /**
+     * Reads new lines.
+     *
+     * @param reader The file to read
+     * @return The new position after the lines have been read
+     * @throws IOException if an I/O error occurs.
+     */
+    private long readLines(final RandomAccessResourceBridge reader) throws IOException {
+        try (ByteArrayOutputStream lineBuf = new ByteArrayOutputStream(64)) {
+            long pos = reader.getPointer();
+            long rePos = pos; // position to re-read
+            int num;
+            boolean seenCR = false;
+            while (getRun() && (num = reader.read(inbuf)) != EOF) {
+                for (int i = 0; i < num; i++) {
+                    final byte ch = inbuf[i];
+                    switch (ch) {
+                    case LF:
+                        seenCR = false; // swallow CR before LF
+                        listener.handle(new String(lineBuf.toByteArray(), charset));
+                        lineBuf.reset();
+                        rePos = pos + i + 1;
+                        break;
+                    case CR:
+                        if (seenCR) {
+                            lineBuf.write(CR);
+                        }
+                        seenCR = true;
+                        break;
+                    default:
+                        if (seenCR) {
+                            seenCR = false; // swallow final CR
+                            listener.handle(new String(lineBuf.toByteArray(), charset));
+                            lineBuf.reset();
+                            rePos = pos + i + 1;
+                        }
+                        lineBuf.write(ch);
+                    }
+                }
+                pos = reader.getPointer();
+            }
+
+            reader.seek(rePos); // Ensure we can re-read if necessary
+
+            if (listener instanceof TailerListenerAdapter) {
+                ((TailerListenerAdapter) listener).endOfFileReached();
+            }
+
+            return rePos;
+        }
+    }
+
+    /**
+     * Follows changes in the file, calling {@link TailerListener#handle(String)} with each new line.
+     */
+    @Override
+    public void run() {
+        RandomAccessResourceBridge reader = null;
+        try {
+            FileTime last = FileTimes.EPOCH; // The last time the file was checked for changes
+            long position = 0; // position within the file
+            // Open the file
+            while (getRun() && reader == null) {
+                try {
+                    reader = tailable.getRandomAccess(RAF_READ_ONLY_MODE);
+                } catch (final FileNotFoundException e) {
+                    listener.fileNotFound();
+                }
+                if (reader == null) {
+                    ThreadUtils.sleep(delayDuration);
+                } else {
+                    // The current position in the file
+                    position = tailAtEnd ? tailable.size() : 0;
+                    last = tailable.lastModifiedFileTime();
+                    reader.seek(position);
+                }
+            }
+            while (getRun()) {
+                final boolean newer = tailable.isNewer(last); // IO-279, must be done first
+                // Check the file length to see if it was rotated
+                final long length = tailable.size();
+                if (length < position) {
+                    // File was rotated
+                    listener.fileRotated();
+                    // Reopen the reader after rotation ensuring that the old file is closed iff we re-open it
+                    // successfully
+                    try (RandomAccessResourceBridge save = reader) {
+                        reader = tailable.getRandomAccess(RAF_READ_ONLY_MODE);
+                        // At this point, we're sure that the old file is rotated
+                        // Finish scanning the old file and then we'll start with the new one
+                        try {
+                            readLines(save);
+                        } catch (final IOException ioe) {
+                            listener.handle(ioe);
+                        }
+                        position = 0;
+                    } catch (final FileNotFoundException e) {
+                        // in this case we continue to use the previous reader and position values
+                        listener.fileNotFound();
+                        ThreadUtils.sleep(delayDuration);
+                    }
+                    continue;
+                }
+                // File was not rotated
+                // See if the file needs to be read again
+                if (length > position) {
+                    // The file has more content than it did last time
+                    position = readLines(reader);
+                    last = tailable.lastModifiedFileTime();
+                } else if (newer) {
+                    /*
+                     * This can happen if the file is truncated or overwritten with the exact same length of information. In cases like
+                     * this, the file position needs to be reset
+                     */
+                    position = 0;
+                    reader.seek(position); // cannot be null here
+
+                    // Now we can read new lines
+                    position = readLines(reader);
+                    last = tailable.lastModifiedFileTime();
+                }
+                if (reOpen && reader != null) {
+                    reader.close();
+                }
+                ThreadUtils.sleep(delayDuration);
+                if (getRun() && reOpen) {
+                    reader = tailable.getRandomAccess(RAF_READ_ONLY_MODE);
+                    reader.seek(position);
+                }
+            }
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+            listener.handle(e);
+        } catch (final Exception e) {
+            listener.handle(e);
+        } finally {
+            try {
+                IOUtils.close(reader);
+            } catch (final IOException e) {
+                listener.handle(e);
+            }
+            close();
+        }
+    }
+
+    /**
+     * Requests the tailer to complete its current loop and return.
+     *
+     * @deprecated Use {@link #close()}.
+     */
+    @Deprecated
+    public void stop() {
+        close();
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/TailerListener.java b/src/main/java/org/apache/commons/io/input/TailerListener.java
new file mode 100644
index 0000000..ae86685
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/TailerListener.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+/**
+ * Listener for events from a {@link Tailer}.
+ *
+ * @since 2.0
+ */
+public interface TailerListener {
+
+    /**
+     * This method is called if the tailed file is not found.
+     * <p>
+     * <b>Note:</b> this is called from the tailer thread.
+     * </p>
+     */
+    void fileNotFound();
+
+    /**
+     * Called if a file rotation is detected.
+     *
+     * This method is called before the file is reopened, and fileNotFound may
+     * be called if the new file has not yet been created.
+     * <p>
+     * <b>Note:</b> this is called from the tailer thread.
+     * </p>
+     */
+    void fileRotated();
+
+    /**
+     * Handles an Exception.
+     * <p>
+     * <b>Note:</b> this is called from the tailer thread.
+     * </p>
+     * @param ex the exception.
+     */
+    void handle(Exception ex);
+
+    /**
+     * Handles a line from a Tailer.
+     * <p>
+     * <b>Note:</b> this is called from the tailer thread.
+     * </p>
+     * @param line the line.
+     */
+    void handle(String line);
+
+    /**
+     * The tailer will call this method during construction,
+     * giving the listener a method of stopping the tailer.
+     * @param tailer the tailer.
+     */
+    void init(Tailer tailer);
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/TailerListenerAdapter.java b/src/main/java/org/apache/commons/io/input/TailerListenerAdapter.java
new file mode 100644
index 0000000..6b8760c
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/TailerListenerAdapter.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+/**
+ * {@link TailerListener} Adapter.
+ *
+ * @since 2.0
+ */
+public class TailerListenerAdapter implements TailerListener {
+
+    /**
+     * Called each time the Tailer reaches the end of the file.
+     *
+     * <b>Note:</b> this is called from the tailer thread.
+     *
+     * Note: a future version of commons-io will pull this method up to the TailerListener interface,
+     * for now clients must subclass this class to use this feature.
+     *
+     * @since 2.5
+     */
+    public void endOfFileReached() {
+        // noop
+    }
+
+    /**
+     * This method is called if the tailed file is not found.
+     */
+    @Override
+    public void fileNotFound() {
+        // noop
+    }
+
+    /**
+     * Called if a file rotation is detected.
+     *
+     * This method is called before the file is reopened, and fileNotFound may
+     * be called if the new file has not yet been created.
+     */
+    @Override
+    public void fileRotated() {
+        // noop
+    }
+
+    /**
+     * Handles an Exception .
+     * @param ex the exception.
+     */
+    @Override
+    public void handle(final Exception ex) {
+        // noop
+    }
+
+    /**
+     * Handles a line from a Tailer.
+     * @param line the line.
+     */
+    @Override
+    public void handle(final String line) {
+        // noop
+    }
+
+    /**
+     * The tailer will call this method during construction,
+     * giving the listener a method of stopping the tailer.
+     * @param tailer the tailer.
+     */
+    @Override
+    public void init(final Tailer tailer) {
+        // noop
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/TeeInputStream.java b/src/main/java/org/apache/commons/io/input/TeeInputStream.java
new file mode 100644
index 0000000..ae1abff
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/TeeInputStream.java
@@ -0,0 +1,153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * InputStream proxy that transparently writes a copy of all bytes read
+ * from the proxied stream to a given OutputStream. Using {@link #skip(long)}
+ * or {@link #mark(int)}/{@link #reset()} on the stream will result on some
+ * bytes from the input stream being skipped or duplicated in the output
+ * stream.
+ * <p>
+ * The proxied input stream is closed when the {@link #close()} method is
+ * called on this proxy. You may configure whether the input stream closes the
+ * output stream.
+ * </p>
+ *
+ * @since 1.4
+ * @see ObservableInputStream
+ */
+public class TeeInputStream extends ProxyInputStream {
+
+    /**
+     * The output stream that will receive a copy of all bytes read from the
+     * proxied input stream.
+     */
+    private final OutputStream branch;
+
+    /**
+     * Flag for closing the associated output stream when this stream is closed.
+     */
+    private final boolean closeBranch;
+
+    /**
+     * Creates a TeeInputStream that proxies the given {@link InputStream}
+     * and copies all read bytes to the given {@link OutputStream}. The given
+     * output stream will not be closed when this stream gets closed.
+     *
+     * @param input input stream to be proxied
+     * @param branch output stream that will receive a copy of all bytes read
+     */
+    public TeeInputStream(final InputStream input, final OutputStream branch) {
+        this(input, branch, false);
+    }
+
+    /**
+     * Creates a TeeInputStream that proxies the given {@link InputStream}
+     * and copies all read bytes to the given {@link OutputStream}. The given
+     * output stream will be closed when this stream gets closed if the
+     * closeBranch parameter is {@code true}.
+     *
+     * @param input input stream to be proxied
+     * @param branch output stream that will receive a copy of all bytes read
+     * @param closeBranch flag for closing also the output stream when this
+     *                    stream is closed
+     */
+    public TeeInputStream(
+            final InputStream input, final OutputStream branch, final boolean closeBranch) {
+        super(input);
+        this.branch = branch;
+        this.closeBranch = closeBranch;
+    }
+
+    /**
+     * Closes the proxied input stream and, if so configured, the associated
+     * output stream. An exception thrown from one stream will not prevent
+     * closing of the other stream.
+     *
+     * @throws IOException if either of the streams could not be closed
+     */
+    @Override
+    public void close() throws IOException {
+        try {
+            super.close();
+        } finally {
+            if (closeBranch) {
+                branch.close();
+            }
+        }
+    }
+
+    /**
+     * Reads a single byte from the proxied input stream and writes it to
+     * the associated output stream.
+     *
+     * @return next byte from the stream, or -1 if the stream has ended
+     * @throws IOException if the stream could not be read (or written)
+     */
+    @Override
+    public int read() throws IOException {
+        final int ch = super.read();
+        if (ch != EOF) {
+            branch.write(ch);
+        }
+        return ch;
+    }
+
+    /**
+     * Reads bytes from the proxied input stream and writes the read bytes
+     * to the associated output stream.
+     *
+     * @param bts byte buffer
+     * @return number of bytes read, or -1 if the stream has ended
+     * @throws IOException if the stream could not be read (or written)
+     */
+    @Override
+    public int read(final byte[] bts) throws IOException {
+        final int n = super.read(bts);
+        if (n != EOF) {
+            branch.write(bts, 0, n);
+        }
+        return n;
+    }
+
+    /**
+     * Reads bytes from the proxied input stream and writes the read bytes
+     * to the associated output stream.
+     *
+     * @param bts byte buffer
+     * @param st start offset within the buffer
+     * @param end maximum number of bytes to read
+     * @return number of bytes read, or -1 if the stream has ended
+     * @throws IOException if the stream could not be read (or written)
+     */
+    @Override
+    public int read(final byte[] bts, final int st, final int end) throws IOException {
+        final int n = super.read(bts, st, end);
+        if (n != EOF) {
+            branch.write(bts, st, n);
+        }
+        return n;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/TeeReader.java b/src/main/java/org/apache/commons/io/input/TeeReader.java
new file mode 100644
index 0000000..354cac9
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/TeeReader.java
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.Writer;
+import java.nio.CharBuffer;
+
+/**
+ * Reader proxy that transparently writes a copy of all characters read from the proxied reader to a given Reader. Using
+ * {@link #skip(long)} or {@link #mark(int)}/{@link #reset()} on the reader will result on some characters from the
+ * reader being skipped or duplicated in the writer.
+ * <p>
+ * The proxied reader is closed when the {@link #close()} method is called on this proxy. You may configure whether the
+ * reader closes the writer.
+ * </p>
+ *
+ * @since 2.7
+ */
+public class TeeReader extends ProxyReader {
+
+    /**
+     * The writer that will receive a copy of all characters read from the proxied reader.
+     */
+    private final Writer branch;
+
+    /**
+     * Flag for closing the associated writer when this reader is closed.
+     */
+    private final boolean closeBranch;
+
+    /**
+     * Creates a TeeReader that proxies the given {@link Reader} and copies all read characters to the given
+     * {@link Writer}. The given writer will not be closed when this reader gets closed.
+     *
+     * @param input  reader to be proxied
+     * @param branch writer that will receive a copy of all characters read
+     */
+    public TeeReader(final Reader input, final Writer branch) {
+        this(input, branch, false);
+    }
+
+    /**
+     * Creates a TeeReader that proxies the given {@link Reader} and copies all read characters to the given
+     * {@link Writer}. The given writer will be closed when this reader gets closed if the closeBranch parameter is
+     * {@code true}.
+     *
+     * @param input       reader to be proxied
+     * @param branch      writer that will receive a copy of all characters read
+     * @param closeBranch flag for closing also the writer when this reader is closed
+     */
+    public TeeReader(final Reader input, final Writer branch, final boolean closeBranch) {
+        super(input);
+        this.branch = branch;
+        this.closeBranch = closeBranch;
+    }
+
+    /**
+     * Closes the proxied reader and, if so configured, the associated writer. An exception thrown from the reader will
+     * not prevent closing of the writer.
+     *
+     * @throws IOException if either the reader or writer could not be closed
+     */
+    @Override
+    public void close() throws IOException {
+        try {
+            super.close();
+        } finally {
+            if (closeBranch) {
+                branch.close();
+            }
+        }
+    }
+
+    /**
+     * Reads a single character from the proxied reader and writes it to the associated writer.
+     *
+     * @return next character from the reader, or -1 if the reader has ended
+     * @throws IOException if the reader could not be read (or written)
+     */
+    @Override
+    public int read() throws IOException {
+        final int ch = super.read();
+        if (ch != EOF) {
+            branch.write(ch);
+        }
+        return ch;
+    }
+
+    /**
+     * Reads characters from the proxied reader and writes the read characters to the associated writer.
+     *
+     * @param chr character buffer
+     * @return number of characters read, or -1 if the reader has ended
+     * @throws IOException if the reader could not be read (or written)
+     */
+    @Override
+    public int read(final char[] chr) throws IOException {
+        final int n = super.read(chr);
+        if (n != EOF) {
+            branch.write(chr, 0, n);
+        }
+        return n;
+    }
+
+    /**
+     * Reads characters from the proxied reader and writes the read characters to the associated writer.
+     *
+     * @param chr character buffer
+     * @param st  start offset within the buffer
+     * @param end maximum number of characters to read
+     * @return number of characters read, or -1 if the reader has ended
+     * @throws IOException if the reader could not be read (or written)
+     */
+    @Override
+    public int read(final char[] chr, final int st, final int end) throws IOException {
+        final int n = super.read(chr, st, end);
+        if (n != EOF) {
+            branch.write(chr, st, n);
+        }
+        return n;
+    }
+
+    /**
+     * Reads characters from the proxied reader and writes the read characters to the associated writer.
+     *
+     * @param target character buffer
+     * @return number of characters read, or -1 if the reader has ended
+     * @throws IOException if the reader could not be read (or written)
+     */
+    @Override
+    public int read(final CharBuffer target) throws IOException {
+        final int originalPosition = target.position();
+        final int n = super.read(target);
+        if (n != EOF) {
+            // Appending can only be done after resetting the CharBuffer to the
+            // right position and limit.
+            final int newPosition = target.position();
+            final int newLimit = target.limit();
+            try {
+                target.position(originalPosition).limit(newPosition);
+                branch.append(target);
+            } finally {
+                // Reset the CharBuffer as if the appending never happened.
+                target.position(newPosition).limit(newLimit);
+            }
+        }
+        return n;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/TimestampedObserver.java b/src/main/java/org/apache/commons/io/input/TimestampedObserver.java
new file mode 100644
index 0000000..df18a6c
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/TimestampedObserver.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.input;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+
+import org.apache.commons.io.input.ObservableInputStream.Observer;
+
+/**
+ * An observer with timestamps.
+ * <p>
+ * For example:
+ * </p>
+ *
+ * <pre>
+ * final TimestampedObserver timetampedObserver = new TimestampedObserver();
+ * try (ObservableInputStream inputStream = new ObservableInputStream(...),
+ *     timetampedObserver)) {
+ *     ...
+ * }
+ * System.out.printf("IO duration: %s%n", timetampedObserver.getOpenToCloseDuration());
+ * </pre>
+ *
+ * @since 2.9.0
+ */
+public class TimestampedObserver extends Observer {
+
+    private volatile Instant closeInstant;
+    private final Instant openInstant = Instant.now();
+
+    @Override
+    public void closed() throws IOException {
+        closeInstant = Instant.now();
+    }
+
+    /**
+     * Gets the instant for when this instance was closed.
+     *
+     * @return the instant for when closed was called.
+     */
+    public Instant getCloseInstant() {
+        return closeInstant;
+    }
+
+    /**
+     * Gets the instant for when this instance was created.
+     *
+     * @return the instant for when this instance was created.
+     */
+    public Instant getOpenInstant() {
+        return openInstant;
+    }
+
+    /**
+     * Gets the Duration between creation and close.
+     *
+     * @return the Duration between creation and close.
+     */
+    public Duration getOpenToCloseDuration() {
+        return Duration.between(openInstant, closeInstant);
+    }
+
+    /**
+     * Gets the Duration between creation and now.
+     *
+     * @return the Duration between creation and now.
+     */
+    public Duration getOpenToNowDuration() {
+        return Duration.between(openInstant, Instant.now());
+    }
+
+    /**
+     * Tests whether {@link #closed()} has been called.
+     *
+     * @return whether {@link #closed()} has been called.
+     * @since 2.12.0
+     */
+    public boolean isClosed() {
+        return closeInstant != null;
+    }
+
+    @Override
+    public String toString() {
+        return "TimestampedObserver [openInstant=" + openInstant + ", closeInstant=" + closeInstant + "]";
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/UncheckedBufferedReader.java b/src/main/java/org/apache/commons/io/input/UncheckedBufferedReader.java
new file mode 100644
index 0000000..6664f5a
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/UncheckedBufferedReader.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.input;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.Reader;
+import java.io.UncheckedIOException;
+import java.nio.CharBuffer;
+
+import org.apache.commons.io.function.Uncheck;
+
+/**
+ * A {@link BufferedReader} that throws {@link UncheckedIOException} instead of {@link IOException}.
+ *
+ * @see BufferedReader
+ * @see IOException
+ * @see UncheckedIOException
+ * @since 2.12.0
+ */
+public class UncheckedBufferedReader extends BufferedReader {
+
+    /**
+     * Creates a new buffered reader.
+     *
+     * @param reader a Reader object providing the underlying stream.
+     * @return a new UncheckedBufferedReader.
+     * @throws NullPointerException if {@code reader} is {@code null}.
+     */
+    public static UncheckedBufferedReader on(final Reader reader) {
+        return new UncheckedBufferedReader(reader);
+    }
+
+    /**
+     * Creates a buffering character-input stream that uses a default-sized input buffer.
+     *
+     * @param reader A Reader
+     */
+    public UncheckedBufferedReader(final Reader reader) {
+        super(reader);
+    }
+
+    /**
+     * Creates a buffering character-input stream that uses an input buffer of the specified size.
+     *
+     * @param reader     A Reader
+     * @param bufferSize Input-buffer size
+     *
+     * @throws IllegalArgumentException If {@code bufferSize <= 0}
+     */
+    public UncheckedBufferedReader(final Reader reader, final int bufferSize) {
+        super(reader, bufferSize);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void close() throws UncheckedIOException {
+        Uncheck.run(super::close);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void mark(final int readAheadLimit) throws UncheckedIOException {
+        Uncheck.accept(super::mark, readAheadLimit);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public int read() throws UncheckedIOException {
+        return Uncheck.get(super::read);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public int read(final char[] cbuf) throws UncheckedIOException {
+        return Uncheck.apply(super::read, cbuf);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public int read(final char[] cbuf, final int off, final int len) throws UncheckedIOException {
+        return Uncheck.apply(super::read, cbuf, off, len);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public int read(final CharBuffer target) throws UncheckedIOException {
+        return Uncheck.apply(super::read, target);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public String readLine() throws UncheckedIOException {
+        return Uncheck.get(super::readLine);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public boolean ready() throws UncheckedIOException {
+        return Uncheck.get(super::ready);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void reset() throws UncheckedIOException {
+        Uncheck.run(super::reset);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public long skip(final long n) throws UncheckedIOException {
+        return Uncheck.apply(super::skip, n);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/UncheckedFilterInputStream.java b/src/main/java/org/apache/commons/io/input/UncheckedFilterInputStream.java
new file mode 100644
index 0000000..f617e93
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/UncheckedFilterInputStream.java
@@ -0,0 +1,116 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ */
+
+package org.apache.commons.io.input;
+
+import java.io.BufferedReader;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+
+import org.apache.commons.io.function.Uncheck;
+
+/**
+ * A {@link BufferedReader} that throws {@link UncheckedIOException} instead of {@link IOException}.
+ *
+ * @see BufferedReader
+ * @see IOException
+ * @see UncheckedIOException
+ * @since 2.12.0
+ */
+public class UncheckedFilterInputStream extends FilterInputStream {
+
+    /**
+     * Creates a {@link UncheckedFilterInputStream}.
+     *
+     * @param inputStream the underlying input stream, or {@code null} if this instance is to be created without an
+     *        underlying stream.
+     * @return a new UncheckedFilterInputStream.
+     */
+    public static UncheckedFilterInputStream on(final InputStream inputStream) {
+        return new UncheckedFilterInputStream(inputStream);
+    }
+
+    /**
+     * Creates a {@link UncheckedFilterInputStream}.
+     *
+     * @param inputStream the underlying input stream, or {@code null} if this instance is to be created without an
+     *        underlying stream.
+     */
+    public UncheckedFilterInputStream(final InputStream inputStream) {
+        super(inputStream);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public int available() throws UncheckedIOException {
+        return Uncheck.get(super::available);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void close() throws UncheckedIOException {
+        Uncheck.run(super::close);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public int read() throws UncheckedIOException {
+        return Uncheck.get(super::read);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public int read(final byte[] b) throws UncheckedIOException {
+        return Uncheck.apply(super::read, b);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public int read(final byte[] b, final int off, final int len) throws UncheckedIOException {
+        return Uncheck.apply(super::read, b, off, len);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public synchronized void reset() throws UncheckedIOException {
+        Uncheck.run(super::reset);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public long skip(final long n) throws UncheckedIOException {
+        return Uncheck.apply(super::skip, n);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/UncheckedFilterReader.java b/src/main/java/org/apache/commons/io/input/UncheckedFilterReader.java
new file mode 100644
index 0000000..794630a
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/UncheckedFilterReader.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.input;
+
+import java.io.FilterReader;
+import java.io.IOException;
+import java.io.Reader;
+import java.io.UncheckedIOException;
+import java.nio.CharBuffer;
+
+import org.apache.commons.io.function.Uncheck;
+
+/**
+ * A {@link FilterReader} that throws {@link UncheckedIOException} instead of {@link IOException}.
+ *
+ * @see FilterReader
+ * @see IOException
+ * @see UncheckedIOException
+ * @since 2.12.0
+ */
+public class UncheckedFilterReader extends FilterReader {
+
+    /**
+     * Creates a new filtered reader.
+     *
+     * @param reader a Reader object providing the underlying stream.
+     * @return a new UncheckedFilterReader.
+     * @throws NullPointerException if {@code reader} is {@code null}.
+     */
+    public static UncheckedFilterReader on(final Reader reader) {
+        return new UncheckedFilterReader(reader);
+    }
+
+    /**
+     * Creates a new filtered reader.
+     *
+     * @param reader a Reader object providing the underlying stream.
+     * @throws NullPointerException if {@code reader} is {@code null}.
+     */
+    public UncheckedFilterReader(final Reader reader) {
+        super(reader);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void close() throws UncheckedIOException {
+        Uncheck.run(super::close);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void mark(final int readAheadLimit) throws UncheckedIOException {
+        Uncheck.accept(super::mark, readAheadLimit);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public int read() throws UncheckedIOException {
+        return Uncheck.get(super::read);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public int read(final char[] cbuf) throws UncheckedIOException {
+        return Uncheck.apply(super::read, cbuf);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public int read(final char[] cbuf, final int off, final int len) throws UncheckedIOException {
+        return Uncheck.apply(super::read, cbuf, off, len);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public int read(final CharBuffer target) throws UncheckedIOException {
+        return Uncheck.apply(super::read, target);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public boolean ready() throws UncheckedIOException {
+        return Uncheck.get(super::ready);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void reset() throws UncheckedIOException {
+        Uncheck.run(super::reset);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public long skip(final long n) throws UncheckedIOException {
+        return Uncheck.apply(super::skip, n);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/UnixLineEndingInputStream.java b/src/main/java/org/apache/commons/io/input/UnixLineEndingInputStream.java
new file mode 100644
index 0000000..40dd692
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/UnixLineEndingInputStream.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.CR;
+import static org.apache.commons.io.IOUtils.EOF;
+import static org.apache.commons.io.IOUtils.LF;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * A filtering input stream that ensures the content will have UNIX-style line endings, LF.
+ *
+ * @since 2.5
+ */
+public class UnixLineEndingInputStream extends InputStream {
+
+    private boolean slashNSeen;
+
+    private boolean slashRSeen;
+
+    private boolean eofSeen;
+
+    private final InputStream target;
+
+    private final boolean ensureLineFeedAtEndOfFile;
+
+    /**
+     * Creates an input stream that filters another stream
+     *
+     * @param in                        The input stream to wrap
+     * @param ensureLineFeedAtEndOfFile true to ensure that the file ends with LF
+     */
+    public UnixLineEndingInputStream(final InputStream in, final boolean ensureLineFeedAtEndOfFile) {
+        this.target = in;
+        this.ensureLineFeedAtEndOfFile = ensureLineFeedAtEndOfFile;
+    }
+
+    /**
+     * Closes the stream. Also closes the underlying stream.
+     * @throws IOException upon error
+     */
+    @Override
+    public void close() throws IOException {
+        super.close();
+        target.close();
+    }
+
+    /**
+     * Handles the EOF-handling at the end of the stream
+     * @param previousWasSlashR Indicates if the last seen was a \r
+     * @return The next char to output to the stream
+     */
+    private int eofGame(final boolean previousWasSlashR) {
+        if (previousWasSlashR || !ensureLineFeedAtEndOfFile) {
+            return EOF;
+        }
+        if (!slashNSeen) {
+            slashNSeen = true;
+            return LF;
+        }
+        return EOF;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public synchronized void mark(final int readlimit) {
+        throw UnsupportedOperationExceptions.mark();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public int read() throws IOException {
+        final boolean previousWasSlashR = slashRSeen;
+        if (eofSeen) {
+            return eofGame(previousWasSlashR);
+        }
+        final int target = readWithUpdate();
+        if (eofSeen) {
+            return eofGame(previousWasSlashR);
+        }
+        if (slashRSeen) {
+            return LF;
+        }
+
+        if (previousWasSlashR && slashNSeen) {
+            return read();
+        }
+
+        return target;
+    }
+
+    /**
+     * Reads the next item from the target, updating internal flags in the process
+     * @return the next int read from the target stream
+     * @throws IOException upon error
+     */
+    private int readWithUpdate() throws IOException {
+        final int target = this.target.read();
+        eofSeen = target == EOF;
+        if (eofSeen) {
+            return target;
+        }
+        slashNSeen = target == LF;
+        slashRSeen = target == CR;
+        return target;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/UnsupportedOperationExceptions.java b/src/main/java/org/apache/commons/io/input/UnsupportedOperationExceptions.java
new file mode 100644
index 0000000..9799e99
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/UnsupportedOperationExceptions.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.input;
+
+/**
+ * Package-private factory for {@link UnsupportedOperationException} to provide messages with consistent formatting.
+ *
+ * <p>
+ * TODO Consider making this public and use from LineIterator but this feels like it belongs in LANG rather than IO.
+ * </p>
+ */
+class UnsupportedOperationExceptions {
+
+    private static final String MARK_RESET = "mark/reset";
+
+    /**
+     * Creates a new instance of UnsupportedOperationException for a {@code mark} method.
+     *
+     * @return a new instance of UnsupportedOperationException
+     */
+    static UnsupportedOperationException mark() {
+        // Use the same message as in java.io.InputStream.reset() in OpenJDK 8.0.275-1.
+        return method(MARK_RESET);
+    }
+
+    /**
+     * Creates a new instance of UnsupportedOperationException for the given unsupported a {@code method} name.
+     *
+     * @param method A method name
+     * @return a new instance of UnsupportedOperationException
+     */
+    static UnsupportedOperationException method(final String method) {
+        return new UnsupportedOperationException(method + " not supported");
+    }
+
+    /**
+     * Creates a new instance of UnsupportedOperationException for a {@code reset} method.
+     *
+     * @return a new instance of UnsupportedOperationException
+     */
+    static UnsupportedOperationException reset() {
+        // Use the same message as in java.io.InputStream.reset() in OpenJDK 8.0.275-1.
+        return method(MARK_RESET);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/UnsynchronizedBufferedInputStream.java b/src/main/java/org/apache/commons/io/input/UnsynchronizedBufferedInputStream.java
new file mode 100644
index 0000000..2352d9e
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/UnsynchronizedBufferedInputStream.java
@@ -0,0 +1,375 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+
+package org.apache.commons.io.input;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.apache.commons.io.IOUtils;
+
+/**
+ * An unsynchronized version of {@link BufferedInputStream}, not thread-safe.
+ * <p>
+ * Wraps an existing {@link InputStream} and <em>buffers</em> the input. Expensive interaction with the underlying input stream is minimized, since most
+ * (smaller) requests can be satisfied by accessing the buffer alone. The drawback is that some extra space is required to hold the buffer and that copying
+ * takes place when filling that buffer, but this is usually outweighed by the performance benefits.
+ * </p>
+ * <p>
+ * A typical application pattern for the class looks like this:
+ * </p>
+ *
+ * <pre>
+ * UnsynchronizedBufferedInputStream buf = new UnsynchronizedBufferedInputStream(new FileInputStream(&quot;file.java&quot;));
+ * </pre>
+ * <p>
+ * Provenance: Apache Harmony and modified.
+ * </p>
+ *
+ * @see BufferedInputStream
+ * @since 2.12.0
+ */
+//@NotThreadSafe
+public class UnsynchronizedBufferedInputStream extends UnsynchronizedFilterInputStream {
+    /**
+     * The buffer containing the current bytes read from the target InputStream.
+     */
+    protected volatile byte[] buf;
+
+    /**
+     * The total number of bytes inside the byte array {@code buf}.
+     */
+    protected int count;
+
+    /**
+     * The current limit, which when passed, invalidates the current mark.
+     */
+    protected int marklimit;
+
+    /**
+     * The currently marked position. -1 indicates no mark has been set or the mark has been invalidated.
+     */
+    protected int markpos = IOUtils.EOF;
+
+    /**
+     * The current position within the byte array {@code buf}.
+     */
+    protected int pos;
+
+    /**
+     * Constructs a new {@code BufferedInputStream} on the {@link InputStream} {@code in}. The default buffer size (8 KB) is allocated and all reads can now be
+     * filtered through this stream.
+     *
+     * @param in the InputStream the buffer reads from.
+     */
+    public UnsynchronizedBufferedInputStream(final InputStream in) {
+        super(in);
+        buf = new byte[IOUtils.DEFAULT_BUFFER_SIZE];
+    }
+
+    /**
+     * Constructs a new {@code BufferedInputStream} on the {@link InputStream} {@code in}. The buffer size is specified by the parameter {@code size} and all
+     * reads are now filtered through this stream.
+     *
+     * @param in   the input stream the buffer reads from.
+     * @param size the size of buffer to allocate.
+     * @throws IllegalArgumentException if {@code size < 0}.
+     */
+    public UnsynchronizedBufferedInputStream(final InputStream in, final int size) {
+        super(in);
+        if (size <= 0) {
+            throw new IllegalArgumentException("Size must be > 0");
+        }
+        buf = new byte[size];
+    }
+
+    /**
+     * Returns the number of bytes that are available before this stream will block. This method returns the number of bytes available in the buffer plus those
+     * available in the source stream.
+     *
+     * @return the number of bytes available before blocking.
+     * @throws IOException if this stream is closed.
+     */
+    @Override
+    public int available() throws IOException {
+        final InputStream localIn = in; // 'in' could be invalidated by close()
+        if (buf == null || localIn == null) {
+            throw new IOException("Stream is closed");
+        }
+        return count - pos + localIn.available();
+    }
+
+    /**
+     * Closes this stream. The source stream is closed and any resources associated with it are released.
+     *
+     * @throws IOException if an error occurs while closing this stream.
+     */
+    @Override
+    public void close() throws IOException {
+        buf = null;
+        final InputStream localIn = in;
+        in = null;
+        if (localIn != null) {
+            localIn.close();
+        }
+    }
+
+    private int fillbuf(final InputStream localIn, byte[] localBuf) throws IOException {
+        if (markpos == IOUtils.EOF || pos - markpos >= marklimit) {
+            /* Mark position not set or exceeded readlimit */
+            final int result = localIn.read(localBuf);
+            if (result > 0) {
+                markpos = IOUtils.EOF;
+                pos = 0;
+                count = result;
+            }
+            return result;
+        }
+        if (markpos == 0 && marklimit > localBuf.length) {
+            /* Increase buffer size to accommodate the readlimit */
+            int newLength = localBuf.length * 2;
+            if (newLength > marklimit) {
+                newLength = marklimit;
+            }
+            final byte[] newbuf = new byte[newLength];
+            System.arraycopy(localBuf, 0, newbuf, 0, localBuf.length);
+            // Reassign buf, which will invalidate any local references
+            // FIXME: what if buf was null?
+            localBuf = buf = newbuf;
+        } else if (markpos > 0) {
+            System.arraycopy(localBuf, markpos, localBuf, 0, localBuf.length - markpos);
+        }
+        /* Set the new position and mark position */
+        pos -= markpos;
+        count = markpos = 0;
+        final int bytesread = localIn.read(localBuf, pos, localBuf.length - pos);
+        count = bytesread <= 0 ? pos : pos + bytesread;
+        return bytesread;
+    }
+
+    /**
+     * Sets a mark position in this stream. The parameter {@code readlimit} indicates how many bytes can be read before a mark is invalidated. Calling
+     * {@code reset()} will reposition the stream back to the marked position if {@code readlimit} has not been surpassed. The underlying buffer may be
+     * increased in size to allow {@code readlimit} number of bytes to be supported.
+     *
+     * @param readlimit the number of bytes that can be read before the mark is invalidated.
+     * @see #reset()
+     */
+    @Override
+    public void mark(final int readlimit) {
+        marklimit = readlimit;
+        markpos = pos;
+    }
+
+    /**
+     * Indicates whether {@code BufferedInputStream} supports the {@code mark()} and {@code reset()} methods.
+     *
+     * @return {@code true} for BufferedInputStreams.
+     * @see #mark(int)
+     * @see #reset()
+     */
+    @Override
+    public boolean markSupported() {
+        return true;
+    }
+
+    /**
+     * Reads a single byte from this stream and returns it as an integer in the range from 0 to 255. Returns -1 if the end of the source string has been
+     * reached. If the internal buffer does not contain any available bytes then it is filled from the source stream and the first byte is returned.
+     *
+     * @return the byte read or -1 if the end of the source stream has been reached.
+     * @throws IOException if this stream is closed or another IOException occurs.
+     */
+    @Override
+    public int read() throws IOException {
+        // Use local refs since buf and in may be invalidated by an
+        // unsynchronized close()
+        byte[] localBuf = buf;
+        final InputStream localIn = in;
+        if (localBuf == null || localIn == null) {
+            throw new IOException("Stream is closed");
+        }
+
+        /* Are there buffered bytes available? */
+        if (pos >= count && fillbuf(localIn, localBuf) == IOUtils.EOF) {
+            return IOUtils.EOF; /* no, fill buffer */
+        }
+        // localBuf may have been invalidated by fillbuf
+        if (localBuf != buf) {
+            localBuf = buf;
+            if (localBuf == null) {
+                throw new IOException("Stream is closed");
+            }
+        }
+
+        /* Did filling the buffer fail with -1 (EOF)? */
+        if (count - pos > 0) {
+            return localBuf[pos++] & 0xFF;
+        }
+        return IOUtils.EOF;
+    }
+
+    /**
+     * Reads at most {@code length} bytes from this stream and stores them in byte array {@code buffer} starting at offset {@code offset}. Returns the number of
+     * bytes actually read or -1 if no bytes were read and the end of the stream was encountered. If all the buffered bytes have been used, a mark has not been
+     * set and the requested number of bytes is larger than the receiver's buffer size, this implementation bypasses the buffer and simply places the results
+     * directly into {@code buffer}.
+     *
+     * @param buffer the byte array in which to store the bytes read.
+     * @param offset the initial position in {@code buffer} to store the bytes read from this stream.
+     * @param length the maximum number of bytes to store in {@code buffer}.
+     * @return the number of bytes actually read or -1 if end of stream.
+     * @throws IndexOutOfBoundsException if {@code offset < 0} or {@code length < 0}, or if {@code offset + length} is greater than the size of {@code buffer}.
+     * @throws IOException               if the stream is already closed or another IOException occurs.
+     */
+    @Override
+    public int read(final byte[] buffer, int offset, final int length) throws IOException {
+        // Use local ref since buf may be invalidated by an unsynchronized
+        // close()
+        byte[] localBuf = buf;
+        if (localBuf == null) {
+            throw new IOException("Stream is closed");
+        }
+        // avoid int overflow
+        if (offset > buffer.length - length || offset < 0 || length < 0) {
+            throw new IndexOutOfBoundsException();
+        }
+        if (length == 0) {
+            return 0;
+        }
+        final InputStream localIn = in;
+        if (localIn == null) {
+            throw new IOException("Stream is closed");
+        }
+
+        int required;
+        if (pos < count) {
+            /* There are bytes available in the buffer. */
+            final int copylength = count - pos >= length ? length : count - pos;
+            System.arraycopy(localBuf, pos, buffer, offset, copylength);
+            pos += copylength;
+            if (copylength == length || localIn.available() == 0) {
+                return copylength;
+            }
+            offset += copylength;
+            required = length - copylength;
+        } else {
+            required = length;
+        }
+
+        while (true) {
+            final int read;
+            /*
+             * If we're not marked and the required size is greater than the buffer, simply read the bytes directly bypassing the buffer.
+             */
+            if (markpos == IOUtils.EOF && required >= localBuf.length) {
+                read = localIn.read(buffer, offset, required);
+                if (read == IOUtils.EOF) {
+                    return required == length ? IOUtils.EOF : length - required;
+                }
+            } else {
+                if (fillbuf(localIn, localBuf) == IOUtils.EOF) {
+                    return required == length ? IOUtils.EOF : length - required;
+                }
+                // localBuf may have been invalidated by fillbuf
+                if (localBuf != buf) {
+                    localBuf = buf;
+                    if (localBuf == null) {
+                        throw new IOException("Stream is closed");
+                    }
+                }
+
+                read = count - pos >= required ? required : count - pos;
+                System.arraycopy(localBuf, pos, buffer, offset, read);
+                pos += read;
+            }
+            required -= read;
+            if (required == 0) {
+                return length;
+            }
+            if (localIn.available() == 0) {
+                return length - required;
+            }
+            offset += read;
+        }
+    }
+
+    /**
+     * Resets this stream to the last marked location.
+     *
+     * @throws IOException if this stream is closed, no mark has been set or the mark is no longer valid because more than {@code readlimit} bytes have been
+     *                     read since setting the mark.
+     * @see #mark(int)
+     */
+    @Override
+    public void reset() throws IOException {
+        if (buf == null) {
+            throw new IOException("Stream is closed");
+        }
+        if (IOUtils.EOF == markpos) {
+            throw new IOException("Mark has been invalidated");
+        }
+        pos = markpos;
+    }
+
+    /**
+     * Skips {@code amount} number of bytes in this stream. Subsequent {@code read()}'s will not return these bytes unless {@code reset()} is used.
+     *
+     * @param amount the number of bytes to skip. {@code skip} does nothing and returns 0 if {@code amount} is less than zero.
+     * @return the number of bytes actually skipped.
+     * @throws IOException if this stream is closed or another IOException occurs.
+     */
+    @Override
+    public long skip(final long amount) throws IOException {
+        // Use local refs since buf and in may be invalidated by an
+        // unsynchronized close()
+        final byte[] localBuf = buf;
+        final InputStream localIn = in;
+        if (localBuf == null) {
+            throw new IOException("Stream is closed");
+        }
+        if (amount < 1) {
+            return 0;
+        }
+        if (localIn == null) {
+            throw new IOException("Stream is closed");
+        }
+
+        if (count - pos >= amount) {
+            pos += amount;
+            return amount;
+        }
+        long read = count - pos;
+        pos = count;
+
+        if (markpos != IOUtils.EOF && amount <= marklimit) {
+            if (fillbuf(localIn, localBuf) == IOUtils.EOF) {
+                return read;
+            }
+            if (count - pos >= amount - read) {
+                pos += amount - read;
+                return amount;
+            }
+            // Couldn't get all the bytes, skip what we read
+            read += count - pos;
+            pos = count;
+            return read;
+        }
+        return read + localIn.skip(amount - read);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/UnsynchronizedByteArrayInputStream.java b/src/main/java/org/apache/commons/io/input/UnsynchronizedByteArrayInputStream.java
new file mode 100644
index 0000000..cd90b8c
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/UnsynchronizedByteArrayInputStream.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static java.lang.Math.min;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.util.Objects;
+
+/**
+ * This is an alternative to {@link java.io.ByteArrayInputStream}
+ * which removes the synchronization overhead for non-concurrent
+ * access; as such this class is not thread-safe.
+ *
+ * @see ByteArrayInputStream
+ * @since 2.7
+ */
+//@NotThreadSafe
+public class UnsynchronizedByteArrayInputStream extends InputStream {
+
+    /**
+     * The end of stream marker.
+     */
+    public static final int END_OF_STREAM = -1;
+
+    /**
+     * The underlying data buffer.
+     */
+    private final byte[] data;
+
+    /**
+     * End Of Data.
+     *
+     * Similar to data.length,
+     * i.e. the last readable offset + 1.
+     */
+    private final int eod;
+
+    /**
+     * Current offset in the data buffer.
+     */
+    private int offset;
+
+    /**
+     * The current mark (if any).
+     */
+    private int markedOffset;
+
+    /**
+     * Creates a new byte array input stream.
+     *
+     * @param data the buffer
+     */
+    public UnsynchronizedByteArrayInputStream(final byte[] data) {
+        this.data = Objects.requireNonNull(data, "data");
+        this.offset = 0;
+        this.eod = data.length;
+        this.markedOffset = this.offset;
+    }
+
+    /**
+     * Creates a new byte array input stream.
+     *
+     * @param data the buffer
+     * @param offset the offset into the buffer
+     *
+     * @throws IllegalArgumentException if the offset is less than zero
+     */
+    public UnsynchronizedByteArrayInputStream(final byte[] data, final int offset) {
+        Objects.requireNonNull(data, "data");
+        if (offset < 0) {
+            throw new IllegalArgumentException("offset cannot be negative");
+        }
+        this.data = data;
+        this.offset = min(offset, data.length > 0 ? data.length : offset);
+        this.eod = data.length;
+        this.markedOffset = this.offset;
+    }
+
+
+    /**
+     * Creates a new byte array input stream.
+     *
+     * @param data the buffer
+     * @param offset the offset into the buffer
+     * @param length the length of the buffer
+     *
+     * @throws IllegalArgumentException if the offset or length less than zero
+     */
+    public UnsynchronizedByteArrayInputStream(final byte[] data, final int offset, final int length) {
+        if (offset < 0) {
+            throw new IllegalArgumentException("offset cannot be negative");
+        }
+        if (length < 0) {
+            throw new IllegalArgumentException("length cannot be negative");
+        }
+        this.data = Objects.requireNonNull(data, "data");
+        this.offset = min(offset, data.length > 0 ? data.length : offset);
+        this.eod = min(this.offset + length, data.length);
+        this.markedOffset = this.offset;
+    }
+
+    @Override
+    public int available() {
+        return offset < eod ? eod - offset : 0;
+    }
+
+    @SuppressWarnings("sync-override")
+    @Override
+    public void mark(final int readlimit) {
+        this.markedOffset = this.offset;
+    }
+
+    @Override
+    public boolean markSupported() {
+        return true;
+    }
+
+    @Override
+    public int read() {
+        return offset < eod ? data[offset++] & 0xff : END_OF_STREAM;
+    }
+
+    @Override
+    public int read(final byte[] dest) {
+        Objects.requireNonNull(dest, "dest");
+        return read(dest, 0, dest.length);
+    }
+
+    @Override
+    public int read(final byte[] dest, final int off, final int len) {
+        Objects.requireNonNull(dest, "dest");
+        if (off < 0 || len < 0 || off + len > dest.length) {
+            throw new IndexOutOfBoundsException();
+        }
+
+        if (offset >= eod) {
+            return END_OF_STREAM;
+        }
+
+        int actualLen = eod - offset;
+        if (len < actualLen) {
+            actualLen = len;
+        }
+        if (actualLen <= 0) {
+            return 0;
+        }
+        System.arraycopy(data, offset, dest, off, actualLen);
+        offset += actualLen;
+        return actualLen;
+    }
+
+    @SuppressWarnings("sync-override")
+    @Override
+    public void reset() {
+        this.offset = this.markedOffset;
+    }
+
+    @Override
+    public long skip(final long n) {
+        if (n < 0) {
+            throw new IllegalArgumentException("Skipping backward is not supported");
+        }
+
+        long actualSkip = eod - offset;
+        if (n < actualSkip) {
+            actualSkip = n;
+        }
+
+        offset = Math.addExact(offset, Math.toIntExact(n));
+        return actualSkip;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/UnsynchronizedFilterInputStream.java b/src/main/java/org/apache/commons/io/input/UnsynchronizedFilterInputStream.java
new file mode 100644
index 0000000..b76c668
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/UnsynchronizedFilterInputStream.java
@@ -0,0 +1,175 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+
+package org.apache.commons.io.input;
+
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * An unsynchronized version of {@link FilterInputStream}, not thread-safe.
+ * <p>
+ * Wraps an existing {@link InputStream} and performs some transformation on the input data while it is being read. Transformations can be anything from a
+ * simple byte-wise filtering input data to an on-the-fly compression or decompression of the underlying stream. Input streams that wrap another input stream
+ * and provide some additional functionality on top of it usually inherit from this class.
+ * </p>
+ * <p>
+ * Provenance: Apache Harmony and modified.
+ * </p>
+ *
+ * @see FilterInputStream
+ * @since 2.12.0
+ */
+//@NotThreadSafe
+public class UnsynchronizedFilterInputStream extends InputStream {
+
+    /**
+     * The source input stream that is filtered.
+     */
+    protected volatile InputStream in;
+
+    /**
+     * Constructs a new {@code FilterInputStream} with the specified input stream as source.
+     *
+     * @param in the non-null InputStream to filter reads on.
+     */
+    protected UnsynchronizedFilterInputStream(final InputStream in) {
+        this.in = in;
+    }
+
+    /**
+     * Returns the number of bytes that are available before this stream will block.
+     *
+     * @return the number of bytes available before blocking.
+     * @throws IOException if an error occurs in this stream.
+     */
+    @Override
+    public int available() throws IOException {
+        return in.available();
+    }
+
+    /**
+     * Closes this stream. This implementation closes the filtered stream.
+     *
+     * @throws IOException if an error occurs while closing this stream.
+     */
+    @Override
+    public void close() throws IOException {
+        in.close();
+    }
+
+    /**
+     * Sets a mark position in this stream. The parameter {@code readlimit} indicates how many bytes can be read before the mark is invalidated. Sending
+     * {@code reset()} will reposition this stream back to the marked position, provided that {@code readlimit} has not been surpassed.
+     * <p>
+     * This implementation sets a mark in the filtered stream.
+     *
+     * @param readlimit the number of bytes that can be read from this stream before the mark is invalidated.
+     * @see #markSupported()
+     * @see #reset()
+     */
+    @SuppressWarnings("sync-override") // by design.
+    @Override
+    public void mark(final int readlimit) {
+        in.mark(readlimit);
+    }
+
+    /**
+     * Indicates whether this stream supports {@code mark()} and {@code reset()}. This implementation returns whether or not the filtered stream supports
+     * marking.
+     *
+     * @return {@code true} if {@code mark()} and {@code reset()} are supported, {@code false} otherwise.
+     * @see #mark(int)
+     * @see #reset()
+     * @see #skip(long)
+     */
+    @Override
+    public boolean markSupported() {
+        return in.markSupported();
+    }
+
+    /**
+     * Reads a single byte from the filtered stream and returns it as an integer in the range from 0 to 255. Returns -1 if the end of this stream has been
+     * reached.
+     *
+     * @return the byte read or -1 if the end of the filtered stream has been reached.
+     * @throws IOException if the stream is closed or another IOException occurs.
+     */
+    @Override
+    public int read() throws IOException {
+        return in.read();
+    }
+
+    /**
+     * Reads bytes from this stream and stores them in the byte array {@code buffer}. Returns the number of bytes actually read or -1 if no bytes were read and
+     * the end of this stream was encountered. This implementation reads bytes from the filtered stream.
+     *
+     * @param buffer the byte array in which to store the read bytes.
+     * @return the number of bytes actually read or -1 if the end of the filtered stream has been reached while reading.
+     * @throws IOException if this stream is closed or another IOException occurs.
+     */
+    @Override
+    public int read(final byte[] buffer) throws IOException {
+        return read(buffer, 0, buffer.length);
+    }
+
+    /**
+     * Reads at most {@code count} bytes from this stream and stores them in the byte array {@code buffer} starting at {@code offset}. Returns the number of
+     * bytes actually read or -1 if no bytes have been read and the end of this stream has been reached. This implementation reads bytes from the filtered
+     * stream.
+     *
+     * @param buffer the byte array in which to store the bytes read.
+     * @param offset the initial position in {@code buffer} to store the bytes read from this stream.
+     * @param count  the maximum number of bytes to store in {@code buffer}.
+     * @return the number of bytes actually read or -1 if the end of the filtered stream has been reached while reading.
+     * @throws IOException if this stream is closed or another I/O error occurs.
+     */
+    @Override
+    public int read(final byte[] buffer, final int offset, final int count) throws IOException {
+        return in.read(buffer, offset, count);
+    }
+
+    /**
+     * Resets this stream to the last marked location. This implementation resets the target stream.
+     *
+     * @throws IOException if this stream is already closed, no mark has been set or the mark is no longer valid because more than {@code readlimit} bytes have
+     *                     been read since setting the mark.
+     * @see #mark(int)
+     * @see #markSupported()
+     */
+    @SuppressWarnings("sync-override") // by design.
+    @Override
+    public void reset() throws IOException {
+        in.reset();
+    }
+
+    /**
+     * Skips {@code count} number of bytes in this stream. Subsequent {@code read()}'s will not return these bytes unless {@code reset()} is used. This
+     * implementation skips {@code count} number of bytes in the filtered stream.
+     *
+     * @param count the number of bytes to skip.
+     * @return the number of bytes actually skipped.
+     * @throws IOException if this stream is closed or another IOException occurs.
+     * @see #mark(int)
+     * @see #reset()
+     */
+    @Override
+    public long skip(final long count) throws IOException {
+        return in.skip(count);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/WindowsLineEndingInputStream.java b/src/main/java/org/apache/commons/io/input/WindowsLineEndingInputStream.java
new file mode 100644
index 0000000..e6ec30f
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/WindowsLineEndingInputStream.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.CR;
+import static org.apache.commons.io.IOUtils.EOF;
+import static org.apache.commons.io.IOUtils.LF;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * A filtering input stream that ensures the content will have Windows line endings, CRLF.
+ *
+ * @since 2.5
+ */
+public class WindowsLineEndingInputStream  extends InputStream {
+
+    private boolean slashRSeen;
+
+    private boolean slashNSeen;
+
+    private boolean injectSlashN;
+
+    private boolean eofSeen;
+
+    private final InputStream target;
+
+    private final boolean ensureLineFeedAtEndOfFile;
+
+    /**
+     * Creates an input stream that filters another stream
+     *
+     * @param in                        The input stream to wrap
+     * @param ensureLineFeedAtEndOfFile true to ensure that the file ends with CRLF
+     */
+    public WindowsLineEndingInputStream(final InputStream in, final boolean ensureLineFeedAtEndOfFile) {
+        this.target = in;
+        this.ensureLineFeedAtEndOfFile = ensureLineFeedAtEndOfFile;
+    }
+
+    /**
+     * Closes the stream. Also closes the underlying stream.
+     * @throws IOException upon error
+     */
+    @Override
+    public void close() throws IOException {
+        super.close();
+        target.close();
+    }
+
+    /**
+     * Handles the EOF-handling at the end of the stream
+     * @return The next char to output to the stream
+     */
+    private int eofGame() {
+        if (!ensureLineFeedAtEndOfFile) {
+            return EOF;
+        }
+        if (!slashNSeen && !slashRSeen) {
+            slashRSeen = true;
+            return CR;
+        }
+        if (!slashNSeen) {
+            slashRSeen = false;
+            slashNSeen = true;
+            return LF;
+        }
+        return EOF;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public synchronized void mark(final int readlimit) {
+        throw UnsupportedOperationExceptions.mark();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public int read() throws IOException {
+        if (eofSeen) {
+            return eofGame();
+        }
+        if (injectSlashN) {
+            injectSlashN = false;
+            return LF;
+        }
+        final boolean prevWasSlashR = slashRSeen;
+        final int target = readWithUpdate();
+        if (eofSeen) {
+            return eofGame();
+        }
+        if (target == LF && !prevWasSlashR) {
+            injectSlashN = true;
+            return CR;
+        }
+        return target;
+    }
+
+    /**
+     * Reads the next item from the target, updating internal flags in the process
+     * @return the next int read from the target stream
+     * @throws IOException upon error
+     */
+    private int readWithUpdate() throws IOException {
+        final int target = this.target.read();
+        eofSeen = target == EOF;
+        if (eofSeen) {
+            return target;
+        }
+        slashRSeen = target == CR;
+        slashNSeen = target == LF;
+        return target;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/XmlStreamReader.java b/src/main/java/org/apache/commons/io/input/XmlStreamReader.java
new file mode 100644
index 0000000..7ca6c19
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/XmlStreamReader.java
@@ -0,0 +1,863 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.StringReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLConnection;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.text.MessageFormat;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.commons.io.ByteOrderMark;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.function.IOConsumer;
+
+/**
+ * Character stream that handles all the necessary Voodoo to figure out the
+ * charset encoding of the XML document within the stream.
+ * <p>
+ * IMPORTANT: This class is not related in any way to the org.xml.sax.XMLReader.
+ * This one IS a character stream.
+ * </p>
+ * <p>
+ * All this has to be done without consuming characters from the stream, if not
+ * the XML parser will not recognized the document as a valid XML. This is not
+ * 100% true, but it's close enough (UTF-8 BOM is not handled by all parsers
+ * right now, XmlStreamReader handles it and things work in all parsers).
+ * </p>
+ * <p>
+ * The XmlStreamReader class handles the charset encoding of XML documents in
+ * Files, raw streams and HTTP streams by offering a wide set of constructors.
+ * </p>
+ * <p>
+ * By default the charset encoding detection is lenient, the constructor with
+ * the lenient flag can be used for a script (following HTTP MIME and XML
+ * specifications). All this is nicely explained by Mark Pilgrim in his blog, <a
+ * href="http://diveintomark.org/archives/2004/02/13/xml-media-types">
+ * Determining the character encoding of a feed</a>.
+ * </p>
+ * <p>
+ * Originally developed for <a href="http://rome.dev.java.net">ROME</a> under
+ * Apache License 2.0.
+ * </p>
+ *
+ * @see org.apache.commons.io.output.XmlStreamWriter
+ * @since 2.0
+ */
+public class XmlStreamReader extends Reader {
+
+    private static final String UTF_8 = StandardCharsets.UTF_8.name();
+
+    private static final String US_ASCII = StandardCharsets.US_ASCII.name();
+
+    private static final String UTF_16BE = StandardCharsets.UTF_16BE.name();
+
+    private static final String UTF_16LE = StandardCharsets.UTF_16LE.name();
+
+    private static final String UTF_32BE = "UTF-32BE";
+
+    private static final String UTF_32LE = "UTF-32LE";
+
+    private static final String UTF_16 = StandardCharsets.UTF_16.name();
+
+    private static final String UTF_32 = "UTF-32";
+
+    private static final String EBCDIC = "CP1047";
+
+    private static final ByteOrderMark[] BOMS = {
+        ByteOrderMark.UTF_8,
+        ByteOrderMark.UTF_16BE,
+        ByteOrderMark.UTF_16LE,
+        ByteOrderMark.UTF_32BE,
+        ByteOrderMark.UTF_32LE
+    };
+
+    /** UTF_16LE and UTF_32LE have the same two starting BOM bytes. */
+    private static final ByteOrderMark[] XML_GUESS_BYTES = {
+        new ByteOrderMark(UTF_8,    0x3C, 0x3F, 0x78, 0x6D),
+        new ByteOrderMark(UTF_16BE, 0x00, 0x3C, 0x00, 0x3F),
+        new ByteOrderMark(UTF_16LE, 0x3C, 0x00, 0x3F, 0x00),
+        new ByteOrderMark(UTF_32BE, 0x00, 0x00, 0x00, 0x3C,
+                0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, 0x6D),
+        new ByteOrderMark(UTF_32LE, 0x3C, 0x00, 0x00, 0x00,
+                0x3F, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, 0x6D, 0x00, 0x00, 0x00),
+        new ByteOrderMark(EBCDIC,   0x4C, 0x6F, 0xA7, 0x94)
+    };
+
+    private static final Pattern CHARSET_PATTERN = Pattern
+            .compile("charset=[\"']?([.[^; \"']]*)[\"']?");
+
+    /**
+     * Pattern capturing the encoding of the "xml" processing instruction.
+     */
+    public static final Pattern ENCODING_PATTERN = Pattern.compile(
+            "<\\?xml.*encoding[\\s]*=[\\s]*((?:\".[^\"]*\")|(?:'.[^']*'))",
+            Pattern.MULTILINE);
+
+    private static final String RAW_EX_1 =
+        "Invalid encoding, BOM [{0}] XML guess [{1}] XML prolog [{2}] encoding mismatch";
+
+    private static final String RAW_EX_2 =
+        "Invalid encoding, BOM [{0}] XML guess [{1}] XML prolog [{2}] unknown BOM";
+
+    private static final String HTTP_EX_1 =
+        "Invalid encoding, CT-MIME [{0}] CT-Enc [{1}] BOM [{2}] XML guess [{3}] XML prolog [{4}], BOM must be NULL";
+
+    private static final String HTTP_EX_2 =
+        "Invalid encoding, CT-MIME [{0}] CT-Enc [{1}] BOM [{2}] XML guess [{3}] XML prolog [{4}], encoding mismatch";
+
+    private static final String HTTP_EX_3 =
+        "Invalid encoding, CT-MIME [{0}] CT-Enc [{1}] BOM [{2}] XML guess [{3}] XML prolog [{4}], Invalid MIME";
+
+    /**
+     * Gets the charset parameter value, NULL if not present, NULL if
+     * httpContentType is NULL.
+     *
+     * @param httpContentType the HTTP content type
+     * @return The content type encoding (upcased)
+     */
+    static String getContentTypeEncoding(final String httpContentType) {
+        String encoding = null;
+        if (httpContentType != null) {
+            final int i = httpContentType.indexOf(";");
+            if (i > -1) {
+                final String postMime = httpContentType.substring(i + 1);
+                final Matcher m = CHARSET_PATTERN.matcher(postMime);
+                encoding = m.find() ? m.group(1) : null;
+                encoding = encoding != null ? encoding.toUpperCase(Locale.ROOT) : null;
+            }
+        }
+        return encoding;
+    }
+
+    /**
+     * Gets the MIME type or NULL if httpContentType is NULL.
+     *
+     * @param httpContentType the HTTP content type
+     * @return The mime content type
+     */
+    static String getContentTypeMime(final String httpContentType) {
+        String mime = null;
+        if (httpContentType != null) {
+            final int i = httpContentType.indexOf(";");
+            if (i >= 0) {
+                mime = httpContentType.substring(0, i);
+            } else {
+                mime = httpContentType;
+            }
+            mime = mime.trim();
+        }
+        return mime;
+    }
+
+    /**
+     * Gets the encoding declared in the <?xml encoding=...?>, NULL if none.
+     *
+     * @param inputStream InputStream to create the reader from.
+     * @param guessedEnc guessed encoding
+     * @return the encoding declared in the <?xml encoding=...?>
+     * @throws IOException thrown if there is a problem reading the stream.
+     */
+    private static String getXmlProlog(final InputStream inputStream, final String guessedEnc)
+            throws IOException {
+        String encoding = null;
+        if (guessedEnc != null) {
+            final byte[] bytes = IOUtils.byteArray();
+            inputStream.mark(IOUtils.DEFAULT_BUFFER_SIZE);
+            int offset = 0;
+            int max = IOUtils.DEFAULT_BUFFER_SIZE;
+            int c = inputStream.read(bytes, offset, max);
+            int firstGT = -1;
+            String xmlProlog = ""; // avoid possible NPE warning (cannot happen; this just silences the warning)
+            while (c != -1 && firstGT == -1 && offset < IOUtils.DEFAULT_BUFFER_SIZE) {
+                offset += c;
+                max -= c;
+                c = inputStream.read(bytes, offset, max);
+                xmlProlog = new String(bytes, 0, offset, guessedEnc);
+                firstGT = xmlProlog.indexOf('>');
+            }
+            if (firstGT == -1) {
+                if (c == -1) {
+                    throw new IOException("Unexpected end of XML stream");
+                }
+                throw new IOException(
+                        "XML prolog or ROOT element not found on first "
+                                + offset + " bytes");
+            }
+            final int bytesRead = offset;
+            if (bytesRead > 0) {
+                inputStream.reset();
+                final BufferedReader bReader = new BufferedReader(new StringReader(xmlProlog.substring(0, firstGT + 1)));
+                final StringBuilder prolog = new StringBuilder();
+                IOConsumer.forEach(bReader.lines(), prolog::append);
+                final Matcher m = ENCODING_PATTERN.matcher(prolog);
+                if (m.find()) {
+                    encoding = m.group(1).toUpperCase(Locale.ROOT);
+                    encoding = encoding.substring(1, encoding.length() - 1);
+                }
+            }
+        }
+        return encoding;
+    }
+
+    /**
+     * Tests if the MIME type belongs to the APPLICATION XML family.
+     *
+     * @param mime The mime type
+     * @return true if the mime type belongs to the APPLICATION XML family,
+     * otherwise false
+     */
+    static boolean isAppXml(final String mime) {
+        return mime != null &&
+               (mime.equals("application/xml") ||
+                mime.equals("application/xml-dtd") ||
+                mime.equals("application/xml-external-parsed-entity") ||
+                mime.startsWith("application/") && mime.endsWith("+xml"));
+    }
+
+    /**
+     * Tests if the MIME type belongs to the TEXT XML family.
+     *
+     * @param mime The mime type
+     * @return true if the mime type belongs to the TEXT XML family,
+     * otherwise false
+     */
+    static boolean isTextXml(final String mime) {
+        return mime != null &&
+              (mime.equals("text/xml") ||
+               mime.equals("text/xml-external-parsed-entity") ||
+               mime.startsWith("text/") && mime.endsWith("+xml"));
+    }
+
+    private final Reader reader;
+
+    private final String encoding;
+
+    private final String defaultEncoding;
+
+    /**
+     * Constructs a Reader for a File.
+     * <p>
+     * It looks for the UTF-8 BOM first, if none sniffs the XML prolog charset,
+     * if this is also missing defaults to UTF-8.
+     * </p>
+     * <p>
+     * It does a lenient charset encoding detection, check the constructor with
+     * the lenient parameter for details.
+     * </p>
+     *
+     * @param file File to create a Reader from.
+     * @throws IOException thrown if there is a problem reading the file.
+     */
+    public XmlStreamReader(final File file) throws IOException {
+        this(Objects.requireNonNull(file, "file").toPath());
+    }
+
+    /**
+     * Constructs a Reader for a raw InputStream.
+     * <p>
+     * It follows the same logic used for files.
+     * </p>
+     * <p>
+     * It does a lenient charset encoding detection, check the constructor with
+     * the lenient parameter for details.
+     * </p>
+     *
+     * @param inputStream InputStream to create a Reader from.
+     * @throws IOException thrown if there is a problem reading the stream.
+     */
+    public XmlStreamReader(final InputStream inputStream) throws IOException {
+        this(inputStream, true);
+    }
+
+    /**
+     * Constructs a Reader for a raw InputStream.
+     * <p>
+     * It follows the same logic used for files.
+     * </p>
+     * <p>
+     * If lenient detection is indicated and the detection above fails as per
+     * specifications it then attempts the following:
+     * </p>
+     * <p>
+     * If the content type was 'text/html' it replaces it with 'text/xml' and
+     * tries the detection again.
+     * </p>
+     * <p>
+     * Else if the XML prolog had a charset encoding that encoding is used.
+     * </p>
+     * <p>
+     * Else if the content type had a charset encoding that encoding is used.
+     * </p>
+     * <p>
+     * Else 'UTF-8' is used.
+     * </p>
+     * <p>
+     * If lenient detection is indicated an XmlStreamReaderException is never
+     * thrown.
+     * </p>
+     *
+     * @param inputStream InputStream to create a Reader from.
+     * @param lenient indicates if the charset encoding detection should be
+     *        relaxed.
+     * @throws IOException thrown if there is a problem reading the stream.
+     * @throws XmlStreamReaderException thrown if the charset encoding could not
+     *         be determined according to the specs.
+     */
+    public XmlStreamReader(final InputStream inputStream, final boolean lenient) throws IOException {
+        this(inputStream, lenient, null);
+    }
+
+    /**
+     * Constructs a Reader for a raw InputStream.
+     * <p>
+     * It follows the same logic used for files.
+     * </p>
+     * <p>
+     * If lenient detection is indicated and the detection above fails as per
+     * specifications it then attempts the following:
+     * </p>
+     * <p>
+     * If the content type was 'text/html' it replaces it with 'text/xml' and
+     * tries the detection again.
+     * </p>
+     * <p>
+     * Else if the XML prolog had a charset encoding that encoding is used.
+     * </p>
+     * <p>
+     * Else if the content type had a charset encoding that encoding is used.
+     * </p>
+     * <p>
+     * Else 'UTF-8' is used.
+     * </p>
+     * <p>
+     * If lenient detection is indicated an XmlStreamReaderException is never
+     * thrown.
+     * </p>
+     *
+     * @param inputStream InputStream to create a Reader from.
+     * @param lenient indicates if the charset encoding detection should be
+     *        relaxed.
+     * @param defaultEncoding The default encoding
+     * @throws IOException thrown if there is a problem reading the stream.
+     * @throws XmlStreamReaderException thrown if the charset encoding could not
+     *         be determined according to the specs.
+     */
+    @SuppressWarnings("resource") // InputStream is managed through a InputStreamReader in this instance.
+    public XmlStreamReader(final InputStream inputStream, final boolean lenient, final String defaultEncoding)
+            throws IOException {
+        Objects.requireNonNull(inputStream, "inputStream");
+        this.defaultEncoding = defaultEncoding;
+        final BOMInputStream bom = new BOMInputStream(new BufferedInputStream(inputStream, IOUtils.DEFAULT_BUFFER_SIZE), false, BOMS);
+        final BOMInputStream pis = new BOMInputStream(bom, true, XML_GUESS_BYTES);
+        this.encoding = doRawStream(bom, pis, lenient);
+        this.reader = new InputStreamReader(pis, encoding);
+    }
+
+    /**
+     * Constructs a Reader using an InputStream and the associated content-type
+     * header.
+     * <p>
+     * First it checks if the stream has BOM. If there is not BOM checks the
+     * content-type encoding. If there is not content-type encoding checks the
+     * XML prolog encoding. If there is not XML prolog encoding uses the default
+     * encoding mandated by the content-type MIME type.
+     * </p>
+     * <p>
+     * It does a lenient charset encoding detection, check the constructor with
+     * the lenient parameter for details.
+     * </p>
+     *
+     * @param inputStream InputStream to create the reader from.
+     * @param httpContentType content-type header to use for the resolution of
+     *        the charset encoding.
+     * @throws IOException thrown if there is a problem reading the file.
+     */
+    public XmlStreamReader(final InputStream inputStream, final String httpContentType)
+            throws IOException {
+        this(inputStream, httpContentType, true);
+    }
+
+    /**
+     * Constructs a Reader using an InputStream and the associated content-type
+     * header. This constructor is lenient regarding the encoding detection.
+     * <p>
+     * First it checks if the stream has BOM. If there is not BOM checks the
+     * content-type encoding. If there is not content-type encoding checks the
+     * XML prolog encoding. If there is not XML prolog encoding uses the default
+     * encoding mandated by the content-type MIME type.
+     * </p>
+     * <p>
+     * If lenient detection is indicated and the detection above fails as per
+     * specifications it then attempts the following:
+     * </p>
+     * <p>
+     * If the content type was 'text/html' it replaces it with 'text/xml' and
+     * tries the detection again.
+     * </p>
+     * <p>
+     * Else if the XML prolog had a charset encoding that encoding is used.
+     * </p>
+     * <p>
+     * Else if the content type had a charset encoding that encoding is used.
+     * </p>
+     * <p>
+     * Else 'UTF-8' is used.
+     * </p>
+     * <p>
+     * If lenient detection is indicated an XmlStreamReaderException is never
+     * thrown.
+     * </p>
+     *
+     * @param inputStream InputStream to create the reader from.
+     * @param httpContentType content-type header to use for the resolution of
+     *        the charset encoding.
+     * @param lenient indicates if the charset encoding detection should be
+     *        relaxed.
+     * @throws IOException thrown if there is a problem reading the file.
+     * @throws XmlStreamReaderException thrown if the charset encoding could not
+     *         be determined according to the specification.
+     */
+    public XmlStreamReader(final InputStream inputStream, final String httpContentType,
+            final boolean lenient) throws IOException {
+        this(inputStream, httpContentType, lenient, null);
+    }
+
+    /**
+     * Constructs a Reader using an InputStream and the associated content-type
+     * header. This constructor is lenient regarding the encoding detection.
+     * <p>
+     * First it checks if the stream has BOM. If there is not BOM checks the
+     * content-type encoding. If there is not content-type encoding checks the
+     * XML prolog encoding. If there is not XML prolog encoding uses the default
+     * encoding mandated by the content-type MIME type.
+     * </p>
+     * <p>
+     * If lenient detection is indicated and the detection above fails as per
+     * specifications it then attempts the following:
+     * </p>
+     * <p>
+     * If the content type was 'text/html' it replaces it with 'text/xml' and
+     * tries the detection again.
+     * </p>
+     * <p>
+     * Else if the XML prolog had a charset encoding that encoding is used.
+     * </p>
+     * <p>
+     * Else if the content type had a charset encoding that encoding is used.
+     * </p>
+     * <p>
+     * Else 'UTF-8' is used.
+     * </p>
+     * <p>
+     * If lenient detection is indicated an XmlStreamReaderException is never
+     * thrown.
+     * </p>
+     *
+     * @param inputStream InputStream to create the reader from.
+     * @param httpContentType content-type header to use for the resolution of
+     *        the charset encoding.
+     * @param lenient indicates if the charset encoding detection should be
+     *        relaxed.
+     * @param defaultEncoding The default encoding
+     * @throws IOException thrown if there is a problem reading the file.
+     * @throws XmlStreamReaderException thrown if the charset encoding could not
+     *         be determined according to the specification.
+     */
+    @SuppressWarnings("resource") // InputStream is managed through a InputStreamReader in this instance.
+    public XmlStreamReader(final InputStream inputStream, final String httpContentType,
+            final boolean lenient, final String defaultEncoding) throws IOException {
+        Objects.requireNonNull(inputStream, "inputStream");
+        this.defaultEncoding = defaultEncoding;
+        final BOMInputStream bom = new BOMInputStream(new BufferedInputStream(inputStream, IOUtils.DEFAULT_BUFFER_SIZE), false, BOMS);
+        final BOMInputStream pis = new BOMInputStream(bom, true, XML_GUESS_BYTES);
+        this.encoding = processHttpStream(bom, pis, httpContentType, lenient);
+        this.reader = new InputStreamReader(pis, encoding);
+    }
+
+    /**
+     * Constructs a Reader for a File.
+     * <p>
+     * It looks for the UTF-8 BOM first, if none sniffs the XML prolog charset,
+     * if this is also missing defaults to UTF-8.
+     * </p>
+     * <p>
+     * It does a lenient charset encoding detection, check the constructor with
+     * the lenient parameter for details.
+     * </p>
+     *
+     * @param file File to create a Reader from.
+     * @throws IOException thrown if there is a problem reading the file.
+     * @since 2.11.0
+     */
+    @SuppressWarnings("resource") // InputStream is managed through another reader in this instance.
+    public XmlStreamReader(final Path file) throws IOException {
+        this(Files.newInputStream(Objects.requireNonNull(file, "file")));
+    }
+
+    /**
+     * Constructs a Reader using the InputStream of a URL.
+     * <p>
+     * If the URL is not of type HTTP and there is not 'content-type' header in
+     * the fetched data it uses the same logic used for Files.
+     * </p>
+     * <p>
+     * If the URL is a HTTP Url or there is a 'content-type' header in the
+     * fetched data it uses the same logic used for an InputStream with
+     * content-type.
+     * </p>
+     * <p>
+     * It does a lenient charset encoding detection, check the constructor with
+     * the lenient parameter for details.
+     * </p>
+     *
+     * @param url URL to create a Reader from.
+     * @throws IOException thrown if there is a problem reading the stream of
+     *         the URL.
+     */
+    public XmlStreamReader(final URL url) throws IOException {
+        this(Objects.requireNonNull(url, "url").openConnection(), null);
+    }
+
+    /**
+     * Constructs a Reader using the InputStream of a URLConnection.
+     * <p>
+     * If the URLConnection is not of type HttpURLConnection and there is not
+     * 'content-type' header in the fetched data it uses the same logic used for
+     * files.
+     * </p>
+     * <p>
+     * If the URLConnection is a HTTP Url or there is a 'content-type' header in
+     * the fetched data it uses the same logic used for an InputStream with
+     * content-type.
+     * </p>
+     * <p>
+     * It does a lenient charset encoding detection, check the constructor with
+     * the lenient parameter for details.
+     * </p>
+     *
+     * @param urlConnection URLConnection to create a Reader from.
+     * @param defaultEncoding The default encoding
+     * @throws IOException thrown if there is a problem reading the stream of
+     *         the URLConnection.
+     */
+    public XmlStreamReader(final URLConnection urlConnection, final String defaultEncoding) throws IOException {
+        Objects.requireNonNull(urlConnection, "urlConnection");
+        this.defaultEncoding = defaultEncoding;
+        final boolean lenient = true;
+        final String contentType = urlConnection.getContentType();
+        final InputStream inputStream = urlConnection.getInputStream();
+        @SuppressWarnings("resource") // managed by the InputStreamReader tracked by this instance
+        final BOMInputStream bomInput = new BOMInputStream(new BufferedInputStream(inputStream, IOUtils.DEFAULT_BUFFER_SIZE), false, BOMS);
+        final BOMInputStream piInput = new BOMInputStream(bomInput, true, XML_GUESS_BYTES);
+        if (urlConnection instanceof HttpURLConnection || contentType != null) {
+            this.encoding = processHttpStream(bomInput, piInput, contentType, lenient);
+        } else {
+            this.encoding = doRawStream(bomInput, piInput, lenient);
+        }
+        this.reader = new InputStreamReader(piInput, encoding);
+    }
+
+    /**
+     * Calculates the HTTP encoding.
+     *
+     * @param httpContentType The HTTP content type
+     * @param bomEnc BOM encoding
+     * @param xmlGuessEnc XML Guess encoding
+     * @param xmlEnc XML encoding
+     * @param lenient indicates if the charset encoding detection should be
+     *        relaxed.
+     * @return the HTTP encoding
+     * @throws IOException thrown if there is a problem reading the stream.
+     */
+    String calculateHttpEncoding(final String httpContentType,
+            final String bomEnc, final String xmlGuessEnc, final String xmlEnc,
+            final boolean lenient) throws IOException {
+
+        // Lenient and has XML encoding
+        if (lenient && xmlEnc != null) {
+            return xmlEnc;
+        }
+
+        // Determine mime/encoding content types from HTTP Content Type
+        final String cTMime = getContentTypeMime(httpContentType);
+        final String cTEnc  = getContentTypeEncoding(httpContentType);
+        final boolean appXml  = isAppXml(cTMime);
+        final boolean textXml = isTextXml(cTMime);
+
+        // Mime type NOT "application/xml" or "text/xml"
+        if (!appXml && !textXml) {
+            final String msg = MessageFormat.format(HTTP_EX_3, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc);
+            throw new XmlStreamReaderException(msg, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc);
+        }
+
+        // No content type encoding
+        if (cTEnc == null) {
+            if (appXml) {
+                return calculateRawEncoding(bomEnc, xmlGuessEnc, xmlEnc);
+            }
+            return defaultEncoding == null ? US_ASCII : defaultEncoding;
+        }
+
+        // UTF-16BE or UTF-16LE content type encoding
+        if (cTEnc.equals(UTF_16BE) || cTEnc.equals(UTF_16LE)) {
+            if (bomEnc != null) {
+                final String msg = MessageFormat.format(HTTP_EX_1, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc);
+                throw new XmlStreamReaderException(msg, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc);
+            }
+            return cTEnc;
+        }
+
+        // UTF-16 content type encoding
+        if (cTEnc.equals(UTF_16)) {
+            if (bomEnc != null && bomEnc.startsWith(UTF_16)) {
+                return bomEnc;
+            }
+            final String msg = MessageFormat.format(HTTP_EX_2, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc);
+            throw new XmlStreamReaderException(msg, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc);
+        }
+
+        // UTF-32BE or UTF-132E content type encoding
+        if (cTEnc.equals(UTF_32BE) || cTEnc.equals(UTF_32LE)) {
+            if (bomEnc != null) {
+                final String msg = MessageFormat.format(HTTP_EX_1, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc);
+                throw new XmlStreamReaderException(msg, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc);
+            }
+            return cTEnc;
+        }
+
+        // UTF-32 content type encoding
+        if (cTEnc.equals(UTF_32)) {
+            if (bomEnc != null && bomEnc.startsWith(UTF_32)) {
+                return bomEnc;
+            }
+            final String msg = MessageFormat.format(HTTP_EX_2, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc);
+            throw new XmlStreamReaderException(msg, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc);
+        }
+
+        return cTEnc;
+    }
+
+    /**
+     * Calculate the raw encoding.
+     *
+     * @param bomEnc BOM encoding
+     * @param xmlGuessEnc XML Guess encoding
+     * @param xmlEnc XML encoding
+     * @return the raw encoding
+     * @throws IOException thrown if there is a problem reading the stream.
+     */
+    String calculateRawEncoding(final String bomEnc, final String xmlGuessEnc,
+            final String xmlEnc) throws IOException {
+
+        // BOM is Null
+        if (bomEnc == null) {
+            if (xmlGuessEnc == null || xmlEnc == null) {
+                return defaultEncoding == null ? UTF_8 : defaultEncoding;
+            }
+            if (xmlEnc.equals(UTF_16) &&
+               (xmlGuessEnc.equals(UTF_16BE) || xmlGuessEnc.equals(UTF_16LE))) {
+                return xmlGuessEnc;
+            }
+            return xmlEnc;
+        }
+
+        // BOM is UTF-8
+        if (bomEnc.equals(UTF_8)) {
+            if (xmlGuessEnc != null && !xmlGuessEnc.equals(UTF_8)) {
+                final String msg = MessageFormat.format(RAW_EX_1, bomEnc, xmlGuessEnc, xmlEnc);
+                throw new XmlStreamReaderException(msg, bomEnc, xmlGuessEnc, xmlEnc);
+            }
+            if (xmlEnc != null && !xmlEnc.equals(UTF_8)) {
+                final String msg = MessageFormat.format(RAW_EX_1, bomEnc, xmlGuessEnc, xmlEnc);
+                throw new XmlStreamReaderException(msg, bomEnc, xmlGuessEnc, xmlEnc);
+            }
+            return bomEnc;
+        }
+
+        // BOM is UTF-16BE or UTF-16LE
+        if (bomEnc.equals(UTF_16BE) || bomEnc.equals(UTF_16LE)) {
+            if (xmlGuessEnc != null && !xmlGuessEnc.equals(bomEnc)) {
+                final String msg = MessageFormat.format(RAW_EX_1, bomEnc, xmlGuessEnc, xmlEnc);
+                throw new XmlStreamReaderException(msg, bomEnc, xmlGuessEnc, xmlEnc);
+            }
+            if (xmlEnc != null && !xmlEnc.equals(UTF_16) && !xmlEnc.equals(bomEnc)) {
+                final String msg = MessageFormat.format(RAW_EX_1, bomEnc, xmlGuessEnc, xmlEnc);
+                throw new XmlStreamReaderException(msg, bomEnc, xmlGuessEnc, xmlEnc);
+            }
+            return bomEnc;
+        }
+
+        // BOM is UTF-32BE or UTF-32LE
+        if (bomEnc.equals(UTF_32BE) || bomEnc.equals(UTF_32LE)) {
+            if (xmlGuessEnc != null && !xmlGuessEnc.equals(bomEnc)) {
+                final String msg = MessageFormat.format(RAW_EX_1, bomEnc, xmlGuessEnc, xmlEnc);
+                throw new XmlStreamReaderException(msg, bomEnc, xmlGuessEnc, xmlEnc);
+            }
+            if (xmlEnc != null && !xmlEnc.equals(UTF_32) && !xmlEnc.equals(bomEnc)) {
+                final String msg = MessageFormat.format(RAW_EX_1, bomEnc, xmlGuessEnc, xmlEnc);
+                throw new XmlStreamReaderException(msg, bomEnc, xmlGuessEnc, xmlEnc);
+            }
+            return bomEnc;
+        }
+
+        // BOM is something else
+        final String msg = MessageFormat.format(RAW_EX_2, bomEnc, xmlGuessEnc, xmlEnc);
+        throw new XmlStreamReaderException(msg, bomEnc, xmlGuessEnc, xmlEnc);
+    }
+
+    /**
+     * Closes the XmlStreamReader stream.
+     *
+     * @throws IOException thrown if there was a problem closing the stream.
+     */
+    @Override
+    public void close() throws IOException {
+        reader.close();
+    }
+
+    /**
+     * Does lenient detection.
+     *
+     * @param httpContentType content-type header to use for the resolution of
+     *        the charset encoding.
+     * @param ex The thrown exception
+     * @return the encoding
+     * @throws IOException thrown if there is a problem reading the stream.
+     */
+    private String doLenientDetection(String httpContentType,
+            XmlStreamReaderException ex) throws IOException {
+        if (httpContentType != null && httpContentType.startsWith("text/html")) {
+            httpContentType = httpContentType.substring("text/html".length());
+            httpContentType = "text/xml" + httpContentType;
+            try {
+                return calculateHttpEncoding(httpContentType, ex.getBomEncoding(),
+                        ex.getXmlGuessEncoding(), ex.getXmlEncoding(), true);
+            } catch (final XmlStreamReaderException ex2) {
+                ex = ex2;
+            }
+        }
+        String encoding = ex.getXmlEncoding();
+        if (encoding == null) {
+            encoding = ex.getContentTypeEncoding();
+        }
+        if (encoding == null) {
+            encoding = defaultEncoding == null ? UTF_8 : defaultEncoding;
+        }
+        return encoding;
+    }
+
+    /**
+     * Process the raw stream.
+     *
+     * @param bom BOMInputStream to detect byte order marks
+     * @param pis BOMInputStream to guess XML encoding
+     * @param lenient indicates if the charset encoding detection should be
+     *        relaxed.
+     * @return the encoding to be used
+     * @throws IOException thrown if there is a problem reading the stream.
+     */
+    private String doRawStream(final BOMInputStream bom, final BOMInputStream pis, final boolean lenient) throws IOException {
+        final String bomEnc = bom.getBOMCharsetName();
+        final String xmlGuessEnc = pis.getBOMCharsetName();
+        final String xmlEnc = getXmlProlog(pis, xmlGuessEnc);
+        try {
+            return calculateRawEncoding(bomEnc, xmlGuessEnc, xmlEnc);
+        } catch (final XmlStreamReaderException ex) {
+            if (lenient) {
+                return doLenientDetection(null, ex);
+            }
+            throw ex;
+        }
+    }
+
+    /**
+     * Gets the default encoding to use if none is set in HTTP content-type,
+     * XML prolog and the rules based on content-type are not adequate.
+     * <p>
+     * If it is NULL the content-type based rules are used.
+     * </p>
+     *
+     * @return the default encoding to use.
+     */
+    public String getDefaultEncoding() {
+        return defaultEncoding;
+    }
+
+    /**
+     * Gets the charset encoding of the XmlStreamReader.
+     *
+     * @return charset encoding.
+     */
+    public String getEncoding() {
+        return encoding;
+    }
+
+    /**
+     * Processes an HTTP stream.
+     *
+     * @param bomInput BOMInputStream to detect byte order marks
+     * @param piInput BOMInputStream to guess XML encoding
+     * @param httpContentType The HTTP content type
+     * @param lenient indicates if the charset encoding detection should be relaxed.
+     * @return the encoding to be used
+     * @throws IOException thrown if there is a problem reading the stream.
+     */
+    private String processHttpStream(final BOMInputStream bomInput, final BOMInputStream piInput, final String httpContentType, final boolean lenient)
+        throws IOException {
+        final String bomEnc = bomInput.getBOMCharsetName();
+        final String xmlGuessEnc = piInput.getBOMCharsetName();
+        final String xmlEnc = getXmlProlog(piInput, xmlGuessEnc);
+        try {
+            return calculateHttpEncoding(httpContentType, bomEnc, xmlGuessEnc, xmlEnc, lenient);
+        } catch (final XmlStreamReaderException ex) {
+            if (lenient) {
+                return doLenientDetection(httpContentType, ex);
+            }
+            throw ex;
+        }
+    }
+
+    /**
+     * Reads the underlying reader's {@code read(char[], int, int)} method.
+     *
+     * @param buf the buffer to read the characters into
+     * @param offset The start offset
+     * @param len The number of bytes to read
+     * @return the number of characters read or -1 if the end of stream
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public int read(final char[] buf, final int offset, final int len) throws IOException {
+        return reader.read(buf, offset, len);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/input/XmlStreamReaderException.java b/src/main/java/org/apache/commons/io/input/XmlStreamReaderException.java
new file mode 100644
index 0000000..7570329
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/XmlStreamReaderException.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.IOException;
+
+/**
+ * The XmlStreamReaderException is thrown by the XmlStreamReader constructors if
+ * the charset encoding can not be determined according to the XML 1.0
+ * specification and RFC 3023.
+ * <p>
+ * The exception returns the unconsumed InputStream to allow the application to
+ * do an alternate processing with the stream. Note that the original
+ * InputStream given to the XmlStreamReader cannot be used as that one has been
+ * already read.
+ * </p>
+ *
+ * @since 2.0
+ */
+public class XmlStreamReaderException extends IOException {
+
+    private static final long serialVersionUID = 1L;
+
+    private final String bomEncoding;
+
+    private final String xmlGuessEncoding;
+
+    private final String xmlEncoding;
+
+    private final String contentTypeMime;
+
+    private final String contentTypeEncoding;
+
+    /**
+     * Creates an exception instance if the charset encoding could not be
+     * determined.
+     * <p>
+     * Instances of this exception are thrown by the XmlStreamReader.
+     * </p>
+     *
+     * @param msg message describing the reason for the exception.
+     * @param bomEnc BOM encoding.
+     * @param xmlGuessEnc XML guess encoding.
+     * @param xmlEnc XML prolog encoding.
+     */
+    public XmlStreamReaderException(final String msg, final String bomEnc,
+            final String xmlGuessEnc, final String xmlEnc) {
+        this(msg, null, null, bomEnc, xmlGuessEnc, xmlEnc);
+    }
+
+    /**
+     * Creates an exception instance if the charset encoding could not be
+     * determined.
+     * <p>
+     * Instances of this exception are thrown by the XmlStreamReader.
+     * </p>
+     *
+     * @param msg message describing the reason for the exception.
+     * @param ctMime MIME type in the content-type.
+     * @param ctEnc encoding in the content-type.
+     * @param bomEnc BOM encoding.
+     * @param xmlGuessEnc XML guess encoding.
+     * @param xmlEnc XML prolog encoding.
+     */
+    public XmlStreamReaderException(final String msg, final String ctMime, final String ctEnc,
+            final String bomEnc, final String xmlGuessEnc, final String xmlEnc) {
+        super(msg);
+        contentTypeMime = ctMime;
+        contentTypeEncoding = ctEnc;
+        bomEncoding = bomEnc;
+        xmlGuessEncoding = xmlGuessEnc;
+        xmlEncoding = xmlEnc;
+    }
+
+    /**
+     * Returns the BOM encoding found in the InputStream.
+     *
+     * @return the BOM encoding, null if none.
+     */
+    public String getBomEncoding() {
+        return bomEncoding;
+    }
+
+    /**
+     * Returns the encoding in the content-type used to attempt determining the
+     * encoding.
+     *
+     * @return the encoding in the content-type, null if there was not
+     *         content-type, no encoding in it or the encoding detection did not
+     *         involve HTTP.
+     */
+    public String getContentTypeEncoding() {
+        return contentTypeEncoding;
+    }
+
+    /**
+     * Returns the MIME type in the content-type used to attempt determining the
+     * encoding.
+     *
+     * @return the MIME type in the content-type, null if there was not
+     *         content-type or the encoding detection did not involve HTTP.
+     */
+    public String getContentTypeMime() {
+        return contentTypeMime;
+    }
+
+    /**
+     * Returns the encoding found in the XML prolog of the InputStream.
+     *
+     * @return the encoding of the XML prolog, null if none.
+     */
+    public String getXmlEncoding() {
+        return xmlEncoding;
+    }
+
+    /**
+     * Returns the encoding guess based on the first bytes of the InputStream.
+     *
+     * @return the encoding guess, null if it couldn't be guessed.
+     */
+    public String getXmlGuessEncoding() {
+        return xmlGuessEncoding;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/buffer/CircularBufferInputStream.java b/src/main/java/org/apache/commons/io/input/buffer/CircularBufferInputStream.java
new file mode 100644
index 0000000..02201b2
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/buffer/CircularBufferInputStream.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input.buffer;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Objects;
+
+import org.apache.commons.io.IOUtils;
+
+/**
+ * Implements a buffered input stream, which is internally based on a {@link CircularByteBuffer}. Unlike the
+ * {@link java.io.BufferedInputStream}, this one doesn't need to reallocate byte arrays internally.
+ */
+public class CircularBufferInputStream extends InputStream {
+
+    /** What we are streaming, used to fill the internal buffer. */
+    protected final InputStream in;
+
+    /** Internal buffer. */
+    protected final CircularByteBuffer buffer;
+
+    /** Internal buffer size. */
+    protected final int bufferSize;
+
+    /** Whether we've seen the input stream EOF. */
+    private boolean eof;
+
+    /**
+     * Creates a new instance, which filters the given input stream, and uses a reasonable default buffer size
+     * ({@link IOUtils#DEFAULT_BUFFER_SIZE}).
+     *
+     * @param inputStream The input stream, which is being buffered.
+     */
+    public CircularBufferInputStream(final InputStream inputStream) {
+        this(inputStream, IOUtils.DEFAULT_BUFFER_SIZE);
+    }
+
+    /**
+     * Creates a new instance, which filters the given input stream, and uses the given buffer size.
+     *
+     * @param inputStream The input stream, which is being buffered.
+     * @param bufferSize The size of the {@link CircularByteBuffer}, which is used internally.
+     */
+    public CircularBufferInputStream(final InputStream inputStream, final int bufferSize) {
+        if (bufferSize <= 0) {
+            throw new IllegalArgumentException("Invalid bufferSize: " + bufferSize);
+        }
+        this.in = Objects.requireNonNull(inputStream, "inputStream");
+        this.buffer = new CircularByteBuffer(bufferSize);
+        this.bufferSize = bufferSize;
+        this.eof = false;
+    }
+
+    @Override
+    public void close() throws IOException {
+        in.close();
+        eof = true;
+        buffer.clear();
+    }
+
+    /**
+     * Fills the buffer with the contents of the input stream.
+     *
+     * @throws IOException in case of an error while reading from the input stream.
+     */
+    protected void fillBuffer() throws IOException {
+        if (eof) {
+            return;
+        }
+        int space = buffer.getSpace();
+        final byte[] buf = IOUtils.byteArray(space);
+        while (space > 0) {
+            final int res = in.read(buf, 0, space);
+            if (res == EOF) {
+                eof = true;
+                return;
+            }
+            if (res > 0) {
+                buffer.add(buf, 0, res);
+                space -= res;
+            }
+        }
+    }
+
+    /**
+     * Fills the buffer from the input stream until the given number of bytes have been added to the buffer.
+     *
+     * @param count number of byte to fill into the buffer
+     * @return true if the buffer has bytes
+     * @throws IOException in case of an error while reading from the input stream.
+     */
+    protected boolean haveBytes(final int count) throws IOException {
+        if (buffer.getCurrentNumberOfBytes() < count) {
+            fillBuffer();
+        }
+        return buffer.hasBytes();
+    }
+
+    @Override
+    public int read() throws IOException {
+        if (!haveBytes(1)) {
+            return EOF;
+        }
+        return buffer.read() & 0xFF; // return unsigned byte
+    }
+
+    @Override
+    public int read(final byte[] buffer) throws IOException {
+        return read(buffer, 0, buffer.length);
+    }
+
+    @Override
+    public int read(final byte[] targetBuffer, final int offset, final int length) throws IOException {
+        Objects.requireNonNull(targetBuffer, "targetBuffer");
+        if (offset < 0) {
+            throw new IllegalArgumentException("Offset must not be negative");
+        }
+        if (length < 0) {
+            throw new IllegalArgumentException("Length must not be negative");
+        }
+        if (!haveBytes(length)) {
+            return EOF;
+        }
+        final int result = Math.min(length, buffer.getCurrentNumberOfBytes());
+        for (int i = 0; i < result; i++) {
+            targetBuffer[offset + i] = buffer.read();
+        }
+        return result;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/buffer/CircularByteBuffer.java b/src/main/java/org/apache/commons/io/input/buffer/CircularByteBuffer.java
new file mode 100644
index 0000000..98a4897
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/buffer/CircularByteBuffer.java
@@ -0,0 +1,265 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input.buffer;
+
+import java.util.Objects;
+
+import org.apache.commons.io.IOUtils;
+
+/**
+ * A buffer, which doesn't need reallocation of byte arrays, because it
+ * reuses a single byte array. This works particularly well, if reading
+ * from the buffer takes place at the same time than writing to. Such is the
+ * case, for example, when using the buffer within a filtering input stream,
+ * like the {@link CircularBufferInputStream}.
+ */
+public class CircularByteBuffer {
+    private final byte[] buffer;
+    private int startOffset;
+    private int endOffset;
+    private int currentNumberOfBytes;
+
+    /**
+     * Creates a new instance with a reasonable default buffer size ({@link IOUtils#DEFAULT_BUFFER_SIZE}).
+     */
+    public CircularByteBuffer() {
+        this(IOUtils.DEFAULT_BUFFER_SIZE);
+    }
+
+    /**
+     * Creates a new instance with the given buffer size.
+     *
+     * @param size the size of buffer to create
+     */
+    public CircularByteBuffer(final int size) {
+        buffer = IOUtils.byteArray(size);
+        startOffset = 0;
+        endOffset = 0;
+        currentNumberOfBytes = 0;
+    }
+
+    /**
+     * Adds a new byte to the buffer, which will eventually be returned by following
+     * invocations of {@link #read()}.
+     *
+     * @param value The byte, which is being added to the buffer.
+     * @throws IllegalStateException The buffer is full. Use {@link #hasSpace()},
+     *                               or {@link #getSpace()}, to prevent this exception.
+     */
+    public void add(final byte value) {
+        if (currentNumberOfBytes >= buffer.length) {
+            throw new IllegalStateException("No space available");
+        }
+        buffer[endOffset] = value;
+        ++currentNumberOfBytes;
+        if (++endOffset == buffer.length) {
+            endOffset = 0;
+        }
+    }
+
+    /**
+     * Adds the given bytes to the buffer. This is the same as invoking {@link #add(byte)}
+     * for the bytes at offsets {@code offset+0}, {@code offset+1}, ...,
+     * {@code offset+length-1} of byte array {@code targetBuffer}.
+     *
+     * @param targetBuffer the buffer to copy
+     * @param offset start offset
+     * @param length length to copy
+     * @throws IllegalStateException    The buffer doesn't have sufficient space. Use
+     *                                  {@link #getSpace()} to prevent this exception.
+     * @throws IllegalArgumentException Either of {@code offset}, or {@code length} is negative.
+     * @throws NullPointerException     The byte array {@code pBuffer} is null.
+     */
+    public void add(final byte[] targetBuffer, final int offset, final int length) {
+        Objects.requireNonNull(targetBuffer, "Buffer");
+        if (offset < 0 || offset >= targetBuffer.length) {
+            throw new IllegalArgumentException("Invalid offset: " + offset);
+        }
+        if (length < 0) {
+            throw new IllegalArgumentException("Invalid length: " + length);
+        }
+        if (currentNumberOfBytes + length > buffer.length) {
+            throw new IllegalStateException("No space available");
+        }
+        for (int i = 0; i < length; i++) {
+            buffer[endOffset] = targetBuffer[offset + i];
+            if (++endOffset == buffer.length) {
+                endOffset = 0;
+            }
+        }
+        currentNumberOfBytes += length;
+    }
+
+    /**
+     * Removes all bytes from the buffer.
+     */
+    public void clear() {
+        startOffset = 0;
+        endOffset = 0;
+        currentNumberOfBytes = 0;
+    }
+
+    /**
+     * Returns the number of bytes, that are currently present in the buffer.
+     *
+     * @return the number of bytes
+     */
+    public int getCurrentNumberOfBytes() {
+        return currentNumberOfBytes;
+    }
+
+    /**
+     * Returns the number of bytes, that can currently be added to the buffer.
+     *
+     * @return the number of bytes that can be added
+     */
+    public int getSpace() {
+        return buffer.length - currentNumberOfBytes;
+    }
+
+    /**
+     * Returns, whether the buffer is currently holding, at least, a single byte.
+     *
+     * @return true if the buffer is not empty
+     */
+    public boolean hasBytes() {
+        return currentNumberOfBytes > 0;
+    }
+
+    /**
+     * Returns, whether there is currently room for a single byte in the buffer.
+     * Same as {@link #hasSpace(int) hasSpace(1)}.
+     *
+     * @return true if there is space for a byte
+     * @see #hasSpace(int)
+     * @see #getSpace()
+     */
+    public boolean hasSpace() {
+        return currentNumberOfBytes < buffer.length;
+    }
+
+    /**
+     * Returns, whether there is currently room for the given number of bytes in the buffer.
+     *
+     * @param count the byte count
+     * @return true if there is space for the given number of bytes
+     * @see #hasSpace()
+     * @see #getSpace()
+     */
+    public boolean hasSpace(final int count) {
+        return currentNumberOfBytes + count <= buffer.length;
+    }
+
+    /**
+     * Returns, whether the next bytes in the buffer are exactly those, given by
+     * {@code sourceBuffer}, {@code offset}, and {@code length}. No bytes are being
+     * removed from the buffer. If the result is true, then the following invocations
+     * of {@link #read()} are guaranteed to return exactly those bytes.
+     *
+     * @param sourceBuffer the buffer to compare against
+     * @param offset start offset
+     * @param length length to compare
+     * @return True, if the next invocations of {@link #read()} will return the
+     * bytes at offsets {@code pOffset}+0, {@code pOffset}+1, ...,
+     * {@code pOffset}+{@code length}-1 of byte array {@code pBuffer}.
+     * @throws IllegalArgumentException Either of {@code pOffset}, or {@code length} is negative.
+     * @throws NullPointerException     The byte array {@code pBuffer} is null.
+     */
+    public boolean peek(final byte[] sourceBuffer, final int offset, final int length) {
+        Objects.requireNonNull(sourceBuffer, "Buffer");
+        if (offset < 0 || offset >= sourceBuffer.length) {
+            throw new IllegalArgumentException("Invalid offset: " + offset);
+        }
+        if (length < 0 || length > buffer.length) {
+            throw new IllegalArgumentException("Invalid length: " + length);
+        }
+        if (length < currentNumberOfBytes) {
+            return false;
+        }
+        int localOffset = startOffset;
+        for (int i = 0; i < length; i++) {
+            if (buffer[localOffset] != sourceBuffer[i + offset]) {
+                return false;
+            }
+            if (++localOffset == buffer.length) {
+                localOffset = 0;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Returns the next byte from the buffer, removing it at the same time, so
+     * that following invocations won't return it again.
+     *
+     * @return The byte, which is being returned.
+     * @throws IllegalStateException The buffer is empty. Use {@link #hasBytes()},
+     *                               or {@link #getCurrentNumberOfBytes()}, to prevent this exception.
+     */
+    public byte read() {
+        if (currentNumberOfBytes <= 0) {
+            throw new IllegalStateException("No bytes available.");
+        }
+        final byte b = buffer[startOffset];
+        --currentNumberOfBytes;
+        if (++startOffset == buffer.length) {
+            startOffset = 0;
+        }
+        return b;
+    }
+
+    /**
+     * Returns the given number of bytes from the buffer by storing them in
+     * the given byte array at the given offset.
+     *
+     * @param targetBuffer The byte array, where to add bytes.
+     * @param targetOffset The offset, where to store bytes in the byte array.
+     * @param length The number of bytes to return.
+     * @throws NullPointerException     The byte array {@code pBuffer} is null.
+     * @throws IllegalArgumentException Either of {@code pOffset}, or {@code length} is negative,
+     *                                  or the length of the byte array {@code targetBuffer} is too small.
+     * @throws IllegalStateException    The buffer doesn't hold the given number
+     *                                  of bytes. Use {@link #getCurrentNumberOfBytes()} to prevent this
+     *                                  exception.
+     */
+    public void read(final byte[] targetBuffer, final int targetOffset, final int length) {
+        Objects.requireNonNull(targetBuffer, "targetBuffer");
+        if (targetOffset < 0 || targetOffset >= targetBuffer.length) {
+            throw new IllegalArgumentException("Invalid offset: " + targetOffset);
+        }
+        if (length < 0 || length > buffer.length) {
+            throw new IllegalArgumentException("Invalid length: " + length);
+        }
+        if (targetOffset + length > targetBuffer.length) {
+            throw new IllegalArgumentException("The supplied byte array contains only "
+                    + targetBuffer.length + " bytes, but offset, and length would require "
+                    + (targetOffset + length - 1));
+        }
+        if (currentNumberOfBytes < length) {
+            throw new IllegalStateException("Currently, there are only " + currentNumberOfBytes
+                    + "in the buffer, not " + length);
+        }
+        int offset = targetOffset;
+        for (int i = 0; i < length; i++) {
+            targetBuffer[offset++] = buffer[startOffset];
+            --currentNumberOfBytes;
+            if (++startOffset == buffer.length) {
+                startOffset = 0;
+            }
+        }
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/buffer/PeekableInputStream.java b/src/main/java/org/apache/commons/io/input/buffer/PeekableInputStream.java
new file mode 100644
index 0000000..6e9b88c
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/buffer/PeekableInputStream.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input.buffer;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Objects;
+
+/**
+ * Implements a buffered input stream, which allows to peek into the buffers first bytes. This comes in handy when
+ * manually implementing scanners, lexers, parsers, and the like.
+ */
+public class PeekableInputStream extends CircularBufferInputStream {
+
+    /**
+     * Creates a new instance, which filters the given input stream, and uses a reasonable default buffer size (8192).
+     *
+     * @param inputStream The input stream, which is being buffered.
+     */
+    public PeekableInputStream(final InputStream inputStream) {
+        super(inputStream);
+    }
+
+    /**
+     * Creates a new instance, which filters the given input stream, and uses the given buffer size.
+     *
+     * @param inputStream The input stream, which is being buffered.
+     * @param bufferSize The size of the {@link CircularByteBuffer}, which is used internally.
+     */
+    public PeekableInputStream(final InputStream inputStream, final int bufferSize) {
+        super(inputStream, bufferSize);
+    }
+
+    /**
+     * Returns whether the next bytes in the buffer are as given by {@code sourceBuffer}. This is equivalent to
+     * {@link #peek(byte[], int, int)} with {@code offset} == 0, and {@code length} == {@code sourceBuffer.length}
+     *
+     * @param sourceBuffer the buffer to compare against
+     * @return true if the next bytes are as given
+     * @throws IOException Refilling the buffer failed.
+     */
+    public boolean peek(final byte[] sourceBuffer) throws IOException {
+        Objects.requireNonNull(sourceBuffer, "sourceBuffer");
+        return peek(sourceBuffer, 0, sourceBuffer.length);
+    }
+
+    /**
+     * Returns whether the next bytes in the buffer are as given by {@code sourceBuffer}, {code offset}, and
+     * {@code length}.
+     *
+     * @param sourceBuffer the buffer to compare against
+     * @param offset the start offset
+     * @param length the length to compare
+     * @return true if the next bytes in the buffer are as given
+     * @throws IOException if there is a problem calling fillBuffer()
+     */
+    public boolean peek(final byte[] sourceBuffer, final int offset, final int length) throws IOException {
+        Objects.requireNonNull(sourceBuffer, "sourceBuffer");
+        if (sourceBuffer.length > bufferSize) {
+            throw new IllegalArgumentException("Peek request size of " + sourceBuffer.length
+                + " bytes exceeds buffer size of " + bufferSize + " bytes");
+        }
+        if (buffer.getCurrentNumberOfBytes() < sourceBuffer.length) {
+            fillBuffer();
+        }
+        return buffer.peek(sourceBuffer, offset, length);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/input/buffer/package-info.java b/src/main/java/org/apache/commons/io/input/buffer/package-info.java
new file mode 100644
index 0000000..146e5b1
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/buffer/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * This package provides implementations of buffered input classes, such as
+ * {@link org.apache.commons.io.input.buffer.CircularBufferInputStream} and
+ * {@link org.apache.commons.io.input.buffer.PeekableInputStream}.
+ */
+package org.apache.commons.io.input.buffer;
diff --git a/src/main/java/org/apache/commons/io/input/package-info.java b/src/main/java/org/apache/commons/io/input/package-info.java
new file mode 100644
index 0000000..25cfd54
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/input/package-info.java
@@ -0,0 +1,21 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * This package provides implementations of input classes, such as {@link java.io.InputStream} and {@link java.io.Reader}.
+ */
+package org.apache.commons.io.input;
diff --git a/src/main/java/org/apache/commons/io/monitor/FileAlterationListener.java b/src/main/java/org/apache/commons/io/monitor/FileAlterationListener.java
new file mode 100644
index 0000000..07f2311
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/monitor/FileAlterationListener.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.monitor;
+import java.io.File;
+
+/**
+ * Receives events of file system modifications.
+ * <p>
+ * Register {@link FileAlterationListener}s with a {@link FileAlterationObserver}.
+ * </p>
+ *
+ * @see FileAlterationObserver
+ * @since 2.0
+ */
+public interface FileAlterationListener {
+
+    /**
+     * Directory changed Event.
+     *
+     * @param directory The directory changed
+     */
+    void onDirectoryChange(final File directory);
+
+    /**
+     * Directory created Event.
+     *
+     * @param directory The directory created
+     */
+    void onDirectoryCreate(final File directory);
+
+    /**
+     * Directory deleted Event.
+     *
+     * @param directory The directory deleted
+     */
+    void onDirectoryDelete(final File directory);
+
+    /**
+     * File changed Event.
+     *
+     * @param file The file changed
+     */
+    void onFileChange(final File file);
+
+    /**
+     * File created Event.
+     *
+     * @param file The file created
+     */
+    void onFileCreate(final File file);
+
+    /**
+     * File deleted Event.
+     *
+     * @param file The file deleted
+     */
+    void onFileDelete(final File file);
+
+    /**
+     * File system observer started checking event.
+     *
+     * @param observer The file system observer
+     */
+    void onStart(final FileAlterationObserver observer);
+
+    /**
+     * File system observer finished checking event.
+     *
+     * @param observer The file system observer
+     */
+    void onStop(final FileAlterationObserver observer);
+}
diff --git a/src/main/java/org/apache/commons/io/monitor/FileAlterationListenerAdaptor.java b/src/main/java/org/apache/commons/io/monitor/FileAlterationListenerAdaptor.java
new file mode 100644
index 0000000..32633eb
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/monitor/FileAlterationListenerAdaptor.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.monitor;
+
+import java.io.File;
+
+/**
+ * Convenience {@link FileAlterationListener} implementation that does nothing.
+ *
+ * @see FileAlterationObserver
+ *
+ * @since 2.0
+ */
+public class FileAlterationListenerAdaptor implements FileAlterationListener {
+
+    /**
+     * Directory changed Event.
+     *
+     * @param directory The directory changed (ignored)
+     */
+    @Override
+    public void onDirectoryChange(final File directory) {
+        // noop
+    }
+
+    /**
+     * Directory created Event.
+     *
+     * @param directory The directory created (ignored)
+     */
+    @Override
+    public void onDirectoryCreate(final File directory) {
+        // noop
+    }
+
+    /**
+     * Directory deleted Event.
+     *
+     * @param directory The directory deleted (ignored)
+     */
+    @Override
+    public void onDirectoryDelete(final File directory) {
+        // noop
+    }
+
+    /**
+     * File changed Event.
+     *
+     * @param file The file changed (ignored)
+     */
+    @Override
+    public void onFileChange(final File file) {
+        // noop
+    }
+
+    /**
+     * File created Event.
+     *
+     * @param file The file created (ignored)
+     */
+    @Override
+    public void onFileCreate(final File file) {
+        // noop
+    }
+
+    /**
+     * File deleted Event.
+     *
+     * @param file The file deleted (ignored)
+     */
+    @Override
+    public void onFileDelete(final File file) {
+        // noop
+    }
+
+    /**
+     * File system observer started checking event.
+     *
+     * @param observer The file system observer (ignored)
+     */
+    @Override
+    public void onStart(final FileAlterationObserver observer) {
+        // noop
+    }
+
+    /**
+     * File system observer finished checking event.
+     *
+     * @param observer The file system observer (ignored)
+     */
+    @Override
+    public void onStop(final FileAlterationObserver observer) {
+        // noop
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/monitor/FileAlterationMonitor.java b/src/main/java/org/apache/commons/io/monitor/FileAlterationMonitor.java
new file mode 100644
index 0000000..f4c07e1
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/monitor/FileAlterationMonitor.java
@@ -0,0 +1,218 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.monitor;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ThreadFactory;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.ThreadUtils;
+
+/**
+ * A runnable that spawns a monitoring thread triggering any
+ * registered {@link FileAlterationObserver} at a specified interval.
+ *
+ * @see FileAlterationObserver
+ * @since 2.0
+ */
+public final class FileAlterationMonitor implements Runnable {
+
+    private static final FileAlterationObserver[] EMPTY_ARRAY = {};
+
+    private final long intervalMillis;
+    private final List<FileAlterationObserver> observers = new CopyOnWriteArrayList<>();
+    private Thread thread;
+    private ThreadFactory threadFactory;
+    private volatile boolean running;
+
+    /**
+     * Constructs a monitor with a default interval of 10 seconds.
+     */
+    public FileAlterationMonitor() {
+        this(10_000);
+    }
+
+    /**
+     * Constructs a monitor with the specified interval.
+     *
+     * @param intervalMillis The amount of time in milliseconds to wait between
+     * checks of the file system.
+     */
+    public FileAlterationMonitor(final long intervalMillis) {
+        this.intervalMillis = intervalMillis;
+    }
+
+    /**
+     * Constructs a monitor with the specified interval and collection of observers.
+     *
+     * @param interval The amount of time in milliseconds to wait between
+     * checks of the file system.
+     * @param observers The collection of observers to add to the monitor.
+     * @since 2.9.0
+     */
+    public FileAlterationMonitor(final long interval, final Collection<FileAlterationObserver> observers) {
+        // @formatter:off
+        this(interval,
+            Optional
+                .ofNullable(observers)
+                .orElse(Collections.emptyList())
+                .toArray(EMPTY_ARRAY)
+        );
+        // @formatter:on
+    }
+
+    /**
+     * Constructs a monitor with the specified interval and set of observers.
+     *
+     * @param interval The amount of time in milliseconds to wait between
+     * checks of the file system.
+     * @param observers The set of observers to add to the monitor.
+     */
+    public FileAlterationMonitor(final long interval, final FileAlterationObserver... observers) {
+        this(interval);
+        if (observers != null) {
+            Stream.of(observers).forEach(this::addObserver);
+        }
+    }
+
+    /**
+     * Adds a file system observer to this monitor.
+     *
+     * @param observer The file system observer to add
+     */
+    public void addObserver(final FileAlterationObserver observer) {
+        if (observer != null) {
+            observers.add(observer);
+        }
+    }
+
+    /**
+     * Returns the interval.
+     *
+     * @return the interval
+     */
+    public long getInterval() {
+        return intervalMillis;
+    }
+
+    /**
+     * Returns the set of {@link FileAlterationObserver} registered with
+     * this monitor.
+     *
+     * @return The set of {@link FileAlterationObserver}
+     */
+    public Iterable<FileAlterationObserver> getObservers() {
+        return observers;
+    }
+
+    /**
+     * Removes a file system observer from this monitor.
+     *
+     * @param observer The file system observer to remove
+     */
+    public void removeObserver(final FileAlterationObserver observer) {
+        if (observer != null) {
+            observers.removeIf(observer::equals);
+        }
+    }
+
+    /**
+     * Runs this monitor.
+     */
+    @Override
+    public void run() {
+        while (running) {
+            observers.forEach(FileAlterationObserver::checkAndNotify);
+            if (!running) {
+                break;
+            }
+            try {
+                ThreadUtils.sleep(Duration.ofMillis(intervalMillis));
+            } catch (final InterruptedException ignored) {
+                // ignore
+            }
+        }
+    }
+
+    /**
+     * Sets the thread factory.
+     *
+     * @param threadFactory the thread factory
+     */
+    public synchronized void setThreadFactory(final ThreadFactory threadFactory) {
+        this.threadFactory = threadFactory;
+    }
+
+    /**
+     * Starts monitoring.
+     *
+     * @throws Exception if an error occurs initializing the observer
+     */
+    public synchronized void start() throws Exception {
+        if (running) {
+            throw new IllegalStateException("Monitor is already running");
+        }
+        for (final FileAlterationObserver observer : observers) {
+            observer.initialize();
+        }
+        running = true;
+        if (threadFactory != null) {
+            thread = threadFactory.newThread(this);
+        } else {
+            thread = new Thread(this);
+        }
+        thread.start();
+    }
+
+    /**
+     * Stops monitoring.
+     *
+     * @throws Exception if an error occurs initializing the observer
+     */
+    public synchronized void stop() throws Exception {
+        stop(intervalMillis);
+    }
+
+    /**
+     * Stops monitoring.
+     *
+     * @param stopInterval the amount of time in milliseconds to wait for the thread to finish.
+     * A value of zero will wait until the thread is finished (see {@link Thread#join(long)}).
+     * @throws Exception if an error occurs initializing the observer
+     * @since 2.1
+     */
+    public synchronized void stop(final long stopInterval) throws Exception {
+        if (!running) {
+            throw new IllegalStateException("Monitor is not running");
+        }
+        running = false;
+        try {
+            thread.interrupt();
+            thread.join(stopInterval);
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+        for (final FileAlterationObserver observer : observers) {
+            observer.destroy();
+        }
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/monitor/FileAlterationObserver.java b/src/main/java/org/apache/commons/io/monitor/FileAlterationObserver.java
new file mode 100644
index 0000000..426f4c4
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/monitor/FileAlterationObserver.java
@@ -0,0 +1,461 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.monitor;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.Serializable;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.IOCase;
+import org.apache.commons.io.comparator.NameFileComparator;
+
+/**
+ * FileAlterationObserver represents the state of files below a root directory,
+ * checking the file system and notifying listeners of create, change or
+ * delete events.
+ * <p>
+ * To use this implementation:
+ * <ul>
+ *   <li>Create {@link FileAlterationListener} implementation(s) that process
+ *      the file/directory create, change and delete events</li>
+ *   <li>Register the listener(s) with a {@link FileAlterationObserver} for
+ *       the appropriate directory.</li>
+ *   <li>Either register the observer(s) with a {@link FileAlterationMonitor} or
+ *       run manually.</li>
+ * </ul>
+ *
+ * <h2>Basic Usage</h2>
+ * Create a {@link FileAlterationObserver} for the directory and register the listeners:
+ * <pre>
+ *      File directory = new File(FileUtils.current(), "src");
+ *      FileAlterationObserver observer = new FileAlterationObserver(directory);
+ *      observer.addListener(...);
+ *      observer.addListener(...);
+ * </pre>
+ * To manually observe a directory, initialize the observer and invoked the
+ * {@link #checkAndNotify()} method as required:
+ * <pre>
+ *      // initialize
+ *      observer.init();
+ *      ...
+ *      // invoke as required
+ *      observer.checkAndNotify();
+ *      ...
+ *      observer.checkAndNotify();
+ *      ...
+ *      // finished
+ *      observer.finish();
+ * </pre>
+ * Alternatively, register the observer(s) with a {@link FileAlterationMonitor},
+ * which creates a new thread, invoking the observer at the specified interval:
+ * <pre>
+ *      long interval = ...
+ *      FileAlterationMonitor monitor = new FileAlterationMonitor(interval);
+ *      monitor.addObserver(observer);
+ *      monitor.start();
+ *      ...
+ *      monitor.stop();
+ * </pre>
+ *
+ * <h2>File Filters</h2>
+ * This implementation can monitor portions of the file system
+ * by using {@link FileFilter}s to observe only the files and/or directories
+ * that are of interest. This makes it more efficient and reduces the
+ * noise from <i>unwanted</i> file system events.
+ * <p>
+ * <a href="https://commons.apache.org/io/">Commons IO</a> has a good range of
+ * useful, ready-made
+ * <a href="../filefilter/package-summary.html">File Filter</a>
+ * implementations for this purpose.
+ * <p>
+ * For example, to only observe 1) visible directories and 2) files with a ".java" suffix
+ * in a root directory called "src" you could set up a {@link FileAlterationObserver} in the following
+ * way:
+ * <pre>
+ *      // Create a FileFilter
+ *      IOFileFilter directories = FileFilterUtils.and(
+ *                                      FileFilterUtils.directoryFileFilter(),
+ *                                      HiddenFileFilter.VISIBLE);
+ *      IOFileFilter files       = FileFilterUtils.and(
+ *                                      FileFilterUtils.fileFileFilter(),
+ *                                      FileFilterUtils.suffixFileFilter(".java"));
+ *      IOFileFilter filter = FileFilterUtils.or(directories, files);
+ *
+ *      // Create the File system observer and register File Listeners
+ *      FileAlterationObserver observer = new FileAlterationObserver(new File("src"), filter);
+ *      observer.addListener(...);
+ *      observer.addListener(...);
+ * </pre>
+ *
+ * <h2>FileEntry</h2>
+ * {@link FileEntry} represents the state of a file or directory, capturing
+ * {@link File} attributes at a point in time. Custom implementations of
+ * {@link FileEntry} can be used to capture additional properties that the
+ * basic implementation does not support. The {@link FileEntry#refresh(File)}
+ * method is used to determine if a file or directory has changed since the last
+ * check and stores the current state of the {@link File}'s properties.
+ *
+ * @see FileAlterationListener
+ * @see FileAlterationMonitor
+ *
+ * @since 2.0
+ */
+public class FileAlterationObserver implements Serializable {
+
+    private static final long serialVersionUID = 1185122225658782848L;
+    private final List<FileAlterationListener> listeners = new CopyOnWriteArrayList<>();
+    private final FileEntry rootEntry;
+    private final FileFilter fileFilter;
+    private final Comparator<File> comparator;
+
+    /**
+     * Constructs an observer for the specified directory.
+     *
+     * @param directory the directory to observe
+     */
+    public FileAlterationObserver(final File directory) {
+        this(directory, null);
+    }
+
+    /**
+     * Constructs an observer for the specified directory and file filter.
+     *
+     * @param directory the directory to observe
+     * @param fileFilter The file filter or null if none
+     */
+    public FileAlterationObserver(final File directory, final FileFilter fileFilter) {
+        this(directory, fileFilter, null);
+    }
+
+    /**
+     * Constructs an observer for the specified directory, file filter and
+     * file comparator.
+     *
+     * @param directory the directory to observe
+     * @param fileFilter The file filter or null if none
+     * @param ioCase  what case sensitivity to use comparing file names, null means system sensitive
+     */
+    public FileAlterationObserver(final File directory, final FileFilter fileFilter, final IOCase ioCase) {
+        this(new FileEntry(directory), fileFilter, ioCase);
+    }
+
+    /**
+     * Constructs an observer for the specified directory, file filter and file comparator.
+     *
+     * @param rootEntry the root directory to observe
+     * @param fileFilter The file filter or null if none
+     * @param ioCase what case sensitivity to use comparing file names, null means system sensitive
+     */
+    protected FileAlterationObserver(final FileEntry rootEntry, final FileFilter fileFilter, final IOCase ioCase) {
+        Objects.requireNonNull(rootEntry, "rootEntry");
+        Objects.requireNonNull(rootEntry.getFile(), "rootEntry.getFile()");
+        this.rootEntry = rootEntry;
+        this.fileFilter = fileFilter;
+        switch (IOCase.value(ioCase, IOCase.SYSTEM)) {
+        case SYSTEM:
+            this.comparator = NameFileComparator.NAME_SYSTEM_COMPARATOR;
+            break;
+        case INSENSITIVE:
+            this.comparator = NameFileComparator.NAME_INSENSITIVE_COMPARATOR;
+            break;
+        default:
+            this.comparator = NameFileComparator.NAME_COMPARATOR;
+        }
+    }
+
+    /**
+     * Constructs an observer for the specified directory.
+     *
+     * @param directoryName the name of the directory to observe
+     */
+    public FileAlterationObserver(final String directoryName) {
+        this(new File(directoryName));
+    }
+
+    /**
+     * Constructs an observer for the specified directory and file filter.
+     *
+     * @param directoryName the name of the directory to observe
+     * @param fileFilter The file filter or null if none
+     */
+    public FileAlterationObserver(final String directoryName, final FileFilter fileFilter) {
+        this(new File(directoryName), fileFilter);
+    }
+
+    /**
+     * Constructs an observer for the specified directory, file filter and file comparator.
+     *
+     * @param directoryName the name of the directory to observe
+     * @param fileFilter The file filter or null if none
+     * @param ioCase what case sensitivity to use comparing file names, null means system sensitive
+     */
+    public FileAlterationObserver(final String directoryName, final FileFilter fileFilter, final IOCase ioCase) {
+        this(new File(directoryName), fileFilter, ioCase);
+    }
+
+    /**
+     * Adds a file system listener.
+     *
+     * @param listener The file system listener
+     */
+    public void addListener(final FileAlterationListener listener) {
+        if (listener != null) {
+            listeners.add(listener);
+        }
+    }
+
+    /**
+     * Checks whether the file and its children have been created, modified or deleted.
+     */
+    public void checkAndNotify() {
+
+        // fire onStart()
+        listeners.forEach(listener -> listener.onStart(this));
+
+        // fire directory/file events
+        final File rootFile = rootEntry.getFile();
+        if (rootFile.exists()) {
+            checkAndNotify(rootEntry, rootEntry.getChildren(), listFiles(rootFile));
+        } else if (rootEntry.isExists()) {
+            checkAndNotify(rootEntry, rootEntry.getChildren(), FileUtils.EMPTY_FILE_ARRAY);
+        }
+        // Else: Didn't exist and still doesn't
+
+        // fire onStop()
+        listeners.forEach(listener -> listener.onStop(this));
+    }
+
+    /**
+     * Compares two file lists for files which have been created, modified or deleted.
+     *
+     * @param parent The parent entry
+     * @param previous The original list of files
+     * @param files  The current list of files
+     */
+    private void checkAndNotify(final FileEntry parent, final FileEntry[] previous, final File[] files) {
+        int c = 0;
+        final FileEntry[] current = files.length > 0 ? new FileEntry[files.length] : FileEntry.EMPTY_FILE_ENTRY_ARRAY;
+        for (final FileEntry entry : previous) {
+            while (c < files.length && comparator.compare(entry.getFile(), files[c]) > 0) {
+                current[c] = createFileEntry(parent, files[c]);
+                doCreate(current[c]);
+                c++;
+            }
+            if (c < files.length && comparator.compare(entry.getFile(), files[c]) == 0) {
+                doMatch(entry, files[c]);
+                checkAndNotify(entry, entry.getChildren(), listFiles(files[c]));
+                current[c] = entry;
+                c++;
+            } else {
+                checkAndNotify(entry, entry.getChildren(), FileUtils.EMPTY_FILE_ARRAY);
+                doDelete(entry);
+            }
+        }
+        for (; c < files.length; c++) {
+            current[c] = createFileEntry(parent, files[c]);
+            doCreate(current[c]);
+        }
+        parent.setChildren(current);
+    }
+
+    /**
+     * Creates a new file entry for the specified file.
+     *
+     * @param parent The parent file entry
+     * @param file The file to create an entry for
+     * @return A new file entry
+     */
+    private FileEntry createFileEntry(final FileEntry parent, final File file) {
+        final FileEntry entry = parent.newChildInstance(file);
+        entry.refresh(file);
+        entry.setChildren(doListFiles(file, entry));
+        return entry;
+    }
+
+    /**
+     * Final processing.
+     *
+     * @throws Exception if an error occurs
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    public void destroy() throws Exception {
+        // noop
+    }
+
+    /**
+     * Fires directory/file created events to the registered listeners.
+     *
+     * @param entry The file entry
+     */
+    private void doCreate(final FileEntry entry) {
+        listeners.forEach(listener -> {
+            if (entry.isDirectory()) {
+                listener.onDirectoryCreate(entry.getFile());
+            } else {
+                listener.onFileCreate(entry.getFile());
+            }
+        });
+        Stream.of(entry.getChildren()).forEach(this::doCreate);
+    }
+
+    /**
+     * Fires directory/file delete events to the registered listeners.
+     *
+     * @param entry The file entry
+     */
+    private void doDelete(final FileEntry entry) {
+        listeners.forEach(listener -> {
+            if (entry.isDirectory()) {
+                listener.onDirectoryDelete(entry.getFile());
+            } else {
+                listener.onFileDelete(entry.getFile());
+            }
+        });
+    }
+
+    /**
+     * Lists the files
+     * @param file The file to list files for
+     * @param entry the parent entry
+     * @return The child files
+     */
+    private FileEntry[] doListFiles(final File file, final FileEntry entry) {
+        final File[] files = listFiles(file);
+        final FileEntry[] children = files.length > 0 ? new FileEntry[files.length] : FileEntry.EMPTY_FILE_ENTRY_ARRAY;
+        Arrays.setAll(children, i -> createFileEntry(entry, files[i]));
+        return children;
+    }
+
+    /**
+     * Fires directory/file change events to the registered listeners.
+     *
+     * @param entry The previous file system entry
+     * @param file The current file
+     */
+    private void doMatch(final FileEntry entry, final File file) {
+        if (entry.refresh(file)) {
+            listeners.forEach(listener -> {
+                if (entry.isDirectory()) {
+                    listener.onDirectoryChange(file);
+                } else {
+                    listener.onFileChange(file);
+                }
+            });
+        }
+    }
+
+    /**
+     * Returns the directory being observed.
+     *
+     * @return the directory being observed
+     */
+    public File getDirectory() {
+        return rootEntry.getFile();
+    }
+
+    /**
+     * Returns the fileFilter.
+     *
+     * @return the fileFilter
+     * @since 2.1
+     */
+    public FileFilter getFileFilter() {
+        return fileFilter;
+    }
+
+    /**
+     * Returns the set of registered file system listeners.
+     *
+     * @return The file system listeners
+     */
+    public Iterable<FileAlterationListener> getListeners() {
+        return listeners;
+    }
+
+    /**
+     * Initializes the observer.
+     *
+     * @throws Exception if an error occurs
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    public void initialize() throws Exception {
+        rootEntry.refresh(rootEntry.getFile());
+        rootEntry.setChildren(doListFiles(rootEntry.getFile(), rootEntry));
+    }
+
+    /**
+     * Lists the contents of a directory
+     *
+     * @param file The file to list the contents of
+     * @return the directory contents or a zero length array if
+     * the empty or the file is not a directory
+     */
+    private File[] listFiles(final File file) {
+        File[] children = null;
+        if (file.isDirectory()) {
+            children = fileFilter == null ? file.listFiles() : file.listFiles(fileFilter);
+        }
+        if (children == null) {
+            children = FileUtils.EMPTY_FILE_ARRAY;
+        }
+        if (comparator != null && children.length > 1) {
+            Arrays.sort(children, comparator);
+        }
+        return children;
+    }
+
+    /**
+     * Removes a file system listener.
+     *
+     * @param listener The file system listener
+     */
+    public void removeListener(final FileAlterationListener listener) {
+        if (listener != null) {
+            listeners.removeIf(listener::equals);
+        }
+    }
+
+    /**
+     * Returns a String representation of this observer.
+     *
+     * @return a String representation of this observer
+     */
+    @Override
+    public String toString() {
+        final StringBuilder builder = new StringBuilder();
+        builder.append(getClass().getSimpleName());
+        builder.append("[file='");
+        builder.append(getDirectory().getPath());
+        builder.append('\'');
+        if (fileFilter != null) {
+            builder.append(", ");
+            builder.append(fileFilter.toString());
+        }
+        builder.append(", listeners=");
+        builder.append(listeners.size());
+        builder.append("]");
+        return builder.toString();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/monitor/FileEntry.java b/src/main/java/org/apache/commons/io/monitor/FileEntry.java
new file mode 100644
index 0000000..773e175
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/monitor/FileEntry.java
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.monitor;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.Serializable;
+import java.nio.file.Files;
+import java.nio.file.attribute.FileTime;
+import java.util.Objects;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.file.attribute.FileTimes;
+
+/**
+ * The state of a file or directory, capturing the following {@link File} attributes at a point in time.
+ * <ul>
+ *   <li>File Name (see {@link File#getName()})</li>
+ *   <li>Exists - whether the file exists or not (see {@link File#exists()})</li>
+ *   <li>Directory - whether the file is a directory or not (see {@link File#isDirectory()})</li>
+ *   <li>Last Modified Date/Time (see {@link FileUtils#lastModifiedUnchecked(File)})</li>
+ *   <li>Length (see {@link File#length()}) - directories treated as zero</li>
+ *   <li>Children - contents of a directory (see {@link File#listFiles(java.io.FileFilter)})</li>
+ * </ul>
+ *
+ * <h2>Custom Implementations</h2>
+ * <p>
+ * If the state of additional {@link File} attributes is required then create a custom
+ * {@link FileEntry} with properties for those attributes. Override the
+ * {@link #newChildInstance(File)} to return a new instance of the appropriate type.
+ * You may also want to override the {@link #refresh(File)} method.
+ * </p>
+ * @see FileAlterationObserver
+ * @since 2.0
+ */
+public class FileEntry implements Serializable {
+
+    private static final long serialVersionUID = -2505664948818681153L;
+
+    static final FileEntry[] EMPTY_FILE_ENTRY_ARRAY = {};
+
+    private final FileEntry parent;
+    private FileEntry[] children;
+    private final File file;
+    private String name;
+    private boolean exists;
+    private boolean directory;
+    private SerializableFileTime lastModified = SerializableFileTime.EPOCH;
+    private long length;
+
+    /**
+     * Constructs a new monitor for a specified {@link File}.
+     *
+     * @param file The file being monitored
+     */
+    public FileEntry(final File file) {
+        this(null, file);
+    }
+
+    /**
+     * Constructs a new monitor for a specified {@link File}.
+     *
+     * @param parent The parent
+     * @param file The file being monitored
+     */
+    public FileEntry(final FileEntry parent, final File file) {
+        this.file = Objects.requireNonNull(file, "file");
+        this.parent = parent;
+        this.name = file.getName();
+    }
+
+    /**
+     * Gets the directory's files.
+     *
+     * @return This directory's files or an empty
+     * array if the file is not a directory or the
+     * directory is empty
+     */
+    public FileEntry[] getChildren() {
+        return children != null ? children : EMPTY_FILE_ENTRY_ARRAY;
+    }
+
+    /**
+     * Gets the file being monitored.
+     *
+     * @return the file being monitored
+     */
+    public File getFile() {
+        return file;
+    }
+
+    /**
+     * Gets the last modified time from the last time it
+     * was checked.
+     *
+     * @return the last modified time in milliseconds.
+     */
+    public long getLastModified() {
+        return lastModified.toMillis();
+    }
+
+    /**
+     * Gets the last modified time from the last time it was checked.
+     *
+     * @return the last modified time.
+     * @since 2.12.0
+     */
+    public FileTime getLastModifiedFileTime() {
+        return lastModified.unwrap();
+    }
+
+    /**
+     * Gets the length.
+     *
+     * @return the length
+     */
+    public long getLength() {
+        return length;
+    }
+
+    /**
+     * Gets the level
+     *
+     * @return the level
+     */
+    public int getLevel() {
+        return parent == null ? 0 : parent.getLevel() + 1;
+    }
+
+    /**
+     * Gets the file name.
+     *
+     * @return the file name
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Gets the parent entry.
+     *
+     * @return the parent entry
+     */
+    public FileEntry getParent() {
+        return parent;
+    }
+
+    /**
+     * Tests whether the file is a directory or not.
+     *
+     * @return whether the file is a directory or not
+     */
+    public boolean isDirectory() {
+        return directory;
+    }
+
+    /**
+     * Tests whether the file existed the last time it
+     * was checked.
+     *
+     * @return whether the file existed
+     */
+    public boolean isExists() {
+        return exists;
+    }
+
+    /**
+     * Creates a new child instance.
+     * <p>
+     * Custom implementations should override this method to return
+     * a new instance of the appropriate type.
+     * </p>
+     *
+     * @param file The child file
+     * @return a new child instance
+     */
+    public FileEntry newChildInstance(final File file) {
+        return new FileEntry(this, file);
+    }
+
+    /**
+     * Refreshes the attributes from the {@link File}, indicating
+     * whether the file has changed.
+     * <p>
+     * This implementation refreshes the {@code name}, {@code exists},
+     * {@code directory}, {@code lastModified} and {@code length}
+     * properties.
+     * </p>
+     * <p>
+     * The {@code exists}, {@code directory}, {@code lastModified}
+     * and {@code length} properties are compared for changes
+     * </p>
+     *
+     * @param file the file instance to compare to
+     * @return {@code true} if the file has changed, otherwise {@code false}
+     */
+    public boolean refresh(final File file) {
+        // cache original values
+        final boolean origExists = exists;
+        final SerializableFileTime origLastModified = lastModified;
+        final boolean origDirectory = directory;
+        final long origLength = length;
+
+        // refresh the values
+        name = file.getName();
+        exists = Files.exists(file.toPath());
+        directory = exists && file.isDirectory();
+        try {
+            setLastModified(exists ? FileUtils.lastModifiedFileTime(file) : FileTimes.EPOCH);
+        } catch (final IOException e) {
+            setLastModified(SerializableFileTime.EPOCH);
+        }
+        length = exists && !directory ? file.length() : 0;
+
+        // Return if there are changes
+        return exists != origExists || !lastModified.equals(origLastModified) || directory != origDirectory
+            || length != origLength;
+    }
+
+    /**
+     * Sets the directory's files.
+     *
+     * @param children This directory's files, may be null
+     */
+    public void setChildren(final FileEntry... children) {
+        this.children = children;
+    }
+
+    /**
+     * Sets whether the file is a directory or not.
+     *
+     * @param directory whether the file is a directory or not
+     */
+    public void setDirectory(final boolean directory) {
+        this.directory = directory;
+    }
+
+    /**
+     * Sets whether the file existed the last time it
+     * was checked.
+     *
+     * @param exists whether the file exists or not
+     */
+    public void setExists(final boolean exists) {
+        this.exists = exists;
+    }
+
+    /**
+     * Sets the last modified time from the last time it was checked.
+     *
+     * @param lastModified The last modified time.
+     * @since 2.12.0
+     */
+    public void setLastModified(final FileTime lastModified) {
+        setLastModified(new SerializableFileTime(lastModified));
+    }
+
+    /**
+     * Sets the last modified time from the last time it
+     * was checked.
+     *
+     * @param lastModified The last modified time in milliseconds.
+     */
+    public void setLastModified(final long lastModified) {
+        setLastModified(FileTime.fromMillis(lastModified));
+    }
+
+    void setLastModified(final SerializableFileTime lastModified) {
+        this.lastModified = lastModified;
+    }
+
+    /**
+     * Sets the length.
+     *
+     * @param length the length
+     */
+    public void setLength(final long length) {
+        this.length = length;
+    }
+
+    /**
+     * Sets the file name.
+     *
+     * @param name the file name
+     */
+    public void setName(final String name) {
+        this.name = name;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/monitor/SerializableFileTime.java b/src/main/java/org/apache/commons/io/monitor/SerializableFileTime.java
new file mode 100644
index 0000000..ee0dd52
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/monitor/SerializableFileTime.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.monitor;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+import java.nio.file.attribute.FileTime;
+import java.time.Instant;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.commons.io.file.attribute.FileTimes;
+
+/**
+ * Wraps a {@link FileTime} and allows it to be serializable.
+ */
+class SerializableFileTime implements Serializable {
+
+    static final SerializableFileTime EPOCH = new SerializableFileTime(FileTimes.EPOCH);
+
+    private static final long serialVersionUID = 1L;
+
+    private FileTime fileTime;
+
+    public SerializableFileTime(final FileTime fileTime) {
+        this.fileTime = Objects.requireNonNull(fileTime);
+    }
+
+    public int compareTo(final FileTime other) {
+        return fileTime.compareTo(other);
+    }
+
+    @Override
+    public boolean equals(final Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!(obj instanceof SerializableFileTime)) {
+            return false;
+        }
+        final SerializableFileTime other = (SerializableFileTime) obj;
+        return Objects.equals(fileTime, other.fileTime);
+    }
+
+    @Override
+    public int hashCode() {
+        return fileTime.hashCode();
+    }
+
+    private void readObject(final ObjectInputStream ois) throws ClassNotFoundException, IOException {
+        this.fileTime = FileTime.from((Instant) ois.readObject());
+    }
+
+    long to(final TimeUnit unit) {
+        return fileTime.to(unit);
+    }
+
+    Instant toInstant() {
+        return fileTime.toInstant();
+    }
+
+    long toMillis() {
+        return fileTime.toMillis();
+    }
+
+    @Override
+    public String toString() {
+        return fileTime.toString();
+    }
+
+    FileTime unwrap() {
+        return fileTime;
+    }
+
+    private void writeObject(final ObjectOutputStream oos) throws IOException {
+        oos.writeObject(fileTime.toInstant());
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/monitor/package-info.java b/src/main/java/org/apache/commons/io/monitor/package-info.java
new file mode 100644
index 0000000..d576883
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/monitor/package-info.java
@@ -0,0 +1,21 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * This package provides a component for monitoring file system events (directory and file create, update and delete events).
+ */
+package org.apache.commons.io.monitor;
diff --git a/src/main/java/org/apache/commons/io/output/AbstractByteArrayOutputStream.java b/src/main/java/org/apache/commons/io/output/AbstractByteArrayOutputStream.java
new file mode 100644
index 0000000..dd8b0e4
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/AbstractByteArrayOutputStream.java
@@ -0,0 +1,411 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.SequenceInputStream;
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.input.ClosedInputStream;
+
+/**
+ * This is the base class for implementing an output stream in which the data
+ * is written into a byte array. The buffer automatically grows as data
+ * is written to it.
+ * <p>
+ * The data can be retrieved using {@code toByteArray()} and
+ * {@code toString()}.
+ * Closing an {@link AbstractByteArrayOutputStream} has no effect. The methods in
+ * this class can be called after the stream has been closed without
+ * generating an {@link IOException}.
+ * </p>
+ * <p>
+ * This is the base for an alternative implementation of the
+ * {@link java.io.ByteArrayOutputStream} class. The original implementation
+ * only allocates 32 bytes at the beginning. As this class is designed for
+ * heavy duty it starts at {@value #DEFAULT_SIZE} bytes. In contrast to the original it doesn't
+ * reallocate the whole memory block but allocates additional buffers. This
+ * way no buffers need to be garbage collected and the contents don't have
+ * to be copied to the new buffer. This class is designed to behave exactly
+ * like the original. The only exception is the deprecated
+ * {@link java.io.ByteArrayOutputStream#toString(int)} method that has been
+ * ignored.
+ * </p>
+ *
+ * @since 2.7
+ */
+public abstract class AbstractByteArrayOutputStream extends OutputStream {
+
+    /**
+     * Constructor for an InputStream subclass.
+     *
+     * @param <T> the type of the InputStream.
+     */
+    @FunctionalInterface
+    protected interface InputStreamConstructor<T extends InputStream> {
+
+        /**
+         * Constructs an InputStream subclass.
+         *
+         * @param buf the buffer
+         * @param offset the offset into the buffer
+         * @param length the length of the buffer
+         *
+         * @return the InputStream subclass.
+         */
+        T construct(final byte[] buf, final int offset, final int length);
+    }
+
+    static final int DEFAULT_SIZE = 1024;
+
+    /** The list of buffers, which grows and never reduces. */
+    private final List<byte[]> buffers = new ArrayList<>();
+
+    /** The index of the current buffer. */
+    private int currentBufferIndex;
+
+    /** The total count of bytes in all the filled buffers. */
+    private int filledBufferSum;
+
+    /** The current buffer. */
+    private byte[] currentBuffer;
+
+    /** The total count of bytes written. */
+    protected int count;
+
+    /** Flag to indicate if the buffers can be reused after reset */
+    private boolean reuseBuffers = true;
+
+    /**
+     * Does nothing.
+     *
+     * The methods in this class can be called after the stream has been closed without generating an {@link IOException}.
+     *
+     * @throws IOException never (this method should not declare this exception but it has to now due to backwards
+     *         compatibility)
+     */
+    @Override
+    public void close() throws IOException {
+        //nop
+    }
+
+    /**
+     * Makes a new buffer available either by allocating
+     * a new one or re-cycling an existing one.
+     *
+     * @param newCount  the size of the buffer if one is created
+     */
+    protected void needNewBuffer(final int newCount) {
+        if (currentBufferIndex < buffers.size() - 1) {
+            // Recycling old buffer
+            filledBufferSum += currentBuffer.length;
+
+            currentBufferIndex++;
+            currentBuffer = buffers.get(currentBufferIndex);
+        } else {
+            // Creating new buffer
+            final int newBufferSize;
+            if (currentBuffer == null) {
+                newBufferSize = newCount;
+                filledBufferSum = 0;
+            } else {
+                newBufferSize = Math.max(currentBuffer.length << 1, newCount - filledBufferSum);
+                filledBufferSum += currentBuffer.length;
+            }
+
+            currentBufferIndex++;
+            currentBuffer = IOUtils.byteArray(newBufferSize);
+            buffers.add(currentBuffer);
+        }
+    }
+
+    /**
+     * @see java.io.ByteArrayOutputStream#reset()
+     */
+    public abstract void reset();
+
+    /**
+     * @see java.io.ByteArrayOutputStream#reset()
+     */
+    protected void resetImpl() {
+        count = 0;
+        filledBufferSum = 0;
+        currentBufferIndex = 0;
+        if (reuseBuffers) {
+            currentBuffer = buffers.get(currentBufferIndex);
+        } else {
+            //Throw away old buffers
+            currentBuffer = null;
+            final int size = buffers.get(0).length;
+            buffers.clear();
+            needNewBuffer(size);
+            reuseBuffers = true;
+        }
+    }
+
+    /**
+     * Returns the current size of the byte array.
+     *
+     * @return the current size of the byte array
+     */
+    public abstract int size();
+
+    /**
+     * Gets the current contents of this byte stream as a byte array.
+     * The result is independent of this stream.
+     *
+     * @return the current contents of this output stream, as a byte array
+     * @see java.io.ByteArrayOutputStream#toByteArray()
+     */
+    public abstract byte[] toByteArray();
+
+    /**
+     * Gets the current contents of this byte stream as a byte array.
+     * The result is independent of this stream.
+     *
+     * @return the current contents of this output stream, as a byte array
+     * @see java.io.ByteArrayOutputStream#toByteArray()
+     */
+    protected byte[] toByteArrayImpl() {
+        int remaining = count;
+        if (remaining == 0) {
+            return IOUtils.EMPTY_BYTE_ARRAY;
+        }
+        final byte[] newBuf = IOUtils.byteArray(remaining);
+        int pos = 0;
+        for (final byte[] buf : buffers) {
+            final int c = Math.min(buf.length, remaining);
+            System.arraycopy(buf, 0, newBuf, pos, c);
+            pos += c;
+            remaining -= c;
+            if (remaining == 0) {
+                break;
+            }
+        }
+        return newBuf;
+    }
+
+    /**
+     * Gets the current contents of this byte stream as an Input Stream. The
+     * returned stream is backed by buffers of {@code this} stream,
+     * avoiding memory allocation and copy, thus saving space and time.<br>
+     *
+     * @return the current contents of this output stream.
+     * @see java.io.ByteArrayOutputStream#toByteArray()
+     * @see #reset()
+     * @since 2.5
+     */
+    public abstract InputStream toInputStream();
+
+    /**
+     * Gets the current contents of this byte stream as an Input Stream. The
+     * returned stream is backed by buffers of {@code this} stream,
+     * avoiding memory allocation and copy, thus saving space and time.<br>
+     *
+     * @param <T> the type of the InputStream which makes up
+     *            the {@link SequenceInputStream}.
+     * @param isConstructor A constructor for an InputStream which makes
+     *                     up the {@link SequenceInputStream}.
+     *
+     * @return the current contents of this output stream.
+     * @see java.io.ByteArrayOutputStream#toByteArray()
+     * @see #reset()
+     * @since 2.7
+     */
+    @SuppressWarnings("resource") // The result InputStream MUST be managed by the call site.
+    protected <T extends InputStream> InputStream toInputStream(
+            final InputStreamConstructor<T> isConstructor) {
+        int remaining = count;
+        if (remaining == 0) {
+            return ClosedInputStream.INSTANCE;
+        }
+        final List<T> list = new ArrayList<>(buffers.size());
+        for (final byte[] buf : buffers) {
+            final int c = Math.min(buf.length, remaining);
+            list.add(isConstructor.construct(buf, 0, c));
+            remaining -= c;
+            if (remaining == 0) {
+                break;
+            }
+        }
+        reuseBuffers = false;
+        return new SequenceInputStream(Collections.enumeration(list));
+    }
+
+    /**
+     * Gets the current contents of this byte stream as a string
+     * using the platform default charset.
+     * @return the contents of the byte array as a String
+     * @see java.io.ByteArrayOutputStream#toString()
+     * @deprecated 2.5 use {@link #toString(String)} instead
+     */
+    @Override
+    @Deprecated
+    public String toString() {
+        // make explicit the use of the default charset
+        return new String(toByteArray(), Charset.defaultCharset());
+    }
+
+    /**
+     * Gets the current contents of this byte stream as a string
+     * using the specified encoding.
+     *
+     * @param charset  the character encoding
+     * @return the string converted from the byte array
+     * @see java.io.ByteArrayOutputStream#toString(String)
+     * @since 2.5
+     */
+    public String toString(final Charset charset) {
+        return new String(toByteArray(), charset);
+    }
+
+    /**
+     * Gets the current contents of this byte stream as a string
+     * using the specified encoding.
+     *
+     * @param enc  the name of the character encoding
+     * @return the string converted from the byte array
+     * @throws UnsupportedEncodingException if the encoding is not supported
+     * @see java.io.ByteArrayOutputStream#toString(String)
+     */
+    public String toString(final String enc) throws UnsupportedEncodingException {
+        return new String(toByteArray(), enc);
+    }
+
+    @Override
+    public abstract void write(final byte[] b, final int off, final int len);
+
+    /**
+     * Writes the entire contents of the specified input stream to this
+     * byte stream. Bytes from the input stream are read directly into the
+     * internal buffer of this stream.
+     *
+     * @param in the input stream to read from
+     * @return total number of bytes read from the input stream
+     *         (and written to this stream)
+     * @throws IOException if an I/O error occurs while reading the input stream
+     * @since 1.4
+     */
+    public abstract int write(final InputStream in) throws IOException;
+
+    @Override
+    public abstract void write(final int b);
+
+    /**
+     * Writes the bytes to the byte array.
+     * @param b the bytes to write
+     * @param off The start offset
+     * @param len The number of bytes to write
+     */
+    protected void writeImpl(final byte[] b, final int off, final int len) {
+        final int newCount = count + len;
+        int remaining = len;
+        int inBufferPos = count - filledBufferSum;
+        while (remaining > 0) {
+            final int part = Math.min(remaining, currentBuffer.length - inBufferPos);
+            System.arraycopy(b, off + len - remaining, currentBuffer, inBufferPos, part);
+            remaining -= part;
+            if (remaining > 0) {
+                needNewBuffer(newCount);
+                inBufferPos = 0;
+            }
+        }
+        count = newCount;
+    }
+
+    /**
+     * Writes the entire contents of the specified input stream to this
+     * byte stream. Bytes from the input stream are read directly into the
+     * internal buffer of this stream.
+     *
+     * @param in the input stream to read from
+     * @return total number of bytes read from the input stream
+     *         (and written to this stream)
+     * @throws IOException if an I/O error occurs while reading the input stream
+     * @since 2.7
+     */
+    protected int writeImpl(final InputStream in) throws IOException {
+        int readCount = 0;
+        int inBufferPos = count - filledBufferSum;
+        int n = in.read(currentBuffer, inBufferPos, currentBuffer.length - inBufferPos);
+        while (n != EOF) {
+            readCount += n;
+            inBufferPos += n;
+            count += n;
+            if (inBufferPos == currentBuffer.length) {
+                needNewBuffer(currentBuffer.length);
+                inBufferPos = 0;
+            }
+            n = in.read(currentBuffer, inBufferPos, currentBuffer.length - inBufferPos);
+        }
+        return readCount;
+    }
+
+    /**
+     * Write a byte to byte array.
+     * @param b the byte to write
+     */
+    protected void writeImpl(final int b) {
+        int inBufferPos = count - filledBufferSum;
+        if (inBufferPos == currentBuffer.length) {
+            needNewBuffer(count + 1);
+            inBufferPos = 0;
+        }
+        currentBuffer[inBufferPos] = (byte) b;
+        count++;
+    }
+
+    /**
+     * Writes the entire contents of this byte stream to the
+     * specified output stream.
+     *
+     * @param out  the output stream to write to
+     * @throws IOException if an I/O error occurs, such as if the stream is closed
+     * @see java.io.ByteArrayOutputStream#writeTo(OutputStream)
+     */
+    public abstract void writeTo(final OutputStream out) throws IOException;
+
+    /**
+     * Writes the entire contents of this byte stream to the
+     * specified output stream.
+     *
+     * @param out  the output stream to write to
+     * @throws IOException if an I/O error occurs, such as if the stream is closed
+     * @see java.io.ByteArrayOutputStream#writeTo(OutputStream)
+     */
+    protected void writeToImpl(final OutputStream out) throws IOException {
+        int remaining = count;
+        for (final byte[] buf : buffers) {
+            final int c = Math.min(buf.length, remaining);
+            out.write(buf, 0, c);
+            remaining -= c;
+            if (remaining == 0) {
+                break;
+            }
+        }
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/AppendableOutputStream.java b/src/main/java/org/apache/commons/io/output/AppendableOutputStream.java
new file mode 100644
index 0000000..17886b5
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/AppendableOutputStream.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * OutputStream implementation that writes the data to an {@link Appendable}
+ * Object.
+ * <p>
+ * For example, can be used with any {@link java.io.Writer} or a {@link java.lang.StringBuilder}
+ * or {@link java.lang.StringBuffer}.
+ * </p>
+ *
+ * @since 2.5
+ * @see Appendable
+ *
+ * @param <T> The type of the {@link Appendable} wrapped by this AppendableOutputStream.
+ */
+public class AppendableOutputStream <T extends Appendable> extends OutputStream {
+
+    private final T appendable;
+
+    /**
+     * Constructs a new instance with the specified appendable.
+     *
+     * @param appendable the appendable to write to
+     */
+    public AppendableOutputStream(final T appendable) {
+        this.appendable = appendable;
+    }
+
+    /**
+     * Return the target appendable.
+     *
+     * @return the target appendable
+     */
+    public T getAppendable() {
+        return appendable;
+    }
+
+    /**
+     * Write a character to the underlying appendable.
+     *
+     * @param b the character to write
+     * @throws IOException upon error
+     */
+    @Override
+    public void write(final int b) throws IOException {
+        appendable.append((char)b);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/AppendableWriter.java b/src/main/java/org/apache/commons/io/output/AppendableWriter.java
new file mode 100644
index 0000000..8e234e6
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/AppendableWriter.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.util.Objects;
+
+/**
+ * Writer implementation that writes the data to an {@link Appendable}
+ * Object.
+ * <p>
+ * For example, can be used with a {@link java.lang.StringBuilder}
+ * or {@link java.lang.StringBuffer}.
+ * </p>
+ *
+ * @since 2.7
+ * @see Appendable
+ *
+ * @param <T> The type of the {@link Appendable} wrapped by this AppendableWriter.
+ */
+public class AppendableWriter <T extends Appendable> extends Writer {
+
+    private final T appendable;
+
+    /**
+     * Constructs a new instance with the specified appendable.
+     *
+     * @param appendable the appendable to write to
+     */
+    public AppendableWriter(final T appendable) {
+        this.appendable = appendable;
+    }
+
+    /**
+     * Appends the specified character to the underlying appendable.
+     *
+     * @param c the character to append
+     * @return this writer
+     * @throws IOException upon error
+     */
+    @Override
+    public Writer append(final char c) throws IOException {
+        appendable.append(c);
+        return this;
+    }
+
+    /**
+     * Appends the specified character sequence to the underlying appendable.
+     *
+     * @param csq the character sequence to append
+     * @return this writer
+     * @throws IOException upon error
+     */
+    @Override
+    public Writer append(final CharSequence csq) throws IOException {
+        appendable.append(csq);
+        return this;
+    }
+
+    /**
+     * Appends a subsequence of the specified character sequence to the underlying appendable.
+     *
+     * @param csq the character sequence from which a subsequence will be appended
+     * @param start the index of the first character in the subsequence
+     * @param end the index of the character following the last character in the subsequence
+     * @return this writer
+     * @throws IOException upon error
+     */
+    @Override
+    public Writer append(final CharSequence csq, final int start, final int end) throws IOException {
+        appendable.append(csq, start, end);
+        return this;
+    }
+
+    /**
+     * Closes the stream. This implementation does nothing.
+     *
+     * @throws IOException upon error
+     */
+    @Override
+    public void close() throws IOException {
+        // noop
+    }
+
+    /**
+     * Flushes the stream. This implementation does nothing.
+     *
+     * @throws IOException upon error
+     */
+    @Override
+    public void flush() throws IOException {
+        // noop
+    }
+
+    /**
+     * Return the target appendable.
+     *
+     * @return the target appendable
+     */
+    public T getAppendable() {
+        return appendable;
+    }
+
+    /**
+     * Writes a portion of an array of characters to the underlying appendable.
+     *
+     * @param cbuf an array with the characters to write
+     * @param off offset from which to start writing characters
+     * @param len number of characters to write
+     * @throws IOException upon error
+     */
+    @Override
+    public void write(final char[] cbuf, final int off, final int len) throws IOException {
+        Objects.requireNonNull(cbuf, "Character array is missing");
+        if (len < 0 || off + len > cbuf.length) {
+            throw new IndexOutOfBoundsException("Array Size=" + cbuf.length +
+                    ", offset=" + off + ", length=" + len);
+        }
+        for (int i = 0; i < len; i++) {
+            appendable.append(cbuf[off + i]);
+        }
+    }
+
+    /**
+     * Writes a character to the underlying appendable.
+     *
+     * @param c the character to write
+     * @throws IOException upon error
+     */
+    @Override
+    public void write(final int c) throws IOException {
+        appendable.append((char)c);
+    }
+
+    /**
+     * Writes a portion of a String to the underlying appendable.
+     *
+     * @param str a string
+     * @param off offset from which to start writing characters
+     * @param len number of characters to write
+     * @throws IOException upon error
+     */
+    @Override
+    public void write(final String str, final int off, final int len) throws IOException {
+        // appendable.append will add "null" for a null String; add an explicit null check
+        Objects.requireNonNull(str, "String is missing");
+        appendable.append(str, off, off + len);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/BrokenOutputStream.java b/src/main/java/org/apache/commons/io/output/BrokenOutputStream.java
new file mode 100644
index 0000000..59d655a
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/BrokenOutputStream.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.function.Supplier;
+
+/**
+ * Broken output stream. This stream always throws an {@link IOException} from
+ * all {@link OutputStream} methods.
+ * <p>
+ * This class is mostly useful for testing error handling in code that uses an
+ * output stream.
+ * </p>
+ *
+ * @since 2.0
+ */
+public class BrokenOutputStream extends OutputStream {
+
+    /**
+     * A singleton instance.
+     *
+     * @since 2.12.0
+     */
+    public static final BrokenOutputStream INSTANCE = new BrokenOutputStream();
+
+    /**
+     * A supplier for the exception that is thrown by all methods of this class.
+     */
+    private final Supplier<IOException> exceptionSupplier;
+
+    /**
+     * Creates a new stream that always throws an {@link IOException}.
+     */
+    public BrokenOutputStream() {
+        this(() -> new IOException("Broken output stream"));
+    }
+
+    /**
+     * Creates a new stream that always throws the given exception.
+     *
+     * @param exception the exception to be thrown.
+     */
+    public BrokenOutputStream(final IOException exception) {
+        this(() -> exception);
+    }
+
+    /**
+     * Creates a new stream that always throws an {@link IOException}.
+     *
+     * @param exceptionSupplier a supplier for the exception to be thrown.
+     * @since 2.12.0
+     */
+    public BrokenOutputStream(final Supplier<IOException> exceptionSupplier) {
+        this.exceptionSupplier = exceptionSupplier;
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @throws IOException always thrown
+     */
+    @Override
+    public void close() throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @throws IOException always thrown
+     */
+    @Override
+    public void flush() throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @param b ignored
+     * @throws IOException always thrown
+     */
+    @Override
+    public void write(final int b) throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/BrokenWriter.java b/src/main/java/org/apache/commons/io/output/BrokenWriter.java
new file mode 100644
index 0000000..9755a53
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/BrokenWriter.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.util.function.Supplier;
+
+/**
+ * Always throws an {@link IOException} from all {@link Writer} methods.
+ * <p>
+ * This class is mostly useful for testing error handling in code that uses a writer.
+ * </p>
+ *
+ * @since 2.0
+ */
+public class BrokenWriter extends Writer {
+
+    /**
+     * The singleton instance.
+     *
+     * @since 2.12.0
+     */
+    public static final BrokenWriter INSTANCE = new BrokenWriter();
+
+    /**
+     * A supplier for the exception that is thrown by all methods of this class.
+     */
+    private final Supplier<IOException> exceptionSupplier;
+
+    /**
+     * Creates a new writer that always throws an {@link IOException}.
+     */
+    public BrokenWriter() {
+        this(() -> new IOException("Broken writer"));
+    }
+
+    /**
+     * Creates a new writer that always throws the given exception.
+     *
+     * @param exception the exception to be thrown.
+     */
+    public BrokenWriter(final IOException exception) {
+        this(() -> exception);
+    }
+
+    /**
+     * Creates a new writer that always throws an {@link IOException}.
+     *
+     * @param exceptionSupplier a supplier for the exception to be thrown.
+     * @since 2.12.0
+     */
+    public BrokenWriter(final Supplier<IOException> exceptionSupplier) {
+        this.exceptionSupplier = exceptionSupplier;
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @throws IOException always thrown
+     */
+    @Override
+    public void close() throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @throws IOException always thrown
+     */
+    @Override
+    public void flush() throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+    /**
+     * Throws the configured exception.
+     *
+     * @param cbuf ignored
+     * @param off ignored
+     * @param len ignored
+     * @throws IOException always thrown
+     */
+    @Override
+    public void write(final char[] cbuf, final int off, final int len) throws IOException {
+        throw exceptionSupplier.get();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/ByteArrayOutputStream.java b/src/main/java/org/apache/commons/io/output/ByteArrayOutputStream.java
new file mode 100644
index 0000000..23474f9
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/ByteArrayOutputStream.java
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Implements a ThreadSafe version of {@link AbstractByteArrayOutputStream} using instance synchronization.
+ */
+//@ThreadSafe
+public class ByteArrayOutputStream extends AbstractByteArrayOutputStream {
+
+    /**
+     * Fetches entire contents of an {@link InputStream} and represent
+     * same data as result InputStream.
+     * <p>
+     * This method is useful where,
+     * </p>
+     * <ul>
+     * <li>Source InputStream is slow.</li>
+     * <li>It has network resources associated, so we cannot keep it open for
+     * long time.</li>
+     * <li>It has network timeout associated.</li>
+     * </ul>
+     * It can be used in favor of {@link #toByteArray()}, since it
+     * avoids unnecessary allocation and copy of byte[].<br>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     *
+     * @param input Stream to be fully buffered.
+     * @return A fully buffered stream.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.0
+     */
+    public static InputStream toBufferedInputStream(final InputStream input)
+            throws IOException {
+        return toBufferedInputStream(input, DEFAULT_SIZE);
+    }
+
+    /**
+     * Fetches entire contents of an {@link InputStream} and represent
+     * same data as result InputStream.
+     * <p>
+     * This method is useful where,
+     * </p>
+     * <ul>
+     * <li>Source InputStream is slow.</li>
+     * <li>It has network resources associated, so we cannot keep it open for
+     * long time.</li>
+     * <li>It has network timeout associated.</li>
+     * </ul>
+     * It can be used in favor of {@link #toByteArray()}, since it
+     * avoids unnecessary allocation and copy of byte[].<br>
+     * This method buffers the input internally, so there is no need to use a
+     * {@link BufferedInputStream}.
+     *
+     * @param input Stream to be fully buffered.
+     * @param size the initial buffer size
+     * @return A fully buffered stream.
+     * @throws IOException if an I/O error occurs.
+     * @since 2.5
+     */
+    public static InputStream toBufferedInputStream(final InputStream input, final int size)
+        throws IOException {
+        try (ByteArrayOutputStream output = new ByteArrayOutputStream(size)) {
+            output.write(input);
+            return output.toInputStream();
+        }
+    }
+
+    /**
+     * Creates a new byte array output stream. The buffer capacity is
+     * initially {@value AbstractByteArrayOutputStream#DEFAULT_SIZE} bytes, though its size increases if necessary.
+     */
+    public ByteArrayOutputStream() {
+        this(DEFAULT_SIZE);
+    }
+
+    /**
+     * Creates a new byte array output stream, with a buffer capacity of
+     * the specified size, in bytes.
+     *
+     * @param size  the initial size
+     * @throws IllegalArgumentException if size is negative
+     */
+    public ByteArrayOutputStream(final int size) {
+        if (size < 0) {
+            throw new IllegalArgumentException("Negative initial size: " + size);
+        }
+        synchronized (this) {
+            needNewBuffer(size);
+        }
+    }
+
+    /**
+     * @see java.io.ByteArrayOutputStream#reset()
+     */
+    @Override
+    public synchronized void reset() {
+        resetImpl();
+    }
+
+    @Override
+    public synchronized int size() {
+        return count;
+    }
+
+    @Override
+    public synchronized byte[] toByteArray() {
+        return toByteArrayImpl();
+    }
+
+    @Override
+    public synchronized InputStream toInputStream() {
+        return toInputStream(java.io.ByteArrayInputStream::new);
+    }
+
+    @Override
+    public void write(final byte[] b, final int off, final int len) {
+        if (off < 0
+                || off > b.length
+                || len < 0
+                || off + len > b.length
+                || off + len < 0) {
+            throw new IndexOutOfBoundsException();
+        }
+        if (len == 0) {
+            return;
+        }
+        synchronized (this) {
+            writeImpl(b, off, len);
+        }
+    }
+
+    @Override
+    public synchronized int write(final InputStream in) throws IOException {
+        return writeImpl(in);
+    }
+
+    @Override
+    public synchronized void write(final int b) {
+        writeImpl(b);
+    }
+
+    @Override
+    public synchronized void writeTo(final OutputStream out) throws IOException {
+        writeToImpl(out);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/output/ChunkedOutputStream.java b/src/main/java/org/apache/commons/io/output/ChunkedOutputStream.java
new file mode 100644
index 0000000..e319454
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/ChunkedOutputStream.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * OutputStream which breaks larger output blocks into chunks.
+ * Native code may need to copy the input array; if the write buffer
+ * is very large this can cause OOME.
+ *
+ * @since 2.5
+ */
+public class ChunkedOutputStream extends FilterOutputStream {
+
+    /**
+     * The default chunk size to use, i.e. {@value} bytes.
+     */
+    private static final int DEFAULT_CHUNK_SIZE = 1024 * 4;
+
+    /**
+     * The maximum chunk size to us when writing data arrays
+     */
+    private final int chunkSize;
+
+    /**
+     * Creates a new stream that uses a chunk size of {@link #DEFAULT_CHUNK_SIZE}.
+     *
+     * @param stream the stream to wrap
+     */
+    public ChunkedOutputStream(final OutputStream stream) {
+        this(stream, DEFAULT_CHUNK_SIZE);
+    }
+
+    /**
+     * Creates a new stream that uses the specified chunk size.
+     *
+     * @param stream the stream to wrap
+     * @param chunkSize the chunk size to use; must be a positive number.
+     * @throws IllegalArgumentException if the chunk size is &lt;= 0
+     */
+    public ChunkedOutputStream(final OutputStream stream, final int chunkSize) {
+       super(stream);
+       if (chunkSize <= 0) {
+           throw new IllegalArgumentException();
+       }
+       this.chunkSize = chunkSize;
+    }
+
+    /**
+     * Writes the data buffer in chunks to the underlying stream
+     *
+     * @param data the data to write
+     * @param srcOffset the offset
+     * @param length the length of data to write
+     *
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final byte[] data, final int srcOffset, final int length) throws IOException {
+        int bytes = length;
+        int dstOffset = srcOffset;
+        while(bytes > 0) {
+            final int chunk = Math.min(bytes, chunkSize);
+            out.write(data, dstOffset, chunk);
+            bytes -= chunk;
+            dstOffset += chunk;
+        }
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/ChunkedWriter.java b/src/main/java/org/apache/commons/io/output/ChunkedWriter.java
new file mode 100644
index 0000000..409c78e
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/ChunkedWriter.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.FilterWriter;
+import java.io.IOException;
+import java.io.Writer;
+
+/**
+ * Writer which breaks larger output blocks into chunks.
+ * Native code may need to copy the input array; if the write buffer
+ * is very large this can cause OOME.
+ *
+ * @since 2.5
+ */
+public class ChunkedWriter extends FilterWriter {
+
+    /**
+     * The default chunk size to use, i.e. {@value} bytes.
+     */
+    private static final int DEFAULT_CHUNK_SIZE = 1024 * 4;
+
+    /**
+     * The maximum chunk size to us when writing data arrays
+     */
+    private final int chunkSize;
+
+    /**
+     * Creates a new writer that uses a chunk size of {@link #DEFAULT_CHUNK_SIZE}
+     * @param writer the writer to wrap
+     */
+    public ChunkedWriter(final Writer writer) {
+        this(writer, DEFAULT_CHUNK_SIZE);
+    }
+
+    /**
+     * Creates a new writer that uses the specified chunk size.
+     *
+     * @param writer the writer to wrap
+     * @param chunkSize the chunk size to use; must be a positive number.
+     * @throws IllegalArgumentException if the chunk size is &lt;= 0
+     */
+    public ChunkedWriter(final Writer writer, final int chunkSize) {
+       super(writer);
+       if (chunkSize <= 0) {
+           throw new IllegalArgumentException();
+       }
+       this.chunkSize = chunkSize;
+    }
+
+    /**
+     * writes the data buffer in chunks to the underlying writer
+     * @param data The data
+     * @param srcOffset the offset
+     * @param length the number of bytes to write
+     *
+     * @throws IOException upon error
+     */
+    @Override
+    public void write(final char[] data, final int srcOffset, final int length) throws IOException {
+        int bytes = length;
+        int dstOffset = srcOffset;
+        while(bytes > 0) {
+            final int chunk = Math.min(bytes, chunkSize);
+            out.write(data, dstOffset, chunk);
+            bytes -= chunk;
+            dstOffset += chunk;
+        }
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/CloseShieldOutputStream.java b/src/main/java/org/apache/commons/io/output/CloseShieldOutputStream.java
new file mode 100644
index 0000000..a5c5485
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/CloseShieldOutputStream.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.OutputStream;
+
+/**
+ * Proxy stream that prevents the underlying output stream from being closed.
+ * <p>
+ * This class is typically used in cases where an output stream needs to be
+ * passed to a component that wants to explicitly close the stream even if other
+ * components would still use the stream for output.
+ * </p>
+ *
+ * @since 1.4
+ */
+public class CloseShieldOutputStream extends ProxyOutputStream {
+
+    /**
+     * Creates a proxy that shields the given output stream from being closed.
+     *
+     * @param outputStream the output stream to wrap
+     * @return the created proxy
+     * @since 2.9.0
+     */
+    public static CloseShieldOutputStream wrap(final OutputStream outputStream) {
+        return new CloseShieldOutputStream(outputStream);
+    }
+
+    /**
+     * Creates a proxy that shields the given output stream from being closed.
+     *
+     * @param outputStream underlying output stream
+     * @deprecated Using this constructor prevents IDEs from warning if the
+     *             underlying output stream is never closed. Use
+     *             {@link #wrap(OutputStream)} instead.
+     */
+    @Deprecated
+    public CloseShieldOutputStream(final OutputStream outputStream) {
+        super(outputStream);
+    }
+
+    /**
+     * Replaces the underlying output stream with a {@link ClosedOutputStream}
+     * sentinel. The original output stream will remain open, but this proxy will
+     * appear closed.
+     */
+    @Override
+    public void close() {
+        out = ClosedOutputStream.INSTANCE;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/CloseShieldWriter.java b/src/main/java/org/apache/commons/io/output/CloseShieldWriter.java
new file mode 100644
index 0000000..8d741ac
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/CloseShieldWriter.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.Writer;
+
+/**
+ * Proxy writer that prevents the underlying writer from being closed.
+ * <p>
+ * This class is typically used in cases where a writer needs to be passed to a
+ * component that wants to explicitly close the writer even if other components
+ * would still use the writer for output.
+ * </p>
+ *
+ * @since 2.7
+ */
+public class CloseShieldWriter extends ProxyWriter {
+
+    /**
+     * Creates a proxy that shields the given writer from being closed.
+     *
+     * @param writer the writer to wrap
+     * @return the created proxy
+     * @since 2.9.0
+     */
+    public static CloseShieldWriter wrap(final Writer writer) {
+        return new CloseShieldWriter(writer);
+    }
+
+    /**
+     * Creates a proxy that shields the given writer from being closed.
+     *
+     * @param writer underlying writer
+     * @deprecated Using this constructor prevents IDEs from warning if the
+     *             underlying writer is never closed. Use {@link #wrap(Writer)}
+     *             instead.
+     */
+    @Deprecated
+    public CloseShieldWriter(final Writer writer) {
+        super(writer);
+    }
+
+    /**
+     * Replaces the underlying writer with a {@link ClosedWriter} sentinel. The
+     * original writer will remain open, but this proxy will appear closed.
+     */
+    @Override
+    public void close() {
+        out = ClosedWriter.INSTANCE;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/ClosedOutputStream.java b/src/main/java/org/apache/commons/io/output/ClosedOutputStream.java
new file mode 100644
index 0000000..8050e46
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/ClosedOutputStream.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * Throws an IOException on all attempts to write to the stream.
+ * <p>
+ * Typically uses of this class include testing for corner cases in methods that accept an output stream and acting as a
+ * sentinel value instead of a {@code null} output stream.
+ * </p>
+ *
+ * @since 1.4
+ */
+public class ClosedOutputStream extends OutputStream {
+
+    /**
+     * The singleton instance.
+     *
+     * @since 2.12.0
+     */
+    public static final ClosedOutputStream INSTANCE = new ClosedOutputStream();
+
+    /**
+     * The singleton instance.
+     *
+     * @deprecated Use {@link #INSTANCE}.
+     */
+    @Deprecated
+    public static final ClosedOutputStream CLOSED_OUTPUT_STREAM = INSTANCE;
+
+    /**
+     * Throws an {@link IOException} to indicate that the stream is closed.
+     *
+     * @throws IOException always thrown
+     */
+    @Override
+    public void flush() throws IOException {
+        throw new IOException("flush() failed: stream is closed");
+    }
+
+    /**
+     * Throws an {@link IOException} to indicate that the stream is closed.
+     *
+     * @param b ignored
+     * @throws IOException always thrown
+     */
+    @Override
+    public void write(final int b) throws IOException {
+        throw new IOException("write(" + b + ") failed: stream is closed");
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/output/ClosedWriter.java b/src/main/java/org/apache/commons/io/output/ClosedWriter.java
new file mode 100644
index 0000000..7220ba1
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/ClosedWriter.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+import java.io.Writer;
+
+/**
+ * Throws an IOException on all attempts to write with {@link #close()} implemented as a noop.
+ * <p>
+ * Typically uses of this class include testing for corner cases in methods that accept a writer and acting as a
+ * sentinel value instead of a {@code null} writer.
+ * </p>
+ *
+ * @since 2.7
+ */
+public class ClosedWriter extends Writer {
+
+    /**
+     * The singleton instance.
+     *
+     * @since 2.12.0
+     */
+    public static final ClosedWriter INSTANCE = new ClosedWriter();
+
+    /**
+     * The singleton instance.
+     *
+     * @deprecated Use {@link #INSTANCE}.
+     */
+    @Deprecated
+    public static final ClosedWriter CLOSED_WRITER = INSTANCE;
+
+    @Override
+    public void close() throws IOException {
+        // noop
+    }
+
+    /**
+     * Throws an {@link IOException} to indicate that the stream is closed.
+     *
+     * @throws IOException always thrown
+     */
+    @Override
+    public void flush() throws IOException {
+        throw new IOException("flush() failed: stream is closed");
+    }
+
+    /**
+     * Throws an {@link IOException} to indicate that the writer is closed.
+     *
+     * @param cbuf ignored
+     * @param off ignored
+     * @param len ignored
+     * @throws IOException always thrown
+     */
+    @Override
+    public void write(final char[] cbuf, final int off, final int len) throws IOException {
+        throw new IOException("write(" + new String(cbuf) + ", " + off + ", " + len + ") failed: stream is closed");
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/output/CountingOutputStream.java b/src/main/java/org/apache/commons/io/output/CountingOutputStream.java
new file mode 100644
index 0000000..fe527a0
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/CountingOutputStream.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.OutputStream;
+
+/**
+ * A decorating output stream that counts the number of bytes that have passed
+ * through the stream so far.
+ * <p>
+ * A typical use case would be during debugging, to ensure that data is being
+ * written as expected.
+ * </p>
+ *
+ */
+public class CountingOutputStream extends ProxyOutputStream {
+
+    /** The count of bytes that have passed. */
+    private long count;
+
+    /**
+     * Constructs a new CountingOutputStream.
+     *
+     * @param out  the OutputStream to write to
+     */
+    public CountingOutputStream(final OutputStream out) {
+        super(out);
+    }
+
+
+    /**
+     * Updates the count with the number of bytes that are being written.
+     *
+     * @param n number of bytes to be written to the stream
+     * @since 2.0
+     */
+    @Override
+    protected synchronized void beforeWrite(final int n) {
+        count += n;
+    }
+
+    /**
+     * The number of bytes that have passed through this stream.
+     * <p>
+     * NOTE: This method is an alternative for {@code getCount()}.
+     * It was added because that method returns an integer which will
+     * result in incorrect count for files over 2GB.
+     *
+     * @return the number of bytes accumulated
+     * @since 1.3
+     */
+    public synchronized long getByteCount() {
+        return this.count;
+    }
+
+    /**
+     * The number of bytes that have passed through this stream.
+     * <p>
+     * NOTE: From v1.3 this method throws an ArithmeticException if the
+     * count is greater than can be expressed by an {@code int}.
+     * See {@link #getByteCount()} for a method using a {@code long}.
+     *
+     * @return the number of bytes accumulated
+     * @throws ArithmeticException if the byte count is too large
+     */
+    public int getCount() {
+        final long result = getByteCount();
+        if (result > Integer.MAX_VALUE) {
+            throw new ArithmeticException("The byte count " + result + " is too large to be converted to an int");
+        }
+        return (int) result;
+    }
+
+    /**
+     * Set the byte count back to 0.
+     * <p>
+     * NOTE: This method is an alternative for {@code resetCount()}.
+     * It was added because that method returns an integer which will
+     * result in incorrect count for files over 2GB.
+     *
+     * @return the count previous to resetting
+     * @since 1.3
+     */
+    public synchronized long resetByteCount() {
+        final long tmp = this.count;
+        this.count = 0;
+        return tmp;
+    }
+
+    /**
+     * Set the byte count back to 0.
+     * <p>
+     * NOTE: From v1.3 this method throws an ArithmeticException if the
+     * count is greater than can be expressed by an {@code int}.
+     * See {@link #resetByteCount()} for a method using a {@code long}.
+     *
+     * @return the count previous to resetting
+     * @throws ArithmeticException if the byte count is too large
+     */
+    public int resetCount() {
+        final long result = resetByteCount();
+        if (result > Integer.MAX_VALUE) {
+            throw new ArithmeticException("The byte count " + result + " is too large to be converted to an int");
+        }
+        return (int) result;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/DeferredFileOutputStream.java b/src/main/java/org/apache/commons/io/output/DeferredFileOutputStream.java
new file mode 100644
index 0000000..35d5cc6
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/DeferredFileOutputStream.java
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Objects;
+import java.util.function.Supplier;
+
+import org.apache.commons.io.file.PathUtils;
+
+/**
+ * An output stream which will retain data in memory until a specified threshold is reached, and only then commit it to
+ * disk. If the stream is closed before the threshold is reached, the data will not be written to disk at all.
+ * <p>
+ * This class originated in FileUpload processing. In this use case, you do not know in advance the size of the file
+ * being uploaded. If the file is small you want to store it in memory (for speed), but if the file is large you want to
+ * store it to file (to avoid memory issues).
+ * </p>
+ */
+public class DeferredFileOutputStream extends ThresholdingOutputStream {
+
+    /**
+     * The output stream to which data will be written prior to the threshold being reached.
+     */
+    private ByteArrayOutputStream memoryOutputStream;
+
+    /**
+     * The output stream to which data will be written at any given time. This will always be one of
+     * {@code memoryOutputStream} or {@code diskOutputStream}.
+     */
+    private OutputStream currentOutputStream;
+
+    /**
+     * The file to which output will be directed if the threshold is exceeded.
+     */
+    private Path outputPath;
+
+    /**
+     * The temporary file prefix.
+     */
+    private final String prefix;
+
+    /**
+     * The temporary file suffix.
+     */
+    private final String suffix;
+
+    /**
+     * The directory to use for temporary files.
+     */
+    private final Path directory;
+
+    /**
+     * True when close() has been called successfully.
+     */
+    private boolean closed;
+
+    /**
+     * Constructs an instance of this class which will trigger an event at the specified threshold, and save data to a
+     * file beyond that point. The initial buffer size will default to
+     * {@value AbstractByteArrayOutputStream#DEFAULT_SIZE} bytes which is ByteArrayOutputStream's default buffer size.
+     *
+     * @param threshold The number of bytes at which to trigger an event.
+     * @param outputFile The file to which data is saved beyond the threshold.
+     */
+    public DeferredFileOutputStream(final int threshold, final File outputFile) {
+        this(threshold, outputFile, null, null, null, AbstractByteArrayOutputStream.DEFAULT_SIZE);
+    }
+
+    /**
+     * Constructs an instance of this class which will trigger an event at the specified threshold, and save data either
+     * to a file beyond that point.
+     *
+     * @param threshold The number of bytes at which to trigger an event.
+     * @param outputFile The file to which data is saved beyond the threshold.
+     * @param prefix Prefix to use for the temporary file.
+     * @param suffix Suffix to use for the temporary file.
+     * @param directory Temporary file directory.
+     * @param initialBufferSize The initial size of the in memory buffer.
+     */
+    private DeferredFileOutputStream(final int threshold, final File outputFile, final String prefix,
+            final String suffix, final File directory, final int initialBufferSize) {
+        super(threshold);
+        this.outputPath = toPath(outputFile, null);
+        this.prefix = prefix;
+        this.suffix = suffix;
+        this.directory = toPath(directory, PathUtils::getTempDirectory);
+
+        memoryOutputStream = new ByteArrayOutputStream(initialBufferSize);
+        currentOutputStream = memoryOutputStream;
+    }
+
+    /**
+     * Constructs an instance of this class which will trigger an event at the specified threshold, and save data to a
+     * file beyond that point.
+     *
+     * @param threshold The number of bytes at which to trigger an event.
+     * @param initialBufferSize The initial size of the in memory buffer.
+     * @param outputFile The file to which data is saved beyond the threshold.
+     *
+     * @since 2.5
+     */
+    public DeferredFileOutputStream(final int threshold, final int initialBufferSize, final File outputFile) {
+        this(threshold, outputFile, null, null, null, initialBufferSize);
+        if (initialBufferSize < 0) {
+            throw new IllegalArgumentException("Initial buffer size must be at least 0.");
+        }
+    }
+
+    /**
+     * Constructs an instance of this class which will trigger an event at the specified threshold, and save data to a
+     * temporary file beyond that point.
+     *
+     * @param threshold The number of bytes at which to trigger an event.
+     * @param initialBufferSize The initial size of the in memory buffer.
+     * @param prefix Prefix to use for the temporary file.
+     * @param suffix Suffix to use for the temporary file.
+     * @param directory Temporary file directory.
+     *
+     * @since 2.5
+     */
+    public DeferredFileOutputStream(final int threshold, final int initialBufferSize, final String prefix,
+        final String suffix, final File directory) {
+        this(threshold, null, prefix, suffix, directory, initialBufferSize);
+        Objects.requireNonNull(prefix, "prefix");
+        if (initialBufferSize < 0) {
+            throw new IllegalArgumentException("Initial buffer size must be at least 0.");
+        }
+    }
+
+    /**
+     * Constructs an instance of this class which will trigger an event at the specified threshold, and save data to a
+     * temporary file beyond that point. The initial buffer size will default to 32 bytes which is
+     * ByteArrayOutputStream's default buffer size.
+     *
+     * @param threshold The number of bytes at which to trigger an event.
+     * @param prefix Prefix to use for the temporary file.
+     * @param suffix Suffix to use for the temporary file.
+     * @param directory Temporary file directory.
+     *
+     * @since 1.4
+     */
+    public DeferredFileOutputStream(final int threshold, final String prefix, final String suffix,
+        final File directory) {
+        this(threshold, null, prefix, suffix, directory, AbstractByteArrayOutputStream.DEFAULT_SIZE);
+        Objects.requireNonNull(prefix, "prefix");
+    }
+
+    /**
+     * Closes underlying output stream, and mark this as closed
+     *
+     * @throws IOException if an error occurs.
+     */
+    @Override
+    public void close() throws IOException {
+        super.close();
+        closed = true;
+    }
+
+    /**
+     * Gets the data for this output stream as an array of bytes, assuming that the data has been retained in memory.
+     * If the data was written to disk, this method returns {@code null}.
+     *
+     * @return The data for this output stream, or {@code null} if no such data is available.
+     */
+    public byte[] getData() {
+        return memoryOutputStream != null ? memoryOutputStream.toByteArray() : null;
+    }
+
+    /**
+     * Gets either the output file specified in the constructor or the temporary file created or null.
+     * <p>
+     * If the constructor specifying the file is used then it returns that same output file, even when threshold has not
+     * been reached.
+     * <p>
+     * If constructor specifying a temporary file prefix/suffix is used then the temporary file created once the
+     * threshold is reached is returned If the threshold was not reached then {@code null} is returned.
+     *
+     * @return The file for this output stream, or {@code null} if no such file exists.
+     */
+    public File getFile() {
+        return outputPath != null ? outputPath.toFile() : null;
+    }
+
+    /**
+     * Gets the current output stream. This may be memory based or disk based, depending on the current state with
+     * respect to the threshold.
+     *
+     * @return The underlying output stream.
+     *
+     * @throws IOException if an error occurs.
+     */
+    @Override
+    protected OutputStream getStream() throws IOException {
+        return currentOutputStream;
+    }
+
+    /**
+     * Tests whether or not the data for this output stream has been retained in memory.
+     *
+     * @return {@code true} if the data is available in memory; {@code false} otherwise.
+     */
+    public boolean isInMemory() {
+        return !isThresholdExceeded();
+    }
+
+    /**
+     * Switches the underlying output stream from a memory based stream to one that is backed by disk. This is the point
+     * at which we realize that too much data is being written to keep in memory, so we elect to switch to disk-based
+     * storage.
+     *
+     * @throws IOException if an error occurs.
+     */
+    @Override
+    protected void thresholdReached() throws IOException {
+        if (prefix != null) {
+            outputPath = Files.createTempFile(directory, prefix, suffix);
+        }
+        PathUtils.createParentDirectories(outputPath);
+        final OutputStream fos = Files.newOutputStream(outputPath);
+        try {
+            memoryOutputStream.writeTo(fos);
+        } catch (final IOException e) {
+            fos.close();
+            throw e;
+        }
+        currentOutputStream = fos;
+        memoryOutputStream = null;
+    }
+
+    /**
+     * Converts the current contents of this byte stream to an {@link InputStream}.
+     * If the data for this output stream has been retained in memory, the
+     * returned stream is backed by buffers of {@code this} stream,
+     * avoiding memory allocation and copy, thus saving space and time.<br>
+     * Otherwise, the returned stream will be one that is created from the data
+     * that has been committed to disk.
+     *
+     * @return the current contents of this output stream.
+     * @throws IOException if this stream is not yet closed or an error occurs.
+     * @see org.apache.commons.io.output.ByteArrayOutputStream#toInputStream()
+     *
+     * @since 2.9.0
+     */
+    public InputStream toInputStream() throws IOException {
+        // we may only need to check if this is closed if we are working with a file
+        // but we should force the habit of closing whether we are working with
+        // a file or memory.
+        if (!closed) {
+            throw new IOException("Stream not closed");
+        }
+
+        if (isInMemory()) {
+            return memoryOutputStream.toInputStream();
+        }
+        return Files.newInputStream(outputPath);
+    }
+
+    private Path toPath(final File file, final Supplier<Path> defaultPathSupplier) {
+        return file != null ? file.toPath() : defaultPathSupplier == null ? null : defaultPathSupplier.get();
+    }
+
+    /**
+     * Writes the data from this output stream to the specified output stream, after it has been closed.
+     *
+     * @param outputStream output stream to write to.
+     * @throws NullPointerException if the OutputStream is {@code null}.
+     * @throws IOException if this stream is not yet closed or an error occurs.
+     */
+    public void writeTo(final OutputStream outputStream) throws IOException {
+        // we may only need to check if this is closed if we are working with a file
+        // but we should force the habit of closing whether we are working with
+        // a file or memory.
+        if (!closed) {
+            throw new IOException("Stream not closed");
+        }
+
+        if (isInMemory()) {
+            memoryOutputStream.writeTo(outputStream);
+        } else {
+            Files.copy(outputPath, outputStream);
+        }
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/output/DemuxOutputStream.java b/src/main/java/org/apache/commons/io/output/DemuxOutputStream.java
new file mode 100644
index 0000000..0b43020
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/DemuxOutputStream.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.apache.commons.io.IOUtils;
+
+/**
+ * Forwards data to a stream that has been associated with this thread.
+ *
+ */
+public class DemuxOutputStream extends OutputStream {
+    private final InheritableThreadLocal<OutputStream> outputStreamThreadLocal = new InheritableThreadLocal<>();
+
+    /**
+     * Binds the specified stream to the current thread.
+     *
+     * @param output
+     *            the stream to bind
+     * @return the OutputStream that was previously active
+     */
+    public OutputStream bindStream(final OutputStream output) {
+        final OutputStream stream = outputStreamThreadLocal.get();
+        outputStreamThreadLocal.set(output);
+        return stream;
+    }
+
+    /**
+     * Closes stream associated with current thread.
+     *
+     * @throws IOException
+     *             if an error occurs
+     */
+    @SuppressWarnings("resource") // we actually close the stream here
+    @Override
+    public void close() throws IOException {
+        IOUtils.close(outputStreamThreadLocal.get());
+    }
+
+    /**
+     * Flushes stream associated with current thread.
+     *
+     * @throws IOException
+     *             if an error occurs
+     */
+    @Override
+    public void flush() throws IOException {
+        @SuppressWarnings("resource")
+        final OutputStream output = outputStreamThreadLocal.get();
+        if (null != output) {
+            output.flush();
+        }
+    }
+
+    /**
+     * Writes byte to stream associated with current thread.
+     *
+     * @param ch
+     *            the byte to write to stream
+     * @throws IOException
+     *             if an error occurs
+     */
+    @Override
+    public void write(final int ch) throws IOException {
+        @SuppressWarnings("resource")
+        final OutputStream output = outputStreamThreadLocal.get();
+        if (null != output) {
+            output.write(ch);
+        }
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/output/FileWriterWithEncoding.java b/src/main/java/org/apache/commons/io/output/FileWriterWithEncoding.java
new file mode 100644
index 0000000..aacde96
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/FileWriterWithEncoding.java
@@ -0,0 +1,239 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetEncoder;
+import java.util.Objects;
+
+import org.apache.commons.io.Charsets;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.IOUtils;
+
+/**
+ * Writer of files that allows the encoding to be set.
+ * <p>
+ * This class provides a simple alternative to {@link FileWriter} that allows an encoding to be set. Unfortunately, it
+ * cannot subclass {@link FileWriter}.
+ * </p>
+ * <p>
+ * By default, the file will be overwritten, but this may be changed to append.
+ * </p>
+ * <p>
+ * The encoding must be specified using either the name of the {@link Charset}, the {@link Charset}, or a
+ * {@link CharsetEncoder}. If the default encoding is required then use the {@link java.io.FileWriter} directly, rather
+ * than this implementation.
+ * </p>
+ *
+ * @since 1.4
+ */
+public class FileWriterWithEncoding extends ProxyWriter {
+
+    /**
+     * Initializes the wrapped file writer. Ensure that a cleanup occurs if the writer creation fails.
+     *
+     * @param file the file to be accessed
+     * @param encoding the encoding to use - may be Charset, CharsetEncoder or String, null uses the default Charset.
+     * @param append true to append
+     * @return the initialized writer
+     * @throws IOException if an error occurs
+     */
+    private static Writer initWriter(final File file, final Object encoding, final boolean append) throws IOException {
+        Objects.requireNonNull(file, "file");
+        OutputStream outputStream = null;
+        final boolean fileExistedAlready = file.exists();
+        try {
+            outputStream = FileUtils.newOutputStream(file, append);
+            if (encoding == null || encoding instanceof Charset) {
+                return new OutputStreamWriter(outputStream, Charsets.toCharset((Charset) encoding));
+            }
+            if (encoding instanceof CharsetEncoder) {
+                return new OutputStreamWriter(outputStream, (CharsetEncoder) encoding);
+            }
+            return new OutputStreamWriter(outputStream, (String) encoding);
+        } catch (final IOException | RuntimeException ex) {
+            try {
+                IOUtils.close(outputStream);
+            } catch (final IOException e) {
+                ex.addSuppressed(e);
+            }
+            if (!fileExistedAlready) {
+                FileUtils.deleteQuietly(file);
+            }
+            throw ex;
+        }
+    }
+
+    /**
+     * Constructs a FileWriterWithEncoding with a file encoding.
+     *
+     * @param file the file to write to, not null
+     * @param charset the encoding to use, not null
+     * @throws NullPointerException if the file or encoding is null
+     * @throws IOException in case of an I/O error
+     */
+    public FileWriterWithEncoding(final File file, final Charset charset) throws IOException {
+        this(file, charset, false);
+    }
+
+    /**
+     * Constructs a FileWriterWithEncoding with a file encoding.
+     *
+     * @param file the file to write to, not null.
+     * @param encoding the name of the requested charset, null uses the default Charset.
+     * @param append true if content should be appended, false to overwrite.
+     * @throws NullPointerException if the file is null.
+     * @throws IOException in case of an I/O error.
+     */
+    @SuppressWarnings("resource") // Call site is responsible for closing a new instance.
+    public FileWriterWithEncoding(final File file, final Charset encoding, final boolean append) throws IOException {
+        super(initWriter(file, encoding, append));
+    }
+
+    /**
+     * Constructs a FileWriterWithEncoding with a file encoding.
+     *
+     * @param file the file to write to, not null
+     * @param charsetEncoder the encoding to use, not null
+     * @throws NullPointerException if the file or encoding is null
+     * @throws IOException in case of an I/O error
+     */
+    public FileWriterWithEncoding(final File file, final CharsetEncoder charsetEncoder) throws IOException {
+        this(file, charsetEncoder, false);
+    }
+
+    /**
+     * Constructs a FileWriterWithEncoding with a file encoding.
+     *
+     * @param file the file to write to, not null.
+     * @param charsetEncoder the encoding to use, null uses the default Charset.
+     * @param append true if content should be appended, false to overwrite.
+     * @throws NullPointerException if the file is null.
+     * @throws IOException in case of an I/O error.
+     */
+    @SuppressWarnings("resource") // Call site is responsible for closing a new instance.
+    public FileWriterWithEncoding(final File file, final CharsetEncoder charsetEncoder, final boolean append) throws IOException {
+        super(initWriter(file, charsetEncoder, append));
+    }
+
+    /**
+     * Constructs a FileWriterWithEncoding with a file encoding.
+     *
+     * @param file the file to write to, not null
+     * @param charsetName the name of the requested charset, not null
+     * @throws NullPointerException if the file or encoding is null
+     * @throws IOException in case of an I/O error
+     */
+    public FileWriterWithEncoding(final File file, final String charsetName) throws IOException {
+        this(file, charsetName, false);
+    }
+
+    /**
+     * Constructs a FileWriterWithEncoding with a file encoding.
+     *
+     * @param file the file to write to, not null.
+     * @param charsetName the name of the requested charset, null uses the default Charset.
+     * @param append true if content should be appended, false to overwrite.
+     * @throws NullPointerException if the file is null.
+     * @throws IOException in case of an I/O error.
+     */
+    @SuppressWarnings("resource") // Call site is responsible for closing a new instance.
+    public FileWriterWithEncoding(final File file, final String charsetName, final boolean append) throws IOException {
+        super(initWriter(file, charsetName, append));
+    }
+
+    /**
+     * Constructs a FileWriterWithEncoding with a file encoding.
+     *
+     * @param fileName the name of the file to write to, not null
+     * @param charset the charset to use, not null
+     * @throws NullPointerException if the file name or encoding is null
+     * @throws IOException in case of an I/O error
+     */
+    public FileWriterWithEncoding(final String fileName, final Charset charset) throws IOException {
+        this(new File(fileName), charset, false);
+    }
+
+    /**
+     * Constructs a FileWriterWithEncoding with a file encoding.
+     *
+     * @param fileName the name of the file to write to, not null
+     * @param charset the encoding to use, not null
+     * @param append true if content should be appended, false to overwrite
+     * @throws NullPointerException if the file name or encoding is null
+     * @throws IOException in case of an I/O error
+     */
+    public FileWriterWithEncoding(final String fileName, final Charset charset, final boolean append) throws IOException {
+        this(new File(fileName), charset, append);
+    }
+
+    /**
+     * Constructs a FileWriterWithEncoding with a file encoding.
+     *
+     * @param fileName the name of the file to write to, not null
+     * @param encoding the encoding to use, not null
+     * @throws NullPointerException if the file name or encoding is null
+     * @throws IOException in case of an I/O error
+     */
+    public FileWriterWithEncoding(final String fileName, final CharsetEncoder encoding) throws IOException {
+        this(new File(fileName), encoding, false);
+    }
+
+    /**
+     * Constructs a FileWriterWithEncoding with a file encoding.
+     *
+     * @param fileName the name of the file to write to, not null
+     * @param charsetEncoder the encoding to use, not null
+     * @param append true if content should be appended, false to overwrite
+     * @throws NullPointerException if the file name or encoding is null
+     * @throws IOException in case of an I/O error
+     */
+    public FileWriterWithEncoding(final String fileName, final CharsetEncoder charsetEncoder, final boolean append) throws IOException {
+        this(new File(fileName), charsetEncoder, append);
+    }
+
+    /**
+     * Constructs a FileWriterWithEncoding with a file encoding.
+     *
+     * @param fileName the name of the file to write to, not null
+     * @param charsetName the name of the requested charset, not null
+     * @throws NullPointerException if the file name or encoding is null
+     * @throws IOException in case of an I/O error
+     */
+    public FileWriterWithEncoding(final String fileName, final String charsetName) throws IOException {
+        this(new File(fileName), charsetName, false);
+    }
+
+    /**
+     * Constructs a FileWriterWithEncoding with a file encoding.
+     *
+     * @param fileName the name of the file to write to, not null
+     * @param charsetName the name of the requested charset, not null
+     * @param append true if content should be appended, false to overwrite
+     * @throws NullPointerException if the file name or encoding is null
+     * @throws IOException in case of an I/O error
+     */
+    public FileWriterWithEncoding(final String fileName, final String charsetName, final boolean append) throws IOException {
+        this(new File(fileName), charsetName, append);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/output/FilterCollectionWriter.java b/src/main/java/org/apache/commons/io/output/FilterCollectionWriter.java
new file mode 100644
index 0000000..f6c0aa8
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/FilterCollectionWriter.java
@@ -0,0 +1,169 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.output;
+
+import java.io.FilterWriter;
+import java.io.IOException;
+import java.io.Writer;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.IOExceptionList;
+import org.apache.commons.io.IOIndexedException;
+import org.apache.commons.io.function.IOConsumer;
+
+/**
+ * Abstract class for writing filtered character streams to a {@link Collection} of writers. This is in contrast to
+ * {@link FilterWriter} which is backed by a single {@link Writer}.
+ * <p>
+ * This abstract class provides default methods that pass all requests to the contained writers. Subclasses should
+ * likely override some of these methods.
+ * </p>
+ * <p>
+ * The class {@link Writer} defines method signatures with {@code throws} {@link IOException}, which in this class are
+ * actually {@link IOExceptionList} containing a list of {@link IOIndexedException}.
+ * </p>
+ *
+ * @since 2.7
+ */
+public class FilterCollectionWriter extends Writer {
+
+    /**
+     * Empty and immutable collection of writers.
+     */
+    protected final Collection<Writer> EMPTY_WRITERS = Collections.emptyList();
+
+    /**
+     * The underlying writers.
+     */
+    protected final Collection<Writer> writers;
+
+    /**
+     * Creates a new filtered collection writer.
+     *
+     * @param writers Writers to provide the underlying targets.
+     */
+    protected FilterCollectionWriter(final Collection<Writer> writers) {
+        this.writers = writers == null ? EMPTY_WRITERS : writers;
+    }
+
+    /**
+     * Creates a new filtered collection writer.
+     *
+     * @param writers Writers to provide the underlying targets.
+     */
+    protected FilterCollectionWriter(final Writer... writers) {
+        this.writers = writers == null ? EMPTY_WRITERS : Arrays.asList(writers);
+    }
+
+    @Override
+    public Writer append(final char c) throws IOException {
+        return forAllWriters(w -> w.append(c));
+    }
+
+    @Override
+    public Writer append(final CharSequence csq) throws IOException {
+        return forAllWriters(w -> w.append(csq));
+    }
+
+    @Override
+    public Writer append(final CharSequence csq, final int start, final int end) throws IOException {
+        return forAllWriters(w -> w.append(csq, start, end));
+    }
+
+    @SuppressWarnings("resource") // no allocation
+    @Override
+    public void close() throws IOException {
+        forAllWriters(Writer::close);
+    }
+
+    /**
+     * Flushes the stream.
+     *
+     * @throws IOException If an I/O error occurs
+     */
+    @SuppressWarnings("resource") // no allocation
+    @Override
+    public void flush() throws IOException {
+        forAllWriters(Writer::flush);
+    }
+
+    private FilterCollectionWriter forAllWriters(final IOConsumer<Writer> action) throws IOExceptionList {
+        IOConsumer.forAll(action, writers());
+        return this;
+    }
+
+    @SuppressWarnings("resource") // no allocation
+    @Override
+    public void write(final char[] cbuf) throws IOException {
+        forAllWriters(w -> w.write(cbuf));
+    }
+
+    /**
+     * Writes a portion of an array of characters.
+     *
+     * @param cbuf Buffer of characters to be written
+     * @param off  Offset from which to start reading characters
+     * @param len  Number of characters to be written
+     * @throws IOException If an I/O error occurs
+     */
+    @SuppressWarnings("resource") // no allocation
+    @Override
+    public void write(final char[] cbuf, final int off, final int len) throws IOException {
+        forAllWriters(w -> w.write(cbuf, off, len));
+    }
+
+    /**
+     * Writes a single character.
+     *
+     * @throws IOException If an I/O error occurs
+     */
+    @SuppressWarnings("resource") // no allocation
+    @Override
+    public void write(final int c) throws IOException {
+        forAllWriters(w -> w.write(c));
+    }
+
+    @SuppressWarnings("resource") // no allocation
+    @Override
+    public void write(final String str) throws IOException {
+        forAllWriters(w -> w.write(str));
+    }
+
+    /**
+     * Writes a portion of a string.
+     *
+     * @param str String to be written
+     * @param off Offset from which to start reading characters
+     * @param len Number of characters to be written
+     * @throws IOException If an I/O error occurs
+     */
+    @SuppressWarnings("resource") // no allocation
+    @Override
+    public void write(final String str, final int off, final int len) throws IOException {
+        forAllWriters(w -> w.write(str, off, len));
+    }
+
+    private Stream<Writer> writers() {
+        return writers.stream().filter(Objects::nonNull);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/LockableFileWriter.java b/src/main/java/org/apache/commons/io/output/LockableFileWriter.java
new file mode 100644
index 0000000..4f63cf4
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/LockableFileWriter.java
@@ -0,0 +1,353 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.Charset;
+
+import org.apache.commons.io.Charsets;
+import org.apache.commons.io.FileUtils;
+
+/**
+ * FileWriter that will create and honor lock files to allow simple
+ * cross thread file lock handling.
+ * <p>
+ * This class provides a simple alternative to {@link FileWriter}
+ * that will use a lock file to prevent duplicate writes.
+ * </p>
+ * <p>
+ * <b>Note:</b> The lock file is deleted when {@link #close()} is called
+ * - or if the main file cannot be opened initially.
+ * In the (unlikely) event that the lock file cannot be deleted,
+ * an exception is thrown.
+ * </p>
+ * <p>
+ * By default, the file will be overwritten, but this may be changed to append.
+ * The lock directory may be specified, but defaults to the system property
+ * {@code java.io.tmpdir}.
+ * The encoding may also be specified, and defaults to the platform default.
+ * </p>
+ */
+public class LockableFileWriter extends Writer {
+    // Cannot extend ProxyWriter, as requires writer to be
+    // known when super() is called
+
+    /** The extension for the lock file. */
+    private static final String LCK = ".lck";
+
+    /** The writer to decorate. */
+    private final Writer out;
+
+    /** The lock file. */
+    private final File lockFile;
+
+    /**
+     * Constructs a LockableFileWriter.
+     * If the file exists, it is overwritten.
+     *
+     * @param file  the file to write to, not null
+     * @throws NullPointerException if the file is null
+     * @throws IOException in case of an I/O error
+     */
+    public LockableFileWriter(final File file) throws IOException {
+        this(file, false, null);
+    }
+
+    /**
+     * Constructs a LockableFileWriter.
+     *
+     * @param file  the file to write to, not null
+     * @param append  true if content should be appended, false to overwrite
+     * @throws NullPointerException if the file is null
+     * @throws IOException in case of an I/O error
+     */
+    public LockableFileWriter(final File file, final boolean append) throws IOException {
+        this(file, append, null);
+    }
+
+    /**
+     * Constructs a LockableFileWriter.
+     *
+     * @param file  the file to write to, not null
+     * @param append  true if content should be appended, false to overwrite
+     * @param lockDir  the directory in which the lock file should be held
+     * @throws NullPointerException if the file is null
+     * @throws IOException in case of an I/O error
+     * @deprecated 2.5 use {@link #LockableFileWriter(File, Charset, boolean, String)} instead
+     */
+    @Deprecated
+    public LockableFileWriter(final File file, final boolean append, final String lockDir) throws IOException {
+        this(file, Charset.defaultCharset(), append, lockDir);
+    }
+
+    /**
+     * Constructs a LockableFileWriter with a file encoding.
+     *
+     * @param file  the file to write to, not null
+     * @param charset  the charset to use, null means platform default
+     * @throws NullPointerException if the file is null
+     * @throws IOException in case of an I/O error
+     * @since 2.3
+     */
+    public LockableFileWriter(final File file, final Charset charset) throws IOException {
+        this(file, charset, false, null);
+    }
+
+    /**
+     * Constructs a LockableFileWriter with a file encoding.
+     *
+     * @param file  the file to write to, not null
+     * @param charset  the name of the requested charset, null means platform default
+     * @param append  true if content should be appended, false to overwrite
+     * @param lockDir  the directory in which the lock file should be held
+     * @throws NullPointerException if the file is null
+     * @throws IOException in case of an I/O error
+     * @since 2.3
+     */
+    public LockableFileWriter(final File file, final Charset charset, final boolean append, String lockDir) throws IOException {
+        // init file to create/append
+        final File absFile = file.getAbsoluteFile();
+        if (absFile.getParentFile() != null) {
+            FileUtils.forceMkdir(absFile.getParentFile());
+        }
+        if (absFile.isDirectory()) {
+            throw new IOException("File specified is a directory");
+        }
+
+        // init lock file
+        if (lockDir == null) {
+            lockDir = FileUtils.getTempDirectoryPath();
+        }
+        final File lockDirFile = new File(lockDir);
+        FileUtils.forceMkdir(lockDirFile);
+        testLockDir(lockDirFile);
+        lockFile = new File(lockDirFile, absFile.getName() + LCK);
+
+        // check if locked
+        createLock();
+
+        // init wrapped writer
+        out = initWriter(absFile, charset, append);
+    }
+
+    /**
+     * Constructs a LockableFileWriter with a file encoding.
+     *
+     * @param file  the file to write to, not null
+     * @param charsetName  the name of the requested charset, null means platform default
+     * @throws NullPointerException if the file is null
+     * @throws IOException in case of an I/O error
+     * @throws java.nio.charset.UnsupportedCharsetException
+     *             thrown instead of {@link java.io.UnsupportedEncodingException} in version 2.2 if the encoding is not
+     *             supported.
+     */
+    public LockableFileWriter(final File file, final String charsetName) throws IOException {
+        this(file, charsetName, false, null);
+    }
+
+    /**
+     * Constructs a LockableFileWriter with a file encoding.
+     *
+     * @param file  the file to write to, not null
+     * @param charsetName  the encoding to use, null means platform default
+     * @param append  true if content should be appended, false to overwrite
+     * @param lockDir  the directory in which the lock file should be held
+     * @throws NullPointerException if the file is null
+     * @throws IOException in case of an I/O error
+     * @throws java.nio.charset.UnsupportedCharsetException
+     *             thrown instead of {@link java.io.UnsupportedEncodingException} in version 2.2 if the encoding is not
+     *             supported.
+     */
+    public LockableFileWriter(final File file, final String charsetName, final boolean append,
+            final String lockDir) throws IOException {
+        this(file, Charsets.toCharset(charsetName), append, lockDir);
+    }
+
+    /**
+     * Constructs a LockableFileWriter.
+     * If the file exists, it is overwritten.
+     *
+     * @param fileName  the file to write to, not null
+     * @throws NullPointerException if the file is null
+     * @throws IOException in case of an I/O error
+     */
+    public LockableFileWriter(final String fileName) throws IOException {
+        this(fileName, false, null);
+    }
+
+    /**
+     * Constructs a LockableFileWriter.
+     *
+     * @param fileName  file to write to, not null
+     * @param append  true if content should be appended, false to overwrite
+     * @throws NullPointerException if the file is null
+     * @throws IOException in case of an I/O error
+     */
+    public LockableFileWriter(final String fileName, final boolean append) throws IOException {
+        this(fileName, append, null);
+    }
+
+    /**
+     * Constructs a LockableFileWriter.
+     *
+     * @param fileName  the file to write to, not null
+     * @param append  true if content should be appended, false to overwrite
+     * @param lockDir  the directory in which the lock file should be held
+     * @throws NullPointerException if the file is null
+     * @throws IOException in case of an I/O error
+     */
+    public LockableFileWriter(final String fileName, final boolean append, final String lockDir) throws IOException {
+        this(new File(fileName), append, lockDir);
+    }
+
+    /**
+     * Closes the file writer and deletes the lock file.
+     *
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void close() throws IOException {
+        try {
+            out.close();
+        } finally {
+            FileUtils.delete(lockFile);
+        }
+    }
+
+    /**
+     * Creates the lock file.
+     *
+     * @throws IOException if we cannot create the file
+     */
+    private void createLock() throws IOException {
+        synchronized (LockableFileWriter.class) {
+            if (!lockFile.createNewFile()) {
+                throw new IOException("Can't write file, lock " + lockFile.getAbsolutePath() + " exists");
+            }
+            lockFile.deleteOnExit();
+        }
+    }
+
+    /**
+     * Flushes the stream.
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void flush() throws IOException {
+        out.flush();
+    }
+
+    /**
+     * Initializes the wrapped file writer.
+     * Ensure that a cleanup occurs if the writer creation fails.
+     *
+     * @param file  the file to be accessed
+     * @param charset  the charset to use
+     * @param append  true to append
+     * @return The initialized writer
+     * @throws IOException if an error occurs
+     */
+    private Writer initWriter(final File file, final Charset charset, final boolean append) throws IOException {
+        final boolean fileExistedAlready = file.exists();
+        try {
+            return new OutputStreamWriter(new FileOutputStream(file.getAbsolutePath(), append), Charsets.toCharset(charset));
+
+        } catch (final IOException | RuntimeException ex) {
+            FileUtils.deleteQuietly(lockFile);
+            if (!fileExistedAlready) {
+                FileUtils.deleteQuietly(file);
+            }
+            throw ex;
+        }
+    }
+
+    /**
+     * Tests that we can write to the lock directory.
+     *
+     * @param lockDir  the File representing the lock directory
+     * @throws IOException if we cannot write to the lock directory
+     * @throws IOException if we cannot find the lock file
+     */
+    private void testLockDir(final File lockDir) throws IOException {
+        if (!lockDir.exists()) {
+            throw new IOException("Could not find lockDir: " + lockDir.getAbsolutePath());
+        }
+        if (!lockDir.canWrite()) {
+            throw new IOException("Could not write to lockDir: " + lockDir.getAbsolutePath());
+        }
+    }
+
+    /**
+     * Writes the characters from an array.
+     * @param cbuf the characters to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final char[] cbuf) throws IOException {
+        out.write(cbuf);
+    }
+
+    /**
+     * Writes the specified characters from an array.
+     * @param cbuf the characters to write
+     * @param off The start offset
+     * @param len The number of characters to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final char[] cbuf, final int off, final int len) throws IOException {
+        out.write(cbuf, off, len);
+    }
+
+    /**
+     * Writes a character.
+     * @param c the character to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final int c) throws IOException {
+        out.write(c);
+    }
+
+    /**
+     * Writes the characters from a string.
+     * @param str the string to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final String str) throws IOException {
+        out.write(str);
+    }
+
+    /**
+     * Writes the specified characters from a string.
+     * @param str the string to write
+     * @param off The start offset
+     * @param len The number of characters to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final String str, final int off, final int len) throws IOException {
+        out.write(str, off, len);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/NullAppendable.java b/src/main/java/org/apache/commons/io/output/NullAppendable.java
new file mode 100644
index 0000000..e2eaf33
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/NullAppendable.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+
+/**
+ * Appends all data to the famous <b>/dev/null</b>.
+ * <p>
+ * This Appendable has no destination (file/socket etc.) and all characters written to it are ignored and lost.
+ * </p>
+ *
+ * @since 2.8.0
+ */
+public class NullAppendable implements Appendable {
+
+    /**
+     * A singleton.
+     */
+    public static final NullAppendable INSTANCE = new NullAppendable();
+
+    /** Use the singleton. */
+    private NullAppendable() {
+        // no instances.
+    }
+
+    @Override
+    public Appendable append(final char c) throws IOException {
+        return this;
+    }
+
+    @Override
+    public Appendable append(final CharSequence csq) throws IOException {
+        return this;
+    }
+
+    @Override
+    public Appendable append(final CharSequence csq, final int start, final int end) throws IOException {
+        return this;
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/NullOutputStream.java b/src/main/java/org/apache/commons/io/output/NullOutputStream.java
new file mode 100644
index 0000000..0fd2a53
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/NullOutputStream.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * Never writes data. Calls never go beyond this class.
+ * <p>
+ * This output stream has no destination (file/socket etc.) and all bytes written to it are ignored and lost.
+ * </p>
+ */
+public class NullOutputStream extends OutputStream {
+
+    /**
+     * The singleton instance.
+     *
+     * @since 2.12.0
+     */
+    public static final NullOutputStream INSTANCE = new NullOutputStream();
+
+    /**
+     * The singleton instance.
+     *
+     * @deprecated Use {@link #INSTANCE}.
+     */
+    @Deprecated
+    public static final NullOutputStream NULL_OUTPUT_STREAM = INSTANCE;
+
+    /**
+     * Deprecated in favor of {@link #NULL_OUTPUT_STREAM}.
+     *
+     * TODO: Will be private in 3.0.
+     *
+     * @deprecated Use {@link #NULL_OUTPUT_STREAM}.
+     */
+    @Deprecated
+    public NullOutputStream() {
+    }
+
+    /**
+     * Does nothing - output to {@code /dev/null}.
+     *
+     * @param b The bytes to write
+     * @throws IOException never
+     */
+    @Override
+    public void write(final byte[] b) throws IOException {
+        // To /dev/null
+    }
+
+    /**
+     * Does nothing - output to {@code /dev/null}.
+     *
+     * @param b The bytes to write
+     * @param off The start offset
+     * @param len The number of bytes to write
+     */
+    @Override
+    public void write(final byte[] b, final int off, final int len) {
+        // To /dev/null
+    }
+
+    /**
+     * Does nothing - output to {@code /dev/null}.
+     *
+     * @param b The byte to write
+     */
+    @Override
+    public void write(final int b) {
+        // To /dev/null
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/NullPrintStream.java b/src/main/java/org/apache/commons/io/output/NullPrintStream.java
new file mode 100644
index 0000000..c882c75
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/NullPrintStream.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.output;
+
+import java.io.PrintStream;
+
+/**
+ * Never prints data. Calls never go beyond this class.
+ * <p>
+ * This print stream has no destination (file/socket etc.) and all bytes written to it are ignored and lost.
+ * </p>
+ *
+ * @since 2.7
+ */
+public class NullPrintStream extends PrintStream {
+
+    /**
+     * The singleton instance.
+     *
+     * @since 2.12.0
+     */
+    public static final NullPrintStream INSTANCE = new NullPrintStream();
+
+    /**
+     * The singleton instance.
+     *
+     * @deprecated Use {@link #INSTANCE}.
+     */
+    @Deprecated
+    public static final NullPrintStream NULL_PRINT_STREAM = INSTANCE;
+
+    /**
+     * Constructs an instance.
+     */
+    public NullPrintStream() {
+        // Relies on the default charset which is OK since we are not actually writing.
+        super(NullOutputStream.INSTANCE);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/NullWriter.java b/src/main/java/org/apache/commons/io/output/NullWriter.java
new file mode 100644
index 0000000..1fb9e31
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/NullWriter.java
@@ -0,0 +1,149 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.Writer;
+
+/**
+ * Never writes data. Calls never go beyond this class.
+ * <p>
+ * This {@link Writer} has no destination (file/socket etc.) and all characters written to it are ignored and lost.
+ * </p>
+ */
+public class NullWriter extends Writer {
+
+    /**
+     * The singleton instance.
+     *
+     * @since 2.12.0
+     */
+    public static final NullWriter INSTANCE = new NullWriter();
+
+    /**
+     * The singleton instance.
+     *
+     * @deprecated Use {@link #INSTANCE}.
+     */
+    @Deprecated
+    public static final NullWriter NULL_WRITER = INSTANCE;
+
+    /**
+     * Constructs a new NullWriter.
+     */
+    public NullWriter() {
+    }
+
+    /**
+     * Does nothing - output to {@code /dev/null}.
+     * @param c The character to write
+     * @return this writer
+     * @since 2.0
+     */
+    @Override
+    public Writer append(final char c) {
+        //to /dev/null
+        return this;
+    }
+
+    /**
+     * Does nothing - output to {@code /dev/null}.
+     * @param csq The character sequence to write
+     * @return this writer
+     * @since 2.0
+     */
+    @Override
+    public Writer append(final CharSequence csq) {
+        //to /dev/null
+        return this;
+    }
+
+    /**
+     * Does nothing - output to {@code /dev/null}.
+     * @param csq The character sequence to write
+     * @param start The index of the first character to write
+     * @param end  The index of the first character to write (exclusive)
+     * @return this writer
+     * @since 2.0
+     */
+    @Override
+    public Writer append(final CharSequence csq, final int start, final int end) {
+        //to /dev/null
+        return this;
+    }
+
+    /** @see java.io.Writer#close() */
+    @Override
+    public void close() {
+        //to /dev/null
+    }
+
+    /** @see java.io.Writer#flush() */
+    @Override
+    public void flush() {
+        //to /dev/null
+    }
+
+    /**
+     * Does nothing - output to {@code /dev/null}.
+     * @param chr The characters to write
+     */
+    @Override
+    public void write(final char[] chr) {
+        //to /dev/null
+    }
+
+    /**
+     * Does nothing - output to {@code /dev/null}.
+     * @param chr The characters to write
+     * @param st The start offset
+     * @param end The number of characters to write
+     */
+    @Override
+    public void write(final char[] chr, final int st, final int end) {
+        //to /dev/null
+    }
+
+    /**
+     * Does nothing - output to {@code /dev/null}.
+     * @param idx The character to write
+     */
+    @Override
+    public void write(final int idx) {
+        //to /dev/null
+    }
+
+    /**
+     * Does nothing - output to {@code /dev/null}.
+     * @param str The string to write
+     */
+    @Override
+    public void write(final String str) {
+        //to /dev/null
+    }
+
+    /**
+     * Does nothing - output to {@code /dev/null}.
+     * @param str The string to write
+     * @param st The start offset
+     * @param end The number of characters to write
+     */
+    @Override
+    public void write(final String str, final int st, final int end) {
+        //to /dev/null
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/ProxyCollectionWriter.java b/src/main/java/org/apache/commons/io/output/ProxyCollectionWriter.java
new file mode 100644
index 0000000..0a4c037
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/ProxyCollectionWriter.java
@@ -0,0 +1,285 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.FilterWriter;
+import java.io.IOException;
+import java.io.Writer;
+import java.util.Collection;
+
+import org.apache.commons.io.IOUtils;
+
+/**
+ * A Proxy stream collection which acts as expected, that is it passes the method calls on to the proxied streams and
+ * doesn't change which methods are being called. It is an alternative base class to {@link FilterWriter} and
+ * {@link FilterCollectionWriter} to increase reusability, because FilterWriter changes the methods being called, such
+ * as {@code write(char[])} to {@code write(char[], int, int)} and {@code write(String)} to
+ * {@code write(String, int, int)}. This is in contrast to {@link ProxyWriter} which is backed by a single
+ * {@link Writer}.
+ *
+ * @since 2.7
+ */
+public class ProxyCollectionWriter extends FilterCollectionWriter {
+
+    /**
+     * Creates a new proxy collection writer.
+     *
+     * @param writers Writers object to provide the underlying targets.
+     */
+    public ProxyCollectionWriter(final Collection<Writer> writers) {
+        super(writers);
+    }
+
+    /**
+     * Creates a new proxy collection writer.
+     *
+     * @param writers Writers to provide the underlying targets.
+     */
+    public ProxyCollectionWriter(final Writer... writers) {
+        super(writers);
+    }
+
+    /**
+     * Invoked by the write methods after the proxied call has returned successfully. The number of chars written (1 for
+     * the {@link #write(int)} method, buffer length for {@link #write(char[])}, etc.) is given as an argument.
+     * <p>
+     * Subclasses can override this method to add common post-processing functionality without having to override all
+     * the write methods. The default implementation does nothing.
+     * </p>
+     *
+     * @param n number of chars written
+     * @throws IOException if the post-processing fails
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected void afterWrite(final int n) throws IOException {
+        // noop
+    }
+
+    /**
+     * Invokes the delegates' {@code append(char)} methods.
+     *
+     * @param c The character to write
+     * @return this writer
+     * @throws IOException if an I/O error occurs.
+     * @since 2.0
+     */
+    @SuppressWarnings("resource") // Fluent API.
+    @Override
+    public Writer append(final char c) throws IOException {
+        try {
+            beforeWrite(1);
+            super.append(c);
+            afterWrite(1);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+        return this;
+    }
+
+    /**
+     * Invokes the delegates' {@code append(CharSequence)} methods.
+     *
+     * @param csq The character sequence to write
+     * @return this writer
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("resource") // Fluent API.
+    @Override
+    public Writer append(final CharSequence csq) throws IOException {
+        try {
+            final int len = IOUtils.length(csq);
+            beforeWrite(len);
+            super.append(csq);
+            afterWrite(len);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+        return this;
+    }
+
+    /**
+     * Invokes the delegates' {@code append(CharSequence, int, int)} methods.
+     *
+     * @param csq   The character sequence to write
+     * @param start The index of the first character to write
+     * @param end   The index of the first character to write (exclusive)
+     * @return this writer
+     * @throws IOException if an I/O error occurs.
+     */
+    @SuppressWarnings("resource") // Fluent API.
+    @Override
+    public Writer append(final CharSequence csq, final int start, final int end) throws IOException {
+        try {
+            beforeWrite(end - start);
+            super.append(csq, start, end);
+            afterWrite(end - start);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+        return this;
+    }
+
+    /**
+     * Invoked by the write methods before the call is proxied. The number of chars to be written (1 for the
+     * {@link #write(int)} method, buffer length for {@link #write(char[])}, etc.) is given as an argument.
+     * <p>
+     * Subclasses can override this method to add common pre-processing functionality without having to override all the
+     * write methods. The default implementation does nothing.
+     * </p>
+     *
+     * @param n number of chars to be written
+     * @throws IOException if the pre-processing fails
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected void beforeWrite(final int n) throws IOException {
+        // noop
+    }
+
+    /**
+     * Invokes the delegate's {@code close()} method.
+     *
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void close() throws IOException {
+        try {
+            super.close();
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code flush()} method.
+     *
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void flush() throws IOException {
+        try {
+            super.flush();
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Handle any IOExceptions thrown.
+     * <p>
+     * This method provides a point to implement custom exception handling. The default behavior is to re-throw the
+     * exception.
+     * </p>
+     *
+     * @param e The IOException thrown
+     * @throws IOException if an I/O error occurs.
+     */
+    protected void handleIOException(final IOException e) throws IOException {
+        throw e;
+    }
+
+    /**
+     * Invokes the delegate's {@code write(char[])} method.
+     *
+     * @param cbuf the characters to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final char[] cbuf) throws IOException {
+        try {
+            final int len = IOUtils.length(cbuf);
+            beforeWrite(len);
+            super.write(cbuf);
+            afterWrite(len);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code write(char[], int, int)} method.
+     *
+     * @param cbuf the characters to write
+     * @param off  The start offset
+     * @param len  The number of characters to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final char[] cbuf, final int off, final int len) throws IOException {
+        try {
+            beforeWrite(len);
+            super.write(cbuf, off, len);
+            afterWrite(len);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code write(int)} method.
+     *
+     * @param c the character to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final int c) throws IOException {
+        try {
+            beforeWrite(1);
+            super.write(c);
+            afterWrite(1);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code write(String)} method.
+     *
+     * @param str the string to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final String str) throws IOException {
+        try {
+            final int len = IOUtils.length(str);
+            beforeWrite(len);
+            super.write(str);
+            afterWrite(len);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code write(String)} method.
+     *
+     * @param str the string to write
+     * @param off The start offset
+     * @param len The number of characters to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final String str, final int off, final int len) throws IOException {
+        try {
+            beforeWrite(len);
+            super.write(str, off, len);
+            afterWrite(len);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/ProxyOutputStream.java b/src/main/java/org/apache/commons/io/output/ProxyOutputStream.java
new file mode 100644
index 0000000..cc60832
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/ProxyOutputStream.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.apache.commons.io.IOUtils;
+
+/**
+ * A Proxy stream which acts as expected, that is it passes the method
+ * calls on to the proxied stream and doesn't change which methods are
+ * being called. It is an alternative base class to FilterOutputStream
+ * to increase reusability.
+ * <p>
+ * See the protected methods for ways in which a subclass can easily decorate
+ * a stream with custom pre-, post- or error processing functionality.
+ * </p>
+ *
+ */
+public class ProxyOutputStream extends FilterOutputStream {
+
+    /**
+     * Constructs a new ProxyOutputStream.
+     *
+     * @param proxy  the OutputStream to delegate to
+     */
+    public ProxyOutputStream(final OutputStream proxy) {
+        super(proxy);
+        // the proxy is stored in a protected superclass variable named 'out'
+    }
+
+    /**
+     * Invoked by the write methods after the proxied call has returned
+     * successfully. The number of bytes written (1 for the
+     * {@link #write(int)} method, buffer length for {@link #write(byte[])},
+     * etc.) is given as an argument.
+     * <p>
+     * Subclasses can override this method to add common post-processing
+     * functionality without having to override all the write methods.
+     * The default implementation does nothing.
+     *
+     * @since 2.0
+     * @param n number of bytes written
+     * @throws IOException if the post-processing fails
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected void afterWrite(final int n) throws IOException {
+        // noop
+    }
+
+    /**
+     * Invoked by the write methods before the call is proxied. The number
+     * of bytes to be written (1 for the {@link #write(int)} method, buffer
+     * length for {@link #write(byte[])}, etc.) is given as an argument.
+     * <p>
+     * Subclasses can override this method to add common pre-processing
+     * functionality without having to override all the write methods.
+     * The default implementation does nothing.
+     *
+     * @since 2.0
+     * @param n number of bytes to be written
+     * @throws IOException if the pre-processing fails
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected void beforeWrite(final int n) throws IOException {
+        // noop
+    }
+
+    /**
+     * Invokes the delegate's {@code close()} method.
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void close() throws IOException {
+        IOUtils.close(out, this::handleIOException);
+    }
+
+    /**
+     * Invokes the delegate's {@code flush()} method.
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void flush() throws IOException {
+        try {
+            out.flush();
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Handle any IOExceptions thrown.
+     * <p>
+     * This method provides a point to implement custom exception
+     * handling. The default behavior is to re-throw the exception.
+     * @param e The IOException thrown
+     * @throws IOException if an I/O error occurs.
+     * @since 2.0
+     */
+    protected void handleIOException(final IOException e) throws IOException {
+        throw e;
+    }
+
+    /**
+     * Invokes the delegate's {@code write(byte[])} method.
+     * @param bts the bytes to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final byte[] bts) throws IOException {
+        try {
+            final int len = IOUtils.length(bts);
+            beforeWrite(len);
+            out.write(bts);
+            afterWrite(len);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code write(byte[])} method.
+     * @param bts the bytes to write
+     * @param st The start offset
+     * @param end The number of bytes to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final byte[] bts, final int st, final int end) throws IOException {
+        try {
+            beforeWrite(end);
+            out.write(bts, st, end);
+            afterWrite(end);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code write(int)} method.
+     * @param idx the byte to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final int idx) throws IOException {
+        try {
+            beforeWrite(1);
+            out.write(idx);
+            afterWrite(1);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/ProxyWriter.java b/src/main/java/org/apache/commons/io/output/ProxyWriter.java
new file mode 100644
index 0000000..b67b22a
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/ProxyWriter.java
@@ -0,0 +1,265 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.FilterWriter;
+import java.io.IOException;
+import java.io.Writer;
+
+import org.apache.commons.io.IOUtils;
+
+/**
+ * A Proxy stream which acts as expected, that is it passes the method calls on to the proxied stream and doesn't
+ * change which methods are being called. It is an alternative base class to FilterWriter to increase reusability,
+ * because FilterWriter changes the methods being called, such as {@code write(char[]) to write(char[], int, int)}
+ * and {@code write(String) to write(String, int, int)}.
+ */
+public class ProxyWriter extends FilterWriter {
+
+    /**
+     * Constructs a new ProxyWriter.
+     *
+     * @param proxy  the Writer to delegate to
+     */
+    public ProxyWriter(final Writer proxy) {
+        super(proxy);
+        // the proxy is stored in a protected superclass variable named 'out'
+    }
+
+    /**
+     * Invoked by the write methods after the proxied call has returned
+     * successfully. The number of chars written (1 for the
+     * {@link #write(int)} method, buffer length for {@link #write(char[])},
+     * etc.) is given as an argument.
+     * <p>
+     * Subclasses can override this method to add common post-processing
+     * functionality without having to override all the write methods.
+     * The default implementation does nothing.
+     * </p>
+     *
+     * @since 2.0
+     * @param n number of chars written
+     * @throws IOException if the post-processing fails
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected void afterWrite(final int n) throws IOException {
+        // noop
+    }
+
+    /**
+     * Invokes the delegate's {@code append(char)} method.
+     * @param c The character to write
+     * @return this writer
+     * @throws IOException if an I/O error occurs.
+     * @since 2.0
+     */
+    @Override
+    public Writer append(final char c) throws IOException {
+        try {
+            beforeWrite(1);
+            out.append(c);
+            afterWrite(1);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+        return this;
+    }
+
+    /**
+     * Invokes the delegate's {@code append(CharSequence)} method.
+     * @param csq The character sequence to write
+     * @return this writer
+     * @throws IOException if an I/O error occurs.
+     * @since 2.0
+     */
+    @Override
+    public Writer append(final CharSequence csq) throws IOException {
+        try {
+            final int len = IOUtils.length(csq);
+            beforeWrite(len);
+            out.append(csq);
+            afterWrite(len);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+        return this;
+    }
+
+    /**
+     * Invokes the delegate's {@code append(CharSequence, int, int)} method.
+     * @param csq The character sequence to write
+     * @param start The index of the first character to write
+     * @param end  The index of the first character to write (exclusive)
+     * @return this writer
+     * @throws IOException if an I/O error occurs.
+     * @since 2.0
+     */
+    @Override
+    public Writer append(final CharSequence csq, final int start, final int end) throws IOException {
+        try {
+            beforeWrite(end - start);
+            out.append(csq, start, end);
+            afterWrite(end - start);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+        return this;
+    }
+
+    /**
+     * Invoked by the write methods before the call is proxied. The number
+     * of chars to be written (1 for the {@link #write(int)} method, buffer
+     * length for {@link #write(char[])}, etc.) is given as an argument.
+     * <p>
+     * Subclasses can override this method to add common pre-processing
+     * functionality without having to override all the write methods.
+     * The default implementation does nothing.
+     * </p>
+     *
+     * @since 2.0
+     * @param n number of chars to be written
+     * @throws IOException if the pre-processing fails
+     */
+    @SuppressWarnings("unused") // Possibly thrown from subclasses.
+    protected void beforeWrite(final int n) throws IOException {
+        // noop
+    }
+
+    /**
+     * Invokes the delegate's {@code close()} method.
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void close() throws IOException {
+        IOUtils.close(out, this::handleIOException);
+    }
+
+    /**
+     * Invokes the delegate's {@code flush()} method.
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void flush() throws IOException {
+        try {
+            out.flush();
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Handles any IOExceptions thrown.
+     * <p>
+     * This method provides a point to implement custom exception
+     * handling. The default behavior is to re-throw the exception.
+     * </p>
+     *
+     * @param e The IOException thrown
+     * @throws IOException if an I/O error occurs.
+     * @since 2.0
+     */
+    protected void handleIOException(final IOException e) throws IOException {
+        throw e;
+    }
+
+    /**
+     * Invokes the delegate's {@code write(char[])} method.
+     * @param cbuf the characters to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final char[] cbuf) throws IOException {
+        try {
+            final int len = IOUtils.length(cbuf);
+            beforeWrite(len);
+            out.write(cbuf);
+            afterWrite(len);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code write(char[], int, int)} method.
+     * @param cbuf the characters to write
+     * @param off The start offset
+     * @param len The number of characters to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final char[] cbuf, final int off, final int len) throws IOException {
+        try {
+            beforeWrite(len);
+            out.write(cbuf, off, len);
+            afterWrite(len);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code write(int)} method.
+     * @param c the character to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final int c) throws IOException {
+        try {
+            beforeWrite(1);
+            out.write(c);
+            afterWrite(1);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code write(String)} method.
+     * @param str the string to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final String str) throws IOException {
+        try {
+            final int len = IOUtils.length(str);
+            beforeWrite(len);
+            out.write(str);
+            afterWrite(len);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+    /**
+     * Invokes the delegate's {@code write(String)} method.
+     * @param str the string to write
+     * @param off The start offset
+     * @param len The number of characters to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final String str, final int off, final int len) throws IOException {
+        try {
+            beforeWrite(len);
+            out.write(str, off, len);
+            afterWrite(len);
+        } catch (final IOException e) {
+            handleIOException(e);
+        }
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/QueueOutputStream.java b/src/main/java/org/apache/commons/io/output/QueueOutputStream.java
new file mode 100644
index 0000000..0eb648b
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/QueueOutputStream.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.io.OutputStream;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import java.util.Objects;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+
+import org.apache.commons.io.input.QueueInputStream;
+
+/**
+ * Simple alternative to JDK {@link java.io.PipedOutputStream}; queue input stream provides what's written in queue
+ * output stream.
+ * <p>
+ * Example usage:
+ * </p>
+ *
+ * <pre>
+ * QueueOutputStream outputStream = new QueueOutputStream();
+ * QueueInputStream inputStream = outputStream.newPipeInputStream();
+ *
+ * outputStream.write("hello world".getBytes(UTF_8));
+ * inputStream.read();
+ * </pre>
+ *
+ * Unlike JDK {@link PipedInputStream} and {@link PipedOutputStream}, queue input/output streams may be used safely in a
+ * single thread or multiple threads. Also, unlike JDK classes, no special meaning is attached to initial or current
+ * thread. Instances can be used longer after initial threads exited.
+ * <p>
+ * Closing a {@link QueueOutputStream} has no effect. The methods in this class can be called after the stream has been
+ * closed without generating an {@link IOException}.
+ * </p>
+ *
+ * @see QueueInputStream
+ * @since 2.9.0
+ */
+public class QueueOutputStream extends OutputStream {
+
+    private final BlockingQueue<Integer> blockingQueue;
+
+    /**
+     * Constructs a new instance with no limit to internal buffer size.
+     */
+    public QueueOutputStream() {
+        this(new LinkedBlockingQueue<>());
+    }
+
+    /**
+     * Constructs a new instance with given buffer.
+     *
+     * @param blockingQueue backing queue for the stream
+     */
+    public QueueOutputStream(final BlockingQueue<Integer> blockingQueue) {
+        this.blockingQueue = Objects.requireNonNull(blockingQueue, "blockingQueue");
+    }
+
+    /**
+     * Creates a new QueueInputStream instance connected to this. Writes to this output stream will be visible to the
+     * input stream.
+     *
+     * @return QueueInputStream connected to this stream
+     */
+    public QueueInputStream newQueueInputStream() {
+        return new QueueInputStream(blockingQueue);
+    }
+
+    /**
+     * Writes a single byte.
+     *
+     * @throws InterruptedIOException if the thread is interrupted while writing to the queue.
+     */
+    @Override
+    public void write(final int b) throws InterruptedIOException {
+        try {
+            blockingQueue.put(0xFF & b);
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+            final InterruptedIOException interruptedIoException = new InterruptedIOException();
+            interruptedIoException.initCause(e);
+            throw interruptedIoException;
+        }
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/output/StringBuilderWriter.java b/src/main/java/org/apache/commons/io/output/StringBuilderWriter.java
new file mode 100644
index 0000000..bd9530d
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/StringBuilderWriter.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.Serializable;
+import java.io.Writer;
+
+/**
+ * {@link Writer} implementation that outputs to a {@link StringBuilder}.
+ * <p>
+ * <strong>NOTE:</strong> This implementation, as an alternative to
+ * {@code java.io.StringWriter}, provides an <i>un-synchronized</i>
+ * (i.e. for use in a single thread) implementation for better performance.
+ * For safe usage with multiple {@link Thread}s then
+ * {@code java.io.StringWriter} should be used.
+ * </p>
+ *
+ * @since 2.0
+ */
+public class StringBuilderWriter extends Writer implements Serializable {
+
+    private static final long serialVersionUID = -146927496096066153L;
+    private final StringBuilder builder;
+
+    /**
+     * Constructs a new {@link StringBuilder} instance with default capacity.
+     */
+    public StringBuilderWriter() {
+        this.builder = new StringBuilder();
+    }
+
+    /**
+     * Constructs a new {@link StringBuilder} instance with the specified capacity.
+     *
+     * @param capacity The initial capacity of the underlying {@link StringBuilder}
+     */
+    public StringBuilderWriter(final int capacity) {
+        this.builder = new StringBuilder(capacity);
+    }
+
+    /**
+     * Constructs a new instance with the specified {@link StringBuilder}.
+     *
+     * <p>If {@code builder} is null a new instance with default capacity will be created.</p>
+     *
+     * @param builder The String builder. May be null.
+     */
+    public StringBuilderWriter(final StringBuilder builder) {
+        this.builder = builder != null ? builder : new StringBuilder();
+    }
+
+    /**
+     * Appends a single character to this Writer.
+     *
+     * @param value The character to append
+     * @return This writer instance
+     */
+    @Override
+    public Writer append(final char value) {
+        builder.append(value);
+        return this;
+    }
+
+    /**
+     * Appends a character sequence to this Writer.
+     *
+     * @param value The character to append
+     * @return This writer instance
+     */
+    @Override
+    public Writer append(final CharSequence value) {
+        builder.append(value);
+        return this;
+    }
+
+    /**
+     * Appends a portion of a character sequence to the {@link StringBuilder}.
+     *
+     * @param value The character to append
+     * @param start The index of the first character
+     * @param end The index of the last character + 1
+     * @return This writer instance
+     */
+    @Override
+    public Writer append(final CharSequence value, final int start, final int end) {
+        builder.append(value, start, end);
+        return this;
+    }
+
+    /**
+     * Closing this writer has no effect.
+     */
+    @Override
+    public void close() {
+        // no-op
+    }
+
+    /**
+     * Flushing this writer has no effect.
+     */
+    @Override
+    public void flush() {
+        // no-op
+    }
+
+
+    /**
+     * Returns the underlying builder.
+     *
+     * @return The underlying builder
+     */
+    public StringBuilder getBuilder() {
+        return builder;
+    }
+
+    /**
+     * Returns {@link StringBuilder#toString()}.
+     *
+     * @return The contents of the String builder.
+     */
+    @Override
+    public String toString() {
+        return builder.toString();
+    }
+
+    /**
+     * Writes a portion of a character array to the {@link StringBuilder}.
+     *
+     * @param value The value to write
+     * @param offset The index of the first character
+     * @param length The number of characters to write
+     */
+    @Override
+    public void write(final char[] value, final int offset, final int length) {
+        if (value != null) {
+            builder.append(value, offset, length);
+        }
+    }
+
+    /**
+     * Writes a String to the {@link StringBuilder}.
+     *
+     * @param value The value to write
+     */
+    @Override
+    public void write(final String value) {
+        if (value != null) {
+            builder.append(value);
+        }
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/output/TaggedOutputStream.java b/src/main/java/org/apache/commons/io/output/TaggedOutputStream.java
new file mode 100644
index 0000000..eeb23a0
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/TaggedOutputStream.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.Serializable;
+import java.util.UUID;
+
+import org.apache.commons.io.TaggedIOException;
+
+/**
+ * An output stream decorator that tags potential exceptions so that the
+ * stream that caused the exception can easily be identified. This is
+ * done by using the {@link TaggedIOException} class to wrap all thrown
+ * {@link IOException}s. See below for an example of using this class.
+ * <pre>
+ * TaggedOutputStream stream = new TaggedOutputStream(...);
+ * try {
+ *     // Processing that may throw an IOException either from this stream
+ *     // or from some other IO activity like temporary files, etc.
+ *     writeToStream(stream);
+ * } catch (IOException e) {
+ *     if (stream.isCauseOf(e)) {
+ *         // The exception was caused by this stream.
+ *         // Use e.getCause() to get the original exception.
+ *     } else {
+ *         // The exception was caused by something else.
+ *     }
+ * }
+ * </pre>
+ * <p>
+ * Alternatively, the {@link #throwIfCauseOf(Exception)} method can be
+ * used to let higher levels of code handle the exception caused by this
+ * stream while other processing errors are being taken care of at this
+ * lower level.
+ * </p>
+ * <pre>
+ * TaggedOutputStream stream = new TaggedOutputStream(...);
+ * try {
+ *     writeToStream(stream);
+ * } catch (IOException e) {
+ *     stream.throwIfCauseOf(e);
+ *     // ... or process the exception that was caused by something else
+ * }
+ * </pre>
+ *
+ * @see TaggedIOException
+ * @since 2.0
+ */
+public class TaggedOutputStream extends ProxyOutputStream {
+
+    /**
+     * The unique tag associated with exceptions from stream.
+     */
+    private final Serializable tag = UUID.randomUUID();
+
+    /**
+     * Creates a tagging decorator for the given output stream.
+     *
+     * @param proxy output stream to be decorated
+     */
+    public TaggedOutputStream(final OutputStream proxy) {
+        super(proxy);
+    }
+
+    /**
+     * Tags any IOExceptions thrown, wrapping and re-throwing.
+     *
+     * @param e The IOException thrown
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    protected void handleIOException(final IOException e) throws IOException {
+        throw new TaggedIOException(e, tag);
+    }
+
+    /**
+     * Tests if the given exception was caused by this stream.
+     *
+     * @param exception an exception
+     * @return {@code true} if the exception was thrown by this stream,
+     *         {@code false} otherwise
+     */
+    public boolean isCauseOf(final Exception exception) {
+        return TaggedIOException.isTaggedWith(exception, tag);
+    }
+
+    /**
+     * Re-throws the original exception thrown by this stream. This method
+     * first checks whether the given exception is a {@link TaggedIOException}
+     * wrapper created by this decorator, and then unwraps and throws the
+     * original wrapped exception. Returns normally if the exception was
+     * not thrown by this stream.
+     *
+     * @param exception an exception
+     * @throws IOException original exception, if any, thrown by this stream
+     */
+    public void throwIfCauseOf(final Exception exception) throws IOException {
+        TaggedIOException.throwCauseIfTaggedWith(exception, tag);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/TaggedWriter.java b/src/main/java/org/apache/commons/io/output/TaggedWriter.java
new file mode 100644
index 0000000..eb6f79d
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/TaggedWriter.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.io.Writer;
+import java.util.UUID;
+
+import org.apache.commons.io.TaggedIOException;
+
+/**
+ * A writer decorator that tags potential exceptions so that the
+ * reader that caused the exception can easily be identified. This is
+ * done by using the {@link TaggedIOException} class to wrap all thrown
+ * {@link IOException}s. See below for an example of using this class.
+ * <pre>
+ * TaggedReader reader = new TaggedReader(...);
+ * try {
+ *     // Processing that may throw an IOException either from this reader
+ *     // or from some other IO activity like temporary files, etc.
+ *     writeToWriter(writer);
+ * } catch (IOException e) {
+ *     if (writer.isCauseOf(e)) {
+ *         // The exception was caused by this writer.
+ *         // Use e.getCause() to get the original exception.
+ *     } else {
+ *         // The exception was caused by something else.
+ *     }
+ * }
+ * </pre>
+ * <p>
+ * Alternatively, the {@link #throwIfCauseOf(Exception)} method can be
+ * used to let higher levels of code handle the exception caused by this
+ * writer while other processing errors are being taken care of at this
+ * lower level.
+ * </p>
+ * <pre>
+ * TaggedWriter writer = new TaggedWriter(...);
+ * try {
+ *     writeToWriter(writer);
+ * } catch (IOException e) {
+ *     writer.throwIfCauseOf(e);
+ *     // ... or process the exception that was caused by something else
+ * }
+ * </pre>
+ *
+ * @see TaggedIOException
+ * @since 2.0
+ */
+public class TaggedWriter extends ProxyWriter {
+
+    /**
+     * The unique tag associated with exceptions from writer.
+     */
+    private final Serializable tag = UUID.randomUUID();
+
+    /**
+     * Creates a tagging decorator for the given writer.
+     *
+     * @param proxy writer to be decorated
+     */
+    public TaggedWriter(final Writer proxy) {
+        super(proxy);
+    }
+
+    /**
+     * Tags any IOExceptions thrown, wrapping and re-throwing.
+     *
+     * @param e The IOException thrown
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    protected void handleIOException(final IOException e) throws IOException {
+        throw new TaggedIOException(e, tag);
+    }
+
+    /**
+     * Tests if the given exception was caused by this writer.
+     *
+     * @param exception an exception
+     * @return {@code true} if the exception was thrown by this writer,
+     *         {@code false} otherwise
+     */
+    public boolean isCauseOf(final Exception exception) {
+        return TaggedIOException.isTaggedWith(exception, tag);
+    }
+
+    /**
+     * Re-throws the original exception thrown by this writer. This method
+     * first checks whether the given exception is a {@link TaggedIOException}
+     * wrapper created by this decorator, and then unwraps and throws the
+     * original wrapped exception. Returns normally if the exception was
+     * not thrown by this writer.
+     *
+     * @param exception an exception
+     * @throws IOException original exception, if any, thrown by this writer
+     */
+    public void throwIfCauseOf(final Exception exception) throws IOException {
+        TaggedIOException.throwCauseIfTaggedWith(exception, tag);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/TeeOutputStream.java b/src/main/java/org/apache/commons/io/output/TeeOutputStream.java
new file mode 100644
index 0000000..b1456a4
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/TeeOutputStream.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * Classic splitter of {@link OutputStream}. Named after the Unix 'tee' command. It allows a stream to be branched off
+ * so there are now two streams.
+ */
+public class TeeOutputStream extends ProxyOutputStream {
+
+    /**
+     * The second OutputStream to write to.
+     *
+     * TODO Make private and final in 3.0.
+     */
+    protected OutputStream branch;
+
+    /**
+     * Constructs a TeeOutputStream.
+     *
+     * @param out    the main OutputStream
+     * @param branch the second OutputStream
+     */
+    public TeeOutputStream(final OutputStream out, final OutputStream branch) {
+        super(out);
+        this.branch = branch;
+    }
+
+    /**
+     * Closes both output streams.
+     * <p>
+     * If closing the main output stream throws an exception, attempt to close the branch output stream.
+     * </p>
+     *
+     * <p>
+     * If closing the main and branch output streams both throw exceptions, which exceptions is thrown by this method is
+     * currently unspecified and subject to change.
+     * </p>
+     *
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void close() throws IOException {
+        try {
+            super.close();
+        } finally {
+            this.branch.close();
+        }
+    }
+
+    /**
+     * Flushes both streams.
+     *
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void flush() throws IOException {
+        super.flush();
+        this.branch.flush();
+    }
+
+    /**
+     * Writes the bytes to both streams.
+     *
+     * @param b the bytes to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public synchronized void write(final byte[] b) throws IOException {
+        super.write(b);
+        this.branch.write(b);
+    }
+
+    /**
+     * Writes the specified bytes to both streams.
+     *
+     * @param b   the bytes to write
+     * @param off The start offset
+     * @param len The number of bytes to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public synchronized void write(final byte[] b, final int off, final int len) throws IOException {
+        super.write(b, off, len);
+        this.branch.write(b, off, len);
+    }
+
+    /**
+     * Writes a byte to both streams.
+     *
+     * @param b the byte to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public synchronized void write(final int b) throws IOException {
+        super.write(b);
+        this.branch.write(b);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/TeeWriter.java b/src/main/java/org/apache/commons/io/output/TeeWriter.java
new file mode 100644
index 0000000..e373bc1
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/TeeWriter.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.output;
+
+import java.io.Writer;
+import java.util.Collection;
+
+/**
+ * Classic splitter of {@link Writer}. Named after the Unix 'tee' command. It allows a stream to be branched off so
+ * there are now two streams.
+ * <p>
+ * This currently a only convenience class with the proper name "TeeWriter".
+ * </p>
+ *
+ * @since 2.7
+ */
+public class TeeWriter extends ProxyCollectionWriter {
+
+    /**
+     * Creates a new filtered collection writer.
+     *
+     * @param writers Writers to provide the underlying targets.
+     */
+    public TeeWriter(final Collection<Writer> writers) {
+        super(writers);
+    }
+
+    /**
+     * Creates a new filtered collection writer.
+     *
+     * @param writers Writers to provide the underlying targets.
+     */
+    public TeeWriter(final Writer... writers) {
+        super(writers);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/output/ThresholdingOutputStream.java b/src/main/java/org/apache/commons/io/output/ThresholdingOutputStream.java
new file mode 100644
index 0000000..9153f0b
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/ThresholdingOutputStream.java
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.apache.commons.io.function.IOConsumer;
+import org.apache.commons.io.function.IOFunction;
+
+/**
+ * An output stream which triggers an event when a specified number of bytes of data have been written to it. The event
+ * can be used, for example, to throw an exception if a maximum has been reached, or to switch the underlying stream
+ * type when the threshold is exceeded.
+ * <p>
+ * This class overrides all {@link OutputStream} methods. However, these overrides ultimately call the corresponding
+ * methods in the underlying output stream implementation.
+ * </p>
+ * <p>
+ * NOTE: This implementation may trigger the event <em>before</em> the threshold is actually reached, since it triggers
+ * when a pending write operation would cause the threshold to be exceeded.
+ * </p>
+ */
+public class ThresholdingOutputStream extends OutputStream {
+
+    /**
+     * Noop output stream getter function.
+     */
+    private static final IOFunction<ThresholdingOutputStream, OutputStream> NOOP_OS_GETTER = os -> NullOutputStream.INSTANCE;
+
+    /**
+     * The threshold at which the event will be triggered.
+     */
+    private final int threshold;
+
+    /**
+     * Accepts reaching the threshold.
+     */
+    private final IOConsumer<ThresholdingOutputStream> thresholdConsumer;
+
+    /**
+     * Gets the output stream.
+     */
+    private final IOFunction<ThresholdingOutputStream, OutputStream> outputStreamGetter;
+
+    /**
+     * The number of bytes written to the output stream.
+     */
+    private long written;
+
+    /**
+     * Whether or not the configured threshold has been exceeded.
+     */
+    private boolean thresholdExceeded;
+
+    /**
+     * Constructs an instance of this class which will trigger an event at the specified threshold.
+     *
+     * @param threshold The number of bytes at which to trigger an event.
+     */
+    public ThresholdingOutputStream(final int threshold) {
+        this(threshold, IOConsumer.noop(), NOOP_OS_GETTER);
+    }
+
+    /**
+     * Constructs an instance of this class which will trigger an event at the specified threshold.
+     *
+     * @param threshold The number of bytes at which to trigger an event.
+     * @param thresholdConsumer Accepts reaching the threshold.
+     * @param outputStreamGetter Gets the output stream.
+     * @since 2.9.0
+     */
+    public ThresholdingOutputStream(final int threshold, final IOConsumer<ThresholdingOutputStream> thresholdConsumer,
+        final IOFunction<ThresholdingOutputStream, OutputStream> outputStreamGetter) {
+        this.threshold = threshold;
+        this.thresholdConsumer = thresholdConsumer == null ? IOConsumer.noop() : thresholdConsumer;
+        this.outputStreamGetter = outputStreamGetter == null ? NOOP_OS_GETTER : outputStreamGetter;
+    }
+
+    /**
+     * Checks to see if writing the specified number of bytes would cause the configured threshold to be exceeded. If
+     * so, triggers an event to allow a concrete implementation to take action on this.
+     *
+     * @param count The number of bytes about to be written to the underlying output stream.
+     *
+     * @throws IOException if an error occurs.
+     */
+    protected void checkThreshold(final int count) throws IOException {
+        if (!thresholdExceeded && written + count > threshold) {
+            thresholdExceeded = true;
+            thresholdReached();
+        }
+    }
+
+    /**
+     * Closes this output stream and releases any system resources associated with this stream.
+     *
+     * @throws IOException if an error occurs.
+     */
+    @Override
+    public void close() throws IOException {
+        try {
+            flush();
+        } catch (final IOException ignored) {
+            // ignore
+        }
+        getStream().close();
+    }
+
+    /**
+     * Flushes this output stream and forces any buffered output bytes to be written out.
+     *
+     * @throws IOException if an error occurs.
+     */
+    @SuppressWarnings("resource") // the underlying stream is managed by a subclass.
+    @Override
+    public void flush() throws IOException {
+        getStream().flush();
+    }
+
+    /**
+     * Returns the number of bytes that have been written to this output stream.
+     *
+     * @return The number of bytes written.
+     */
+    public long getByteCount() {
+        return written;
+    }
+
+    /**
+     * Returns the underlying output stream, to which the corresponding {@link OutputStream} methods in this class will
+     * ultimately delegate.
+     *
+     * @return The underlying output stream.
+     *
+     * @throws IOException if an error occurs.
+     */
+    protected OutputStream getStream() throws IOException {
+        return outputStreamGetter.apply(this);
+    }
+
+    /**
+     * Returns the threshold, in bytes, at which an event will be triggered.
+     *
+     * @return The threshold point, in bytes.
+     */
+    public int getThreshold() {
+        return threshold;
+    }
+
+    /**
+     * Determines whether or not the configured threshold has been exceeded for this output stream.
+     *
+     * @return {@code true} if the threshold has been reached; {@code false} otherwise.
+     */
+    public boolean isThresholdExceeded() {
+        return written > threshold;
+    }
+
+    /**
+     * Resets the byteCount to zero. You can call this from {@link #thresholdReached()} if you want the event to be
+     * triggered again.
+     */
+    protected void resetByteCount() {
+        this.thresholdExceeded = false;
+        this.written = 0;
+    }
+
+    /**
+     * Sets the byteCount to count. Useful for re-opening an output stream that has previously been written to.
+     *
+     * @param count The number of bytes that have already been written to the output stream
+     *
+     * @since 2.5
+     */
+    protected void setByteCount(final long count) {
+        this.written = count;
+    }
+
+    /**
+     * Indicates that the configured threshold has been reached, and that a subclass should take whatever action
+     * necessary on this event. This may include changing the underlying output stream.
+     *
+     * @throws IOException if an error occurs.
+     */
+    protected void thresholdReached() throws IOException {
+        thresholdConsumer.accept(this);
+    }
+
+    /**
+     * Writes {@code b.length} bytes from the specified byte array to this output stream.
+     *
+     * @param b The array of bytes to be written.
+     *
+     * @throws IOException if an error occurs.
+     */
+    @SuppressWarnings("resource") // the underlying stream is managed by a subclass.
+    @Override
+    public void write(final byte[] b) throws IOException {
+        checkThreshold(b.length);
+        getStream().write(b);
+        written += b.length;
+    }
+
+    /**
+     * Writes {@code len} bytes from the specified byte array starting at offset {@code off} to this output stream.
+     *
+     * @param b The byte array from which the data will be written.
+     * @param off The start offset in the byte array.
+     * @param len The number of bytes to write.
+     *
+     * @throws IOException if an error occurs.
+     */
+    @SuppressWarnings("resource") // the underlying stream is managed by a subclass.
+    @Override
+    public void write(final byte[] b, final int off, final int len) throws IOException {
+        checkThreshold(len);
+        getStream().write(b, off, len);
+        written += len;
+    }
+
+    /**
+     * Writes the specified byte to this output stream.
+     *
+     * @param b The byte to be written.
+     *
+     * @throws IOException if an error occurs.
+     */
+    @SuppressWarnings("resource") // the underlying stream is managed by a subclass.
+    @Override
+    public void write(final int b) throws IOException {
+        checkThreshold(1);
+        getStream().write(b);
+        written++;
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/output/UncheckedAppendable.java b/src/main/java/org/apache/commons/io/output/UncheckedAppendable.java
new file mode 100644
index 0000000..8abda70
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/UncheckedAppendable.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+
+/**
+ * An {@link Appendable} that throws {@link UncheckedIOException} instead of {@link IOException}.
+ *
+ * @see Appendable
+ * @see IOException
+ * @see UncheckedIOException
+ * @since 2.12.0
+ */
+public interface UncheckedAppendable extends Appendable {
+
+    /**
+     * Creates a new instance on the given Appendable.
+     *
+     * @param appendable The Appendable to uncheck.
+     * @return a new instance.
+     */
+    static UncheckedAppendable on(final Appendable appendable) {
+        return new UncheckedAppendableImpl(appendable);
+    }
+
+    /**
+     * Appends per {@link Appendable#append(char)} but rethrows {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    UncheckedAppendable append(char c);
+
+    /**
+     * Appends per {@link Appendable#append(CharSequence)} but rethrows {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    UncheckedAppendable append(CharSequence csq);
+
+    /**
+     * Appends per {@link Appendable#append(CharSequence, int, int)} but rethrows {@link IOException} as
+     * {@link UncheckedIOException}.
+     */
+    @Override
+    UncheckedAppendable append(CharSequence csq, int start, int end);
+}
diff --git a/src/main/java/org/apache/commons/io/output/UncheckedAppendableImpl.java b/src/main/java/org/apache/commons/io/output/UncheckedAppendableImpl.java
new file mode 100644
index 0000000..17cbe32
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/UncheckedAppendableImpl.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Objects;
+
+import org.apache.commons.io.function.Uncheck;
+
+/**
+ * An {@link Appendable} implementation that throws {@link UncheckedIOException} instead of {@link IOException}.
+ *
+ * @see Appendable
+ * @see IOException
+ * @see UncheckedIOException
+ * @since 2.12.0
+ */
+class UncheckedAppendableImpl implements UncheckedAppendable {
+
+    private final Appendable appendable;
+
+    UncheckedAppendableImpl(final Appendable appendable) {
+        this.appendable = Objects.requireNonNull(appendable, "appendable");
+    }
+
+    @Override
+    public UncheckedAppendable append(final char c) {
+        Uncheck.apply(appendable::append, c);
+        return this;
+    }
+
+    @Override
+    public UncheckedAppendable append(final CharSequence csq) {
+        Uncheck.apply(appendable::append, csq);
+        return this;
+    }
+
+    @Override
+    public UncheckedAppendable append(final CharSequence csq, final int start, final int end) {
+        Uncheck.apply(appendable::append, csq, start, end);
+        return this;
+    }
+
+    @Override
+    public String toString() {
+        return appendable.toString();
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/UncheckedFilterOutputStream.java b/src/main/java/org/apache/commons/io/output/UncheckedFilterOutputStream.java
new file mode 100644
index 0000000..31dd5ef
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/UncheckedFilterOutputStream.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.output;
+
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+
+import org.apache.commons.io.function.Uncheck;
+
+/**
+ * A {@link FilterOutputStream} that throws {@link UncheckedIOException} instead of {@link UncheckedIOException}.
+ *
+ * @see FilterOutputStream
+ * @see UncheckedIOException
+ * @see UncheckedIOException
+ * @since 2.12.0
+ */
+public class UncheckedFilterOutputStream extends FilterOutputStream {
+
+    /**
+     * Creates a new instance.
+     *
+     * @param outputStream an OutputStream object providing the underlying stream.
+     * @return a new UncheckedFilterOutputStream.
+     */
+    public static UncheckedFilterOutputStream on(final OutputStream outputStream) {
+        return new UncheckedFilterOutputStream(outputStream);
+    }
+
+    /**
+     * Creates an output stream filter built on top of the specified underlying output stream.
+     *
+     * @param outputStream the underlying output stream, or {@code null} if this instance is to be created without an
+     *        underlying stream.
+     */
+    public UncheckedFilterOutputStream(final OutputStream outputStream) {
+        super(outputStream);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void close() throws UncheckedIOException {
+        Uncheck.run(super::close);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void flush() throws UncheckedIOException {
+        Uncheck.run(super::flush);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void write(final byte[] b) throws UncheckedIOException {
+        Uncheck.accept(super::write, b);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void write(final byte[] b, final int off, final int len) throws UncheckedIOException {
+        Uncheck.accept(super::write, b, off, len);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void write(final int b) throws UncheckedIOException {
+        Uncheck.accept(super::write, b);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/UncheckedFilterWriter.java b/src/main/java/org/apache/commons/io/output/UncheckedFilterWriter.java
new file mode 100644
index 0000000..e64a4ce
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/UncheckedFilterWriter.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.output;
+
+import java.io.FilterWriter;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.io.Writer;
+
+import org.apache.commons.io.function.Uncheck;
+
+/**
+ * A {@link FilterWriter} that throws {@link UncheckedIOException} instead of {@link IOException}.
+ *
+ * @see FilterWriter
+ * @see IOException
+ * @see UncheckedIOException
+ * @since 2.12.0
+ */
+public class UncheckedFilterWriter extends FilterWriter {
+
+    /**
+     * Creates a new filtered writer.
+     *
+     * @param writer a Writer object providing the underlying stream.
+     * @return a new UncheckedFilterReader.
+     * @throws NullPointerException if {@code writer} is {@code null}.
+     */
+    public static UncheckedFilterWriter on(final Writer writer) {
+        return new UncheckedFilterWriter(writer);
+    }
+
+    /**
+     * Creates a new filtered writer.
+     *
+     * @param writer a Writer object providing the underlying stream.
+     * @throws NullPointerException if {@code writer} is {@code null}.
+     */
+    protected UncheckedFilterWriter(final Writer writer) {
+        super(writer);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public Writer append(final char c) throws UncheckedIOException {
+        return Uncheck.apply(super::append, c);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public Writer append(final CharSequence csq) throws UncheckedIOException {
+        return Uncheck.apply(super::append, csq);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public Writer append(final CharSequence csq, final int start, final int end) throws UncheckedIOException {
+        return Uncheck.apply(super::append, csq, start, end);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void close() throws UncheckedIOException {
+        Uncheck.run(super::close);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void flush() throws UncheckedIOException {
+        Uncheck.run(super::flush);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void write(final char[] cbuf) throws UncheckedIOException {
+        Uncheck.accept(super::write, cbuf);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void write(final char[] cbuf, final int off, final int len) throws UncheckedIOException {
+        Uncheck.accept(super::write, cbuf, off, len);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void write(final int c) throws UncheckedIOException {
+        Uncheck.accept(super::write, c);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void write(final String str) throws UncheckedIOException {
+        Uncheck.accept(super::write, str);
+    }
+
+    /**
+     * Calls this method's super and rethrow {@link IOException} as {@link UncheckedIOException}.
+     */
+    @Override
+    public void write(final String str, final int off, final int len) throws UncheckedIOException {
+        Uncheck.accept(super::write, str, off, len);
+    }
+
+}
diff --git a/src/main/java/org/apache/commons/io/output/UnsynchronizedByteArrayOutputStream.java b/src/main/java/org/apache/commons/io/output/UnsynchronizedByteArrayOutputStream.java
new file mode 100644
index 0000000..f48d043
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/UnsynchronizedByteArrayOutputStream.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import org.apache.commons.io.input.UnsynchronizedByteArrayInputStream;
+
+/**
+ * Implements a version of {@link AbstractByteArrayOutputStream} <b>without</b> any concurrent thread safety.
+ *
+ * @since 2.7
+ */
+//@NotThreadSafe
+public final class UnsynchronizedByteArrayOutputStream extends AbstractByteArrayOutputStream {
+
+    /**
+     * Fetches entire contents of an {@link InputStream} and represent same data as result InputStream.
+     * <p>
+     * This method is useful where,
+     * </p>
+     * <ul>
+     * <li>Source InputStream is slow.</li>
+     * <li>It has network resources associated, so we cannot keep it open for long time.</li>
+     * <li>It has network timeout associated.</li>
+     * </ul>
+     * It can be used in favor of {@link #toByteArray()}, since it avoids unnecessary allocation and copy of byte[].<br>
+     * This method buffers the input internally, so there is no need to use a {@link BufferedInputStream}.
+     *
+     * @param input Stream to be fully buffered.
+     * @return A fully buffered stream.
+     * @throws IOException if an I/O error occurs.
+     */
+    public static InputStream toBufferedInputStream(final InputStream input) throws IOException {
+        return toBufferedInputStream(input, DEFAULT_SIZE);
+    }
+
+    /**
+     * Fetches entire contents of an {@link InputStream} and represent same data as result InputStream.
+     * <p>
+     * This method is useful where,
+     * </p>
+     * <ul>
+     * <li>Source InputStream is slow.</li>
+     * <li>It has network resources associated, so we cannot keep it open for long time.</li>
+     * <li>It has network timeout associated.</li>
+     * </ul>
+     * It can be used in favor of {@link #toByteArray()}, since it avoids unnecessary allocation and copy of byte[].<br>
+     * This method buffers the input internally, so there is no need to use a {@link BufferedInputStream}.
+     *
+     * @param input Stream to be fully buffered.
+     * @param size the initial buffer size
+     * @return A fully buffered stream.
+     * @throws IOException if an I/O error occurs.
+     */
+    public static InputStream toBufferedInputStream(final InputStream input, final int size) throws IOException {
+        // It does not matter if a ByteArrayOutputStream is not closed as close() is a no-op
+        try (UnsynchronizedByteArrayOutputStream output = new UnsynchronizedByteArrayOutputStream(size)) {
+            output.write(input);
+            return output.toInputStream();
+        }
+    }
+
+    /**
+     * Creates a new byte array output stream. The buffer capacity is initially
+     * {@value AbstractByteArrayOutputStream#DEFAULT_SIZE} bytes, though its size increases if necessary.
+     */
+    public UnsynchronizedByteArrayOutputStream() {
+        this(DEFAULT_SIZE);
+    }
+
+    /**
+     * Creates a new byte array output stream, with a buffer capacity of the specified size, in bytes.
+     *
+     * @param size the initial size
+     * @throws IllegalArgumentException if size is negative
+     */
+    public UnsynchronizedByteArrayOutputStream(final int size) {
+        if (size < 0) {
+            throw new IllegalArgumentException("Negative initial size: " + size);
+        }
+        needNewBuffer(size);
+    }
+
+    /**
+     * @see java.io.ByteArrayOutputStream#reset()
+     */
+    @Override
+    public void reset() {
+        resetImpl();
+    }
+
+    @Override
+    public int size() {
+        return count;
+    }
+
+    @Override
+    public byte[] toByteArray() {
+        return toByteArrayImpl();
+    }
+
+    @Override
+    public InputStream toInputStream() {
+        return toInputStream(UnsynchronizedByteArrayInputStream::new);
+    }
+
+    @Override
+    public void write(final byte[] b, final int off, final int len) {
+        if (off < 0 || off > b.length || len < 0 || off + len > b.length || off + len < 0) {
+            throw new IndexOutOfBoundsException(String.format("offset=%,d, length=%,d", off, len));
+        }
+        if (len == 0) {
+            return;
+        }
+        writeImpl(b, off, len);
+    }
+
+    @Override
+    public int write(final InputStream in) throws IOException {
+        return writeImpl(in);
+    }
+
+    @Override
+    public void write(final int b) {
+        writeImpl(b);
+    }
+
+    @Override
+    public void writeTo(final OutputStream out) throws IOException {
+        writeToImpl(out);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/output/WriterOutputStream.java b/src/main/java/org/apache/commons/io/output/WriterOutputStream.java
new file mode 100644
index 0000000..c9ab9cb
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/WriterOutputStream.java
@@ -0,0 +1,352 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.Writer;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CoderResult;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.commons.io.Charsets;
+import org.apache.commons.io.charset.CharsetDecoders;
+
+/**
+ * {@link OutputStream} implementation that transforms a byte stream to a
+ * character stream using a specified charset encoding and writes the resulting
+ * stream to a {@link Writer}. The stream is transformed using a
+ * {@link CharsetDecoder} object, guaranteeing that all charset
+ * encodings supported by the JRE are handled correctly.
+ * <p>
+ * The output of the {@link CharsetDecoder} is buffered using a fixed size buffer.
+ * This implies that the data is written to the underlying {@link Writer} in chunks
+ * that are no larger than the size of this buffer. By default, the buffer is
+ * flushed only when it overflows or when {@link #flush()} or {@link #close()}
+ * is called. In general there is therefore no need to wrap the underlying {@link Writer}
+ * in a {@link java.io.BufferedWriter}. {@link WriterOutputStream} can also
+ * be instructed to flush the buffer after each write operation. In this case, all
+ * available data is written immediately to the underlying {@link Writer}, implying that
+ * the current position of the {@link Writer} is correlated to the current position
+ * of the {@link WriterOutputStream}.
+ * <p>
+ * {@link WriterOutputStream} implements the inverse transformation of {@link java.io.OutputStreamWriter};
+ * in the following example, writing to {@code out2} would have the same result as writing to
+ * {@code out} directly (provided that the byte sequence is legal with respect to the
+ * charset encoding):
+ * <pre>
+ * OutputStream out = ...
+ * Charset cs = ...
+ * OutputStreamWriter writer = new OutputStreamWriter(out, cs);
+ * WriterOutputStream out2 = new WriterOutputStream(writer, cs);</pre>
+ * {@link WriterOutputStream} implements the same transformation as {@link java.io.InputStreamReader},
+ * except that the control flow is reversed: both classes transform a byte stream
+ * into a character stream, but {@link java.io.InputStreamReader} pulls data from the underlying stream,
+ * while {@link WriterOutputStream} pushes it to the underlying stream.
+ * <p>
+ * Note that while there are use cases where there is no alternative to using
+ * this class, very often the need to use this class is an indication of a flaw
+ * in the design of the code. This class is typically used in situations where an existing
+ * API only accepts an {@link OutputStream} object, but where the stream is known to represent
+ * character data that must be decoded for further use.
+ * </p>
+ * <p>
+ * Instances of {@link WriterOutputStream} are not thread safe.
+ * </p>
+ *
+ * @see org.apache.commons.io.input.ReaderInputStream
+ * @since 2.0
+ */
+public class WriterOutputStream extends OutputStream {
+    private static final int BUFFER_SIZE = 1024;
+
+    /**
+     * Check if the JDK in use properly supports the given charset.
+     *
+     * @param charset the charset to check the support for
+     */
+    private static void checkIbmJdkWithBrokenUTF16(final Charset charset){
+        if (!StandardCharsets.UTF_16.name().equals(charset.name())) {
+            return;
+        }
+        final String TEST_STRING_2 = "v\u00e9s";
+        final byte[] bytes = TEST_STRING_2.getBytes(charset);
+
+        final CharsetDecoder charsetDecoder2 = charset.newDecoder();
+        final ByteBuffer bb2 = ByteBuffer.allocate(16);
+        final CharBuffer cb2 = CharBuffer.allocate(TEST_STRING_2.length());
+        final int len = bytes.length;
+        for (int i = 0; i < len; i++) {
+            bb2.put(bytes[i]);
+            bb2.flip();
+            try {
+                charsetDecoder2.decode(bb2, cb2, i == len - 1);
+            } catch ( final IllegalArgumentException e){
+                throw new UnsupportedOperationException("UTF-16 requested when running on an IBM JDK with broken UTF-16 support. " +
+                        "Please find a JDK that supports UTF-16 if you intend to use UF-16 with WriterOutputStream");
+            }
+            bb2.compact();
+        }
+        cb2.rewind();
+        if (!TEST_STRING_2.equals(cb2.toString())){
+            throw new UnsupportedOperationException("UTF-16 requested when running on an IBM JDK with broken UTF-16 support. " +
+                    "Please find a JDK that supports UTF-16 if you intend to use UF-16 with WriterOutputStream");
+        }
+
+    }
+    private final Writer writer;
+    private final CharsetDecoder decoder;
+
+    private final boolean writeImmediately;
+
+    /**
+     * ByteBuffer used as input for the decoder. This buffer can be small
+     * as it is used only to transfer the received data to the
+     * decoder.
+     */
+    private final ByteBuffer decoderIn = ByteBuffer.allocate(128);
+
+    /**
+     * CharBuffer used as output for the decoder. It should be
+     * somewhat larger as we write from this buffer to the
+     * underlying Writer.
+     */
+    private final CharBuffer decoderOut;
+
+    /**
+     * Constructs a new {@link WriterOutputStream} that uses the default character encoding and with a default output
+     * buffer size of {@value #BUFFER_SIZE} characters. The output buffer will only be flushed when it overflows or when
+     * {@link #flush()} or {@link #close()} is called.
+     *
+     * @param writer the target {@link Writer}
+     * @deprecated 2.5 use {@link #WriterOutputStream(Writer, Charset)} instead
+     */
+    @Deprecated
+    public WriterOutputStream(final Writer writer) {
+        this(writer, Charset.defaultCharset(), BUFFER_SIZE, false);
+    }
+
+    /**
+     * Constructs a new {@link WriterOutputStream} with a default output buffer size of {@value #BUFFER_SIZE}
+     * characters. The output buffer will only be flushed when it overflows or when {@link #flush()} or {@link #close()}
+     * is called.
+     *
+     * @param writer the target {@link Writer}
+     * @param charset the charset encoding
+     */
+    public WriterOutputStream(final Writer writer, final Charset charset) {
+        this(writer, charset, BUFFER_SIZE, false);
+    }
+
+    /**
+     * Constructs a new {@link WriterOutputStream}.
+     *
+     * @param writer the target {@link Writer}
+     * @param charset the charset encoding
+     * @param bufferSize the size of the output buffer in number of characters
+     * @param writeImmediately If {@code true} the output buffer will be flushed after each
+     *                         write operation, i.e. all available data will be written to the
+     *                         underlying {@link Writer} immediately. If {@code false}, the
+     *                         output buffer will only be flushed when it overflows or when
+     *                         {@link #flush()} or {@link #close()} is called.
+     */
+    public WriterOutputStream(final Writer writer, final Charset charset, final int bufferSize, final boolean writeImmediately) {
+        // @formatter:off
+        this(writer,
+            Charsets.toCharset(charset).newDecoder()
+                    .onMalformedInput(CodingErrorAction.REPLACE)
+                    .onUnmappableCharacter(CodingErrorAction.REPLACE)
+                    .replaceWith("?"),
+             bufferSize,
+             writeImmediately);
+        // @formatter:on
+    }
+
+    /**
+     * Constructs a new {@link WriterOutputStream} with a default output buffer size of {@value #BUFFER_SIZE}
+     * characters. The output buffer will only be flushed when it overflows or when {@link #flush()} or {@link #close()}
+     * is called.
+     *
+     * @param writer the target {@link Writer}
+     * @param decoder the charset decoder
+     * @since 2.1
+     */
+    public WriterOutputStream(final Writer writer, final CharsetDecoder decoder) {
+        this(writer, decoder, BUFFER_SIZE, false);
+    }
+
+    /**
+     * Constructs a new {@link WriterOutputStream}.
+     *
+     * @param writer the target {@link Writer}
+     * @param decoder the charset decoder
+     * @param bufferSize the size of the output buffer in number of characters
+     * @param writeImmediately If {@code true} the output buffer will be flushed after each
+     *                         write operation, i.e. all available data will be written to the
+     *                         underlying {@link Writer} immediately. If {@code false}, the
+     *                         output buffer will only be flushed when it overflows or when
+     *                         {@link #flush()} or {@link #close()} is called.
+     * @since 2.1
+     */
+    public WriterOutputStream(final Writer writer, final CharsetDecoder decoder, final int bufferSize, final boolean writeImmediately) {
+        checkIbmJdkWithBrokenUTF16(CharsetDecoders.toCharsetDecoder(decoder).charset());
+        this.writer = writer;
+        this.decoder = CharsetDecoders.toCharsetDecoder(decoder);
+        this.writeImmediately = writeImmediately;
+        decoderOut = CharBuffer.allocate(bufferSize);
+    }
+
+    /**
+     * Constructs a new {@link WriterOutputStream} with a default output buffer size of {@value #BUFFER_SIZE}
+     * characters. The output buffer will only be flushed when it overflows or when {@link #flush()} or {@link #close()}
+     * is called.
+     *
+     * @param writer the target {@link Writer}
+     * @param charsetName the name of the charset encoding
+     */
+    public WriterOutputStream(final Writer writer, final String charsetName) {
+        this(writer, charsetName, BUFFER_SIZE, false);
+    }
+
+    /**
+     * Constructs a new {@link WriterOutputStream}.
+     *
+     * @param writer the target {@link Writer}
+     * @param charsetName the name of the charset encoding
+     * @param bufferSize the size of the output buffer in number of characters
+     * @param writeImmediately If {@code true} the output buffer will be flushed after each
+     *                         write operation, i.e. all available data will be written to the
+     *                         underlying {@link Writer} immediately. If {@code false}, the
+     *                         output buffer will only be flushed when it overflows or when
+     *                         {@link #flush()} or {@link #close()} is called.
+     */
+    public WriterOutputStream(final Writer writer, final String charsetName, final int bufferSize,
+                              final boolean writeImmediately) {
+        this(writer, Charsets.toCharset(charsetName), bufferSize, writeImmediately);
+    }
+
+    /**
+     * Close the stream. Any remaining content accumulated in the output buffer
+     * will be written to the underlying {@link Writer}. After that
+     * {@link Writer#close()} will be called.
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void close() throws IOException {
+        processInput(true);
+        flushOutput();
+        writer.close();
+    }
+
+    /**
+     * Flush the stream. Any remaining content accumulated in the output buffer
+     * will be written to the underlying {@link Writer}. After that
+     * {@link Writer#flush()} will be called.
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void flush() throws IOException {
+        flushOutput();
+        writer.flush();
+    }
+
+    /**
+     * Flush the output.
+     *
+     * @throws IOException if an I/O error occurs.
+     */
+    private void flushOutput() throws IOException {
+        if (decoderOut.position() > 0) {
+            writer.write(decoderOut.array(), 0, decoderOut.position());
+            decoderOut.rewind();
+        }
+    }
+
+    /**
+     * Decode the contents of the input ByteBuffer into a CharBuffer.
+     *
+     * @param endOfInput indicates end of input
+     * @throws IOException if an I/O error occurs.
+     */
+    private void processInput(final boolean endOfInput) throws IOException {
+        // Prepare decoderIn for reading
+        decoderIn.flip();
+        CoderResult coderResult;
+        while (true) {
+            coderResult = decoder.decode(decoderIn, decoderOut, endOfInput);
+            if (coderResult.isOverflow()) {
+                flushOutput();
+            } else if (coderResult.isUnderflow()) {
+                break;
+            } else {
+                // The decoder is configured to replace malformed input and unmappable characters,
+                // so we should not get here.
+                throw new IOException("Unexpected coder result");
+            }
+        }
+        // Discard the bytes that have been read
+        decoderIn.compact();
+    }
+
+    /**
+     * Write bytes from the specified byte array to the stream.
+     *
+     * @param b the byte array containing the bytes to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final byte[] b) throws IOException {
+        write(b, 0, b.length);
+    }
+
+    /**
+     * Write bytes from the specified byte array to the stream.
+     *
+     * @param b the byte array containing the bytes to write
+     * @param off the start offset in the byte array
+     * @param len the number of bytes to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final byte[] b, int off, int len) throws IOException {
+        while (len > 0) {
+            final int c = Math.min(len, decoderIn.remaining());
+            decoderIn.put(b, off, c);
+            processInput(false);
+            len -= c;
+            off += c;
+        }
+        if (writeImmediately) {
+            flushOutput();
+        }
+    }
+
+    /**
+     * Write a single byte to the stream.
+     *
+     * @param b the byte to write
+     * @throws IOException if an I/O error occurs.
+     */
+    @Override
+    public void write(final int b) throws IOException {
+        write(new byte[] {(byte) b}, 0, 1);
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/output/XmlStreamWriter.java b/src/main/java/org/apache/commons/io/output/XmlStreamWriter.java
new file mode 100644
index 0000000..ab55e09
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/XmlStreamWriter.java
@@ -0,0 +1,234 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.regex.Matcher;
+
+import org.apache.commons.io.Charsets;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.input.XmlStreamReader;
+
+/**
+ * Character stream that handles all the necessary Voodoo to figure out the
+ * charset encoding of the XML document written to the stream.
+ *
+ * @see XmlStreamReader
+ * @since 2.0
+ */
+public class XmlStreamWriter extends Writer {
+
+    private static final int BUFFER_SIZE = IOUtils.DEFAULT_BUFFER_SIZE;
+
+    private final OutputStream out;
+
+    private final Charset defaultCharset;
+
+    private StringWriter prologWriter = new StringWriter(BUFFER_SIZE);
+
+    private Writer writer;
+
+    private Charset charset;
+
+    /**
+     * Constructs a new XML stream writer for the specified file
+     * with a default encoding of UTF-8.
+     *
+     * @param file The file to write to
+     * @throws FileNotFoundException if there is an error creating or
+     * opening the file
+     */
+    public XmlStreamWriter(final File file) throws FileNotFoundException {
+        this(file, null);
+    }
+
+    /**
+     * Constructs a new XML stream writer for the specified file
+     * with the specified default encoding.
+     *
+     * @param file The file to write to
+     * @param defaultEncoding The default encoding if not encoding could be detected
+     * @throws FileNotFoundException if there is an error creating or
+     * opening the file
+     */
+    @SuppressWarnings("resource")
+    public XmlStreamWriter(final File file, final String defaultEncoding) throws FileNotFoundException {
+        this(new FileOutputStream(file), defaultEncoding);
+    }
+
+    /**
+     * Constructs a new XML stream writer for the specified output stream
+     * with a default encoding of UTF-8.
+     *
+     * @param out The output stream
+     */
+    public XmlStreamWriter(final OutputStream out) {
+        this(out, StandardCharsets.UTF_8);
+    }
+
+    /**
+     * Constructs a new XML stream writer for the specified output stream
+     * with the specified default encoding.
+     *
+     * @param out The output stream
+     * @param defaultEncoding The default encoding if not encoding could be detected
+     * @since 2.12.0
+     */
+    public XmlStreamWriter(final OutputStream out, final Charset defaultEncoding) {
+        this.out = out;
+        this.defaultCharset = Objects.requireNonNull(defaultEncoding);
+    }
+
+    /**
+     * Constructs a new XML stream writer for the specified output stream
+     * with the specified default encoding.
+     *
+     * @param out The output stream
+     * @param defaultEncoding The default encoding if not encoding could be detected
+     */
+    public XmlStreamWriter(final OutputStream out, final String defaultEncoding) {
+        this(out, Charsets.toCharset(defaultEncoding, StandardCharsets.UTF_8));
+    }
+
+    /**
+     * Closes the underlying writer.
+     *
+     * @throws IOException if an error occurs closing the underlying writer
+     */
+    @Override
+    public void close() throws IOException {
+        if (writer == null) {
+            charset = defaultCharset;
+            writer = new OutputStreamWriter(out, charset);
+            writer.write(prologWriter.toString());
+        }
+        writer.close();
+    }
+
+    /**
+     * Detects the encoding.
+     *
+     * @param cbuf the buffer to write the characters from
+     * @param off The start offset
+     * @param len The number of characters to write
+     * @throws IOException if an error occurs detecting the encoding
+     */
+    private void detectEncoding(final char[] cbuf, final int off, final int len)
+            throws IOException {
+        int size = len;
+        final StringBuffer xmlProlog = prologWriter.getBuffer();
+        if (xmlProlog.length() + len > BUFFER_SIZE) {
+            size = BUFFER_SIZE - xmlProlog.length();
+        }
+        prologWriter.write(cbuf, off, size);
+
+        // try to determine encoding
+        if (xmlProlog.length() >= 5) {
+            if (xmlProlog.substring(0, 5).equals("<?xml")) {
+                // try to extract encoding from XML prolog
+                final int xmlPrologEnd = xmlProlog.indexOf("?>");
+                if (xmlPrologEnd > 0) {
+                    // ok, full XML prolog written: let's extract encoding
+                    final Matcher m = XmlStreamReader.ENCODING_PATTERN.matcher(xmlProlog.substring(0,
+                            xmlPrologEnd));
+                    if (m.find()) {
+                        final String encName = m.group(1).toUpperCase(Locale.ROOT);
+                        charset = Charset.forName(encName.substring(1, encName.length() - 1));
+                    } else {
+                        // no encoding found in XML prolog: using default
+                        // encoding
+                        charset = defaultCharset;
+                    }
+                } else if (xmlProlog.length() >= BUFFER_SIZE) {
+                    // no encoding found in first characters: using default
+                    // encoding
+                    charset = defaultCharset;
+                }
+            } else {
+                // no XML prolog: using default encoding
+                charset = defaultCharset;
+            }
+            if (charset != null) {
+                // encoding has been chosen: let's do it
+                prologWriter = null;
+                writer = new OutputStreamWriter(out, charset);
+                writer.write(xmlProlog.toString());
+                if (len > size) {
+                    writer.write(cbuf, off + size, len - size);
+                }
+            }
+        }
+    }
+
+    /**
+     * Flushes the underlying writer.
+     *
+     * @throws IOException if an error occurs flushing the underlying writer
+     */
+    @Override
+    public void flush() throws IOException {
+        if (writer != null) {
+            writer.flush();
+        }
+    }
+
+    /**
+     * Returns the default encoding.
+     *
+     * @return the default encoding
+     */
+    public String getDefaultEncoding() {
+        return defaultCharset.name();
+    }
+
+    /**
+     * Returns the detected encoding.
+     *
+     * @return the detected encoding
+     */
+    public String getEncoding() {
+        return charset.name();
+    }
+
+    /**
+     * Writes the characters to the underlying writer, detecting encoding.
+     *
+     * @param cbuf the buffer to write the characters from
+     * @param off The start offset
+     * @param len The number of characters to write
+     * @throws IOException if an error occurs detecting the encoding
+     */
+    @Override
+    public void write(final char[] cbuf, final int off, final int len) throws IOException {
+        if (prologWriter != null) {
+            detectEncoding(cbuf, off, len);
+        } else {
+            writer.write(cbuf, off, len);
+        }
+    }
+}
diff --git a/src/main/java/org/apache/commons/io/output/package-info.java b/src/main/java/org/apache/commons/io/output/package-info.java
new file mode 100644
index 0000000..04accce
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/output/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * This package provides implementations of output classes, such as {@link java.io.OutputStream} and
+ * {@link java.io.Writer}.
+ */
+package org.apache.commons.io.output;
diff --git a/src/main/java/org/apache/commons/io/overview.html b/src/main/java/org/apache/commons/io/overview.html
new file mode 100644
index 0000000..5f2ff1e
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/overview.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<html>
+<body bgcolor="white">
+<p>
+The commons-io component contains utility classes,
+filters, streams, readers and writers.
+</p>
+<p>
+These classes aim to add to the standard JDK IO classes.
+The utilities provide convenience wrappers around the JDK, simplifying
+various operations into pre-tested units of code.
+The filters and streams provide useful implementations that perhaps should
+be in the JDK itself.
+</p>
+</body>
+</html>
diff --git a/src/main/java/org/apache/commons/io/package.html b/src/main/java/org/apache/commons/io/package.html
new file mode 100644
index 0000000..914a0a6
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/package.html
@@ -0,0 +1,47 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<html>
+<body bgcolor="white">
+<p>
+This package defines utility classes for working with streams, readers,
+writers and files. The most commonly used classes are described here:
+</p>
+<p>
+<b>IOUtils</b> is the most frequently used class.
+It provides operations to read, write, copy and close streams.
+</p>
+<p>
+<b>FileUtils</b> provides operations based around the JDK File class.
+These include reading, writing, copying, comparing and deleting.
+</p>
+<p>
+<b>FilenameUtils</b> provides utilities based on filenames.
+This utility class manipulates filenames without using File objects.
+It aims to simplify the transition between Windows and Unix.
+Before using this class however, you should consider whether you should
+be using File objects.
+</p>
+<p>
+<b>FileSystemUtils</b> allows access to the filing system in ways the JDK
+does not support. At present this allows you to get the free space on a drive.
+</p>
+<p>
+<b>EndianUtils</b> swaps data between Big-Endian and Little-Endian formats.
+</p>
+</body>
+</html>
diff --git a/src/main/java/org/apache/commons/io/serialization/ClassNameMatcher.java b/src/main/java/org/apache/commons/io/serialization/ClassNameMatcher.java
new file mode 100644
index 0000000..f454bc3
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/serialization/ClassNameMatcher.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.commons.io.serialization;
+
+/**
+ * An object that matches a Class name to a condition.
+ */
+public interface ClassNameMatcher {
+
+    /**
+     * Returns {@code true} if the supplied class name matches this object's condition.
+     *
+     * @param className fully qualified class name
+     * @return {@code true} if the class name matches this object's condition
+     */
+    boolean matches(String className);
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/commons/io/serialization/FullClassNameMatcher.java b/src/main/java/org/apache/commons/io/serialization/FullClassNameMatcher.java
new file mode 100644
index 0000000..873b433
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/serialization/FullClassNameMatcher.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.commons.io.serialization;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * A {@link ClassNameMatcher} that matches on full class names.
+ * <p>
+ * This object is immutable and thread-safe.
+ * </p>
+ */
+final class FullClassNameMatcher implements ClassNameMatcher {
+
+    private final Set<String> classesSet;
+
+    /**
+     * Constructs an object based on the specified class names.
+     *
+     * @param classes a list of class names
+     */
+    public FullClassNameMatcher(final String... classes) {
+        classesSet = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(classes)));
+    }
+
+    @Override
+    public boolean matches(final String className) {
+        return classesSet.contains(className);
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/commons/io/serialization/RegexpClassNameMatcher.java b/src/main/java/org/apache/commons/io/serialization/RegexpClassNameMatcher.java
new file mode 100644
index 0000000..c2c21f5
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/serialization/RegexpClassNameMatcher.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.commons.io.serialization;
+
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+/**
+ * A {@link ClassNameMatcher} that uses regular expressions.
+ * <p>
+ * This object is immutable and thread-safe.
+ * </p>
+ */
+final class RegexpClassNameMatcher implements ClassNameMatcher {
+
+    private final Pattern pattern; // Class is thread-safe
+
+    /**
+     * Constructs an object based on the specified pattern.
+     *
+     * @param pattern a pattern for evaluating acceptable class names
+     * @throws NullPointerException if {@code pattern} is null
+     */
+    public RegexpClassNameMatcher(final Pattern pattern) {
+        this.pattern = Objects.requireNonNull(pattern, "pattern");
+    }
+
+    /**
+     * Constructs an object based on the specified regular expression.
+     *
+     * @param regex a regular expression for evaluating acceptable class names
+     */
+    public RegexpClassNameMatcher(final String regex) {
+        this(Pattern.compile(regex));
+    }
+
+    @Override
+    public boolean matches(final String className) {
+        return pattern.matcher(className).matches();
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/commons/io/serialization/ValidatingObjectInputStream.java b/src/main/java/org/apache/commons/io/serialization/ValidatingObjectInputStream.java
new file mode 100644
index 0000000..bde163c
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/serialization/ValidatingObjectInputStream.java
@@ -0,0 +1,202 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.commons.io.serialization;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InvalidClassException;
+import java.io.ObjectInputStream;
+import java.io.ObjectStreamClass;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+/**
+ * An {@link ObjectInputStream} that's restricted to deserialize
+ * a limited set of classes.
+ *
+ * <p>
+ * Various accept/reject methods allow for specifying which classes
+ * can be deserialized.
+ * </p>
+ *
+ * <p>
+ * Design inspired by <a
+ * href="http://www.ibm.com/developerworks/library/se-lookahead/">IBM
+ * DeveloperWorks Article</a>.
+ * </p>
+ */
+public class ValidatingObjectInputStream extends ObjectInputStream {
+    private final List<ClassNameMatcher> acceptMatchers = new ArrayList<>();
+    private final List<ClassNameMatcher> rejectMatchers = new ArrayList<>();
+
+    /**
+     * Constructs an object to deserialize the specified input stream.
+     * At least one accept method needs to be called to specify which
+     * classes can be deserialized, as by default no classes are
+     * accepted.
+     *
+     * @param input an input stream
+     * @throws IOException if an I/O error occurs while reading stream header
+     */
+    public ValidatingObjectInputStream(final InputStream input) throws IOException {
+        super(input);
+    }
+
+    /**
+     * Accept the specified classes for deserialization, unless they
+     * are otherwise rejected.
+     *
+     * @param classes Classes to accept
+     * @return this object
+     */
+    public ValidatingObjectInputStream accept(final Class<?>... classes) {
+        Stream.of(classes).map(c -> new FullClassNameMatcher(c.getName())).forEach(acceptMatchers::add);
+        return this;
+    }
+
+    /**
+     * Accept class names where the supplied ClassNameMatcher matches for
+     * deserialization, unless they are otherwise rejected.
+     *
+     * @param m the matcher to use
+     * @return this object
+     */
+    public ValidatingObjectInputStream accept(final ClassNameMatcher m) {
+        acceptMatchers.add(m);
+        return this;
+    }
+
+    /**
+     * Accept class names that match the supplied pattern for
+     * deserialization, unless they are otherwise rejected.
+     *
+     * @param pattern standard Java regexp
+     * @return this object
+     */
+    public ValidatingObjectInputStream accept(final Pattern pattern) {
+        acceptMatchers.add(new RegexpClassNameMatcher(pattern));
+        return this;
+    }
+
+    /**
+     * Accept the wildcard specified classes for deserialization,
+     * unless they are otherwise rejected.
+     *
+     * @param patterns Wildcard file name patterns as defined by
+     *                  {@link org.apache.commons.io.FilenameUtils#wildcardMatch(String, String) FilenameUtils.wildcardMatch}
+     * @return this object
+     */
+    public ValidatingObjectInputStream accept(final String... patterns) {
+        Stream.of(patterns).map(WildcardClassNameMatcher::new).forEach(acceptMatchers::add);
+        return this;
+    }
+
+    /**
+     * Called to throw {@link InvalidClassException} if an invalid
+     * class name is found during deserialization. Can be overridden, for example
+     * to log those class names.
+     *
+     * @param className name of the invalid class
+     * @throws InvalidClassException if the specified class is not allowed
+     */
+    protected void invalidClassNameFound(final String className) throws InvalidClassException {
+        throw new InvalidClassException("Class name not accepted: " + className);
+    }
+
+    /**
+     * Reject the specified classes for deserialization, even if they
+     * are otherwise accepted.
+     *
+     * @param classes Classes to reject
+     * @return this object
+     */
+    public ValidatingObjectInputStream reject(final Class<?>... classes) {
+        Stream.of(classes).map(c -> new FullClassNameMatcher(c.getName())).forEach(rejectMatchers::add);
+        return this;
+    }
+
+    /**
+     * Reject class names where the supplied ClassNameMatcher matches for
+     * deserialization, even if they are otherwise accepted.
+     *
+     * @param m the matcher to use
+     * @return this object
+     */
+    public ValidatingObjectInputStream reject(final ClassNameMatcher m) {
+        rejectMatchers.add(m);
+        return this;
+    }
+
+    /**
+     * Reject class names that match the supplied pattern for
+     * deserialization, even if they are otherwise accepted.
+     *
+     * @param pattern standard Java regexp
+     * @return this object
+     */
+    public ValidatingObjectInputStream reject(final Pattern pattern) {
+        rejectMatchers.add(new RegexpClassNameMatcher(pattern));
+        return this;
+    }
+
+    /**
+     * Reject the wildcard specified classes for deserialization,
+     * even if they are otherwise accepted.
+     *
+     * @param patterns Wildcard file name patterns as defined by
+     *                  {@link org.apache.commons.io.FilenameUtils#wildcardMatch(String, String) FilenameUtils.wildcardMatch}
+     * @return this object
+     */
+    public ValidatingObjectInputStream reject(final String... patterns) {
+        Stream.of(patterns).map(WildcardClassNameMatcher::new).forEach(rejectMatchers::add);
+        return this;
+    }
+
+    @Override
+    protected Class<?> resolveClass(final ObjectStreamClass osc) throws IOException, ClassNotFoundException {
+        validateClassName(osc.getName());
+        return super.resolveClass(osc);
+    }
+
+    /** Check that the classname conforms to requirements.
+     * @param name The class name
+     * @throws InvalidClassException when a non-accepted class is encountered
+     */
+    private void validateClassName(final String name) throws InvalidClassException {
+        // Reject has precedence over accept
+        for (final ClassNameMatcher m : rejectMatchers) {
+            if (m.matches(name)) {
+                invalidClassNameFound(name);
+            }
+        }
+
+        boolean ok = false;
+        for (final ClassNameMatcher m : acceptMatchers) {
+            if (m.matches(name)) {
+                ok = true;
+                break;
+            }
+        }
+        if (!ok) {
+            invalidClassNameFound(name);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/commons/io/serialization/WildcardClassNameMatcher.java b/src/main/java/org/apache/commons/io/serialization/WildcardClassNameMatcher.java
new file mode 100644
index 0000000..de57c39
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/serialization/WildcardClassNameMatcher.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.commons.io.serialization;
+
+import org.apache.commons.io.FilenameUtils;
+
+/**
+ * A {@link ClassNameMatcher} that uses simplified regular expressions
+ *  provided by {@link org.apache.commons.io.FilenameUtils#wildcardMatch(String, String) FilenameUtils.wildcardMatch}
+ * <p>
+ * This object is immutable and thread-safe.
+ * </p>
+ */
+final class WildcardClassNameMatcher implements ClassNameMatcher {
+
+    private final String pattern;
+
+    /**
+     * Constructs an object based on the specified simplified regular expression.
+     *
+     * @param pattern a {@link FilenameUtils#wildcardMatch} pattern.
+     */
+    public WildcardClassNameMatcher(final String pattern) {
+        this.pattern = pattern;
+    }
+
+    @Override
+    public boolean matches(final String className) {
+        return FilenameUtils.wildcardMatch(className, pattern);
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/commons/io/serialization/package-info.java b/src/main/java/org/apache/commons/io/serialization/package-info.java
new file mode 100644
index 0000000..b953219
--- /dev/null
+++ b/src/main/java/org/apache/commons/io/serialization/package-info.java
@@ -0,0 +1,21 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * This package provides a framework for controlling the deserialization of classes.
+ */
+package org.apache.commons.io.serialization;
diff --git a/src/media/io-logo-white.xcf b/src/media/io-logo-white.xcf
new file mode 100644
index 0000000..a3c899b
--- /dev/null
+++ b/src/media/io-logo-white.xcf
Binary files differ
diff --git a/src/media/logo.gif b/src/media/logo.gif
new file mode 100644
index 0000000..314441b
--- /dev/null
+++ b/src/media/logo.gif
Binary files differ
diff --git a/src/site/resources/download_io.cgi b/src/site/resources/download_io.cgi
new file mode 100755
index 0000000..495cde1
--- /dev/null
+++ b/src/site/resources/download_io.cgi
@@ -0,0 +1,4 @@
+#!/bin/sh
+# Just call the standard mirrors.cgi script. It will use download.html
+# as the input template.
+exec /www/www.apache.org/dyn/mirrors/mirrors.cgi $*
\ No newline at end of file
diff --git a/src/site/resources/images/io-logo-white.png b/src/site/resources/images/io-logo-white.png
new file mode 100644
index 0000000..75be7af
--- /dev/null
+++ b/src/site/resources/images/io-logo-white.png
Binary files differ
diff --git a/src/site/resources/profile.jacoco b/src/site/resources/profile.jacoco
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/site/resources/profile.jacoco
diff --git a/src/site/site.xml b/src/site/site.xml
new file mode 100644
index 0000000..09aafda
--- /dev/null
+++ b/src/site/site.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="ISO-8859-1"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<project name="Commons IO">
+    <bannerRight>
+        <name>Commons IO</name>
+        <src>/images/io-logo-white.png</src>
+        <href>/index.html</href>
+    </bannerRight>
+
+    <body>
+        <menu name="Commons IO">
+            <item name="Overview"             href="/index.html"/>
+            <item name="Download"             href="https://commons.apache.org/io/download_io.cgi"/>
+            <item name="User guide"           href="/description.html"/>
+            <item name="Best practices"       href="/bestpractices.html"/>
+            <item name="Javadoc"              href="/apidocs/index.html"/>
+            <item name="Javadoc Archive"      href="https://javadoc.io/doc/commons-io/commons-io/latest/index.html"/>
+        </menu>
+
+        <menu name="Development">
+            <item name="Building"             href="/building.html"/>
+            <item name="Mailing lists"        href="/mail-lists.html"/>
+            <item name="Issue Tracking"       href="/issue-tracking.html"/>
+            <item name="Team"                 href="/team.html"/>
+            <item name="Tasks"                href="/tasks.html"/>
+            <item name="Proposal"             href="/proposal.html"/>
+            <item name="Source repository"    href="/scm.html"/>
+        </menu>
+
+    </body>
+
+</project>
diff --git a/src/site/xdoc/bestpractices.xml b/src/site/xdoc/bestpractices.xml
new file mode 100644
index 0000000..eb1a61e
--- /dev/null
+++ b/src/site/xdoc/bestpractices.xml
@@ -0,0 +1,135 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Best practices</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+  <body>
+
+    <section name="Best practices">
+        <p>
+            This document presents a number of 'best practices' in the IO area.
+        </p>
+    </section>
+
+    <section name="java.io.File">
+    
+        <p>
+            Often, you have to deal with files and filenames. There are many
+            things that can go wrong:
+        </p>
+        <ul>
+            <li>A class works in Unix but doesn't on Windows (or vice versa)</li>
+            <li>Invalid filenames due to double or missing path separators</li>
+            <li>UNC filenames (on Windows) don't work with my home-grown filename utility function</li>
+            <li>etc. etc.</li>
+        </ul>
+        <p>
+            These are good reasons not to work with filenames as Strings.
+            Using java.io.File instead handles many of the above cases nicely.
+            Thus, our best practice recommendation is to use java.io.File
+            instead of String for filenames to avoid platform dependencies.
+        </p>
+        <p>
+            <i>
+            Version 1.1 of commons-io now includes a dedicated filename
+            handling class - <a href="apidocs/index.html?org/apache/commons/io/FilenameUtils.html">FilenameUtils</a>.
+            This does handle many of these filename issues, however we still
+            recommend, wherever possible, that you use java.io.File objects.
+            </i>
+        </p>
+        <p>
+            Let's look at an example.
+        </p>
+        <source>
+ public static String getExtension(String filename) {
+   int index = filename.lastIndexOf('.');
+   if (index == -1) {
+     return "";
+   } else {
+     return filename.substring(index + 1);
+   }
+ }</source>
+        <p>
+            Easy enough? Right, but what happens if someone passes in a full path 
+            instead of only a filename? Consider the following, perfectly legal path:
+            "C:\Temp\documentation.new\README".
+            The method as defined above would return "new\README" - definitely
+            not what you wanted.
+        </p>
+        <p>
+            Please use java.io.File for filenames instead of Strings.
+            The functionality that the class provides is well tested.
+            In FileUtils you will find other useful utility functions
+            around java.io.File.
+        </p>
+        <p>
+            Instead of:
+        </p>
+        <source>
+ String tmpdir = "/var/tmp";
+ String tmpfile = tmpdir + System.getProperty("file.separator") + "test.tmp";
+ InputStream in = new java.io.FileInputStream(tmpfile);</source>
+        <p>
+            ...write:
+        </p>
+        <source>
+ File tmpdir = new File("/var/tmp");
+ File tmpfile = new File(tmpdir, "test.tmp");
+ InputStream in = new java.io.FileInputStream(tmpfile);</source>
+
+    </section>
+    
+    <section name="Buffering streams">
+        <p>
+            IO performance depends a lot on the buffering strategy. Usually, it's
+            quite fast to read packets with the size of 512 or 1024 bytes because
+            these sizes match well with the packet sizes used on hard disks in
+            file systems or file system caches. But as soon as you have to read only 
+            a few bytes and that many times performance drops significantly.
+        </p>
+        <p>
+            Make sure you're properly buffering streams when reading or writing 
+            streams, especially when working with files. Just decorate your 
+            FileInputStream with a BufferedInputStream:
+        </p>
+        <source>
+ InputStream in = new java.io.FileInputStream(myfile);
+ try {
+   in = new java.io.BufferedInputStream(in);
+   
+   in.read(.....
+ } finally {
+   IOUtils.closeQuietly(in);
+ }</source>
+        <p>
+            Pay attention that you're not buffering an already buffered stream. Some
+            components like XML parsers may do their own buffering so decorating
+            the InputStream you pass to the XML parser does nothing but slowing down
+            your code. If you use our CopyUtils or IOUtils you don't need to 
+            additionally buffer the streams you use as the code in there already
+            buffers the copy process. Always check the Javadocs for information. 
+            Another case where buffering is unnecessary is when you write to a 
+            ByteArrayOutputStream since you're writing to memory only.
+        </p>
+    </section>
+
+  </body>
+
+</document>
diff --git a/src/site/xdoc/building.xml b/src/site/xdoc/building.xml
new file mode 100644
index 0000000..c0143d8
--- /dev/null
+++ b/src/site/xdoc/building.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Building</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+<body>
+<!-- ================================================== -->
+<section name="Overview">
+<p>
+  Commons IO uses <a href="http://maven.apache.org">Maven</a> its build system.
+</p>
+<p>
+  Commons IO requires a minimum of JDK 8 to build.
+</p>
+<p>
+  You may also be interested in the upgrade notes:<br />
+  Upgrade <a href="upgradeto2_0_1.html">from 2.0 to 2.0.1</a><br />
+  Upgrade <a href="upgradeto2_0.html">from 1.4 to 2.0</a><br />
+  Upgrade <a href="upgradeto1_4.html">from 1.3.2 to 1.4</a><br />
+  Upgrade <a href="upgradeto1_3_2.html">from 1.3, or 1.3.1 to 1.3.2</a><br />
+  Upgrade <a href="upgradeto1_3_1.html">from 1.3 to 1.3.1</a><br />
+  Upgrade <a href="upgradeto1_3.html">from 1.2 to 1.3</a><br />
+  Upgrade <a href="upgradeto1_2.html">from 1.1 to 1.2</a><br />
+  Upgrade <a href="upgradeto1_1.html">from 1.0 to 1.1</a><br />
+</p>
+</section>
+<section name="Maven Goals">
+  <p>
+    The following <a href="http://maven.apache.org">Maven</a> commands can be used to build io:
+  </p>
+  <ul>
+    <li><code>mvn clean</code> - clean up</li>
+    <li><code>mvn test</code> - compile and run the unit tests</li>
+    <li><code>mvn site</code> - create io documentation</li>
+    <li><code>mvn package</code> - build the jar</li>
+  </ul>
+</section>
+</body>
+</document>
diff --git a/src/site/xdoc/description.xml b/src/site/xdoc/description.xml
new file mode 100644
index 0000000..828f424
--- /dev/null
+++ b/src/site/xdoc/description.xml
@@ -0,0 +1,259 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>User guide</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+  <body>
+
+    <section name="User guide">
+        <p>
+            Commons-IO contains
+            <a href="#Utility classes">utility classes</a>,
+            <a href="#Endian classes">endian classes</a>,
+            <a href="#Line iterator">line iterator</a>,
+            <a href="#File filters">file filters</a>,
+            <a href="#File comparators">file comparators</a> and
+            <a href="#Streams">stream implementations</a>.
+        </p>
+
+        <p>
+            For a more detailed descriptions, take a look at the
+            <a href="api-release/index.html">Javadocs</a>.
+        </p>
+    </section>
+
+    <section name="Utility classes">
+        <subsection name="IOUtils">
+            <p>
+                <a href="apidocs/index.html?org/apache/commons/io/IOUtils.html">IOUtils</a>
+                contains utility methods dealing with reading, writing and copying.
+                The methods work on InputStream, OutputStream, Reader and Writer.
+            </p>
+            <p>
+                As an example, consider the task of reading bytes
+                from a URL, and printing them. This would typically be done like this:
+            </p>
+
+            <source>
+ InputStream in = new URL( "https://commons.apache.org" ).openStream();
+ try {
+   InputStreamReader inR = new InputStreamReader( in );
+   BufferedReader buf = new BufferedReader( inR );
+   String line;
+   while ( ( line = buf.readLine() ) != null ) {
+     System.out.println( line );
+   }
+ } finally {
+   in.close();
+ }</source>
+
+            <p>
+                With the IOUtils class, that could be done with:
+            </p>
+
+            <source>
+ InputStream in = new URL( "https://commons.apache.org" ).openStream();
+ try {
+   System.out.println( IOUtils.toString( in ) );
+ } finally {
+   IOUtils.closeQuietly(in);
+ }</source>
+
+            <p>
+                In certain application domains, such IO operations are
+                common, and this class can save a great deal of time. And you can
+                rely on well-tested code.
+            </p>
+            <p>
+                For utility code such as this, flexibility and speed are of primary importance.
+                However you should also understand the limitations of this approach.
+                Using the above technique to read a 1GB file would result in an
+                attempt to create a 1GB String object!
+            </p>
+
+        </subsection>
+
+        <subsection name="FileUtils">
+            <p>
+                The <a href="apidocs/index.html?org/apache/commons/io/FileUtils.html">FileUtils</a>
+                class contains utility methods for working with File objects.
+                These include reading, writing, copying and comparing files.
+            </p>
+            <p>
+                For example to read an entire file line by line you could use:
+            </p>
+            <source>
+ File file = new File("/commons/io/project.properties");
+ List lines = FileUtils.readLines(file, "UTF-8");</source>
+        </subsection>
+
+        <subsection name="FilenameUtils">
+            <p>
+                The <a href="apidocs/index.html?org/apache/commons/io/FilenameUtils.html">FilenameUtils</a>
+                class contains utility methods for working with filenames <i>without</i>
+                using File objects. The class aims to be consistent
+                between Unix and Windows, to aid transitions between these
+                environments (such as moving from development to production).
+            </p>
+            <p>
+                For example to normalize a filename removing double dot segments:
+            </p>
+            <source>
+ String filename = "C:/commons/io/../lang/project.xml";
+ String normalized = FilenameUtils.normalize(filename);
+ // result is "C:/commons/lang/project.xml"</source>
+        </subsection>
+
+        <subsection name="FileSystemUtils">
+            <p>
+                The <a href="apidocs/index.html?org/apache/commons/io/FileSystemUtils.html">FileSystemUtils</a>
+                class contains
+                utility methods for working with the file system
+                to access functionality not supported by the JDK.
+                Currently, the only method is to get the free space on a drive.
+                Note that this uses the command line, not native code.
+            </p>
+            <p>
+                For example to find the free space on a drive:
+            </p>
+            <source>
+ long freeSpace = FileSystemUtils.freeSpace("C:/");</source>
+        </subsection>
+
+    </section>
+
+    <section name="Endian classes">
+        <p>
+            Different computer architectures adopt different
+            conventions for byte ordering. In so-called
+            "Little Endian" architectures (eg Intel), the low-order
+            byte is stored in memory at the lowest address, and
+            subsequent bytes at higher addresses. For "Big Endian"
+            architectures (eg Motorola), the situation is reversed.
+        </p>
+
+        <p>
+        There are two classes in this package of relevance:
+        </p>
+
+        <ul>
+           <li>
+           The <a href="apidocs/index.html?org/apache/commons/io/EndianUtils.html">EndianUtils</a>
+           class contains static methods for swapping the Endian-ness
+           of Java primitives and streams.
+           </li>
+
+           <li>
+           The <a href="apidocs/index.html?org/apache/commons/io/input/SwappedDataInputStream.html">SwappedDataInputStream</a>
+           class is an implementation of the <code>DataInput</code> interface. With
+           this, one can read data from files of non-native Endian-ness.
+           </li>
+        </ul>
+
+        <p>
+            For more information, see
+            <a
+                href="http://www.cs.umass.edu/~verts/cs32/endian.html">http://www.cs.umass.edu/~verts/cs32/endian.html</a>
+         </p>
+
+    </section>
+
+    <section name="Line iterator">
+        <p>
+            The <code>org.apache.commons.io.LineIterator</code> class
+            provides a flexible way for working with a line-based file.
+            An instance can be created directly, or via factory methods on
+            <code>FileUtils</code> or <code>IOUtils</code>.
+            The recommended usage pattern is:
+        </p>
+        <source>
+ LineIterator it = FileUtils.lineIterator(file, "UTF-8");
+ try {
+   while (it.hasNext()) {
+     String line = it.nextLine();
+     /// do something with line
+   }
+ } finally {
+   LineIterator.closeQuietly(iterator);
+ }</source>
+    </section>
+
+    <section name="File filters">
+        <p>
+            The <code>org.apache.commons.io.filefilter</code>
+            package defines an interface
+            (<a href="apidocs/index.html?org/apache/commons/io/filefilter/IOFileFilter.html">IOFileFilter</a>)
+            that combines both <code>java.io.FileFilter</code> and
+            <code>java.io.FilenameFilter</code>. Besides
+            that the package offers a series of ready-to-use
+            implementations of the <code>IOFileFilter</code>
+            interface including
+            implementation that allow you to combine other such filters.
+
+            These filters can be used to list files or in FileDialog, for example.
+        </p>
+        <p>
+            See the
+            <a href="apidocs/index.html?org/apache/commons/io/filefilter/package-summary.html">filefilter</a>
+            package javadoc for more details.
+        </p>
+    </section>
+
+    <section name="File comparators">
+        <p>
+            The <code>org.apache.commons.io.comparator</code>
+            package provides a number of <code>java.util.Comparator</code>
+            implementations for <code>java.io.File</code>.
+
+            These comparators can be used to sort lists and arrays of files, for example.
+        </p>
+        <p>
+            See the
+            <a href="apidocs/index.html?org/apache/commons/io/comparator/package-summary.html">comparator</a>
+            package javadoc for more details.
+        </p>
+    </section>
+
+    <section name="Streams">
+        <p>
+            The <code>org.apache.commons.io.input</code> and
+            <code>org.apache.commons.io.output</code> packages
+            contain various useful implementations of streams.
+            These include:
+            <ul>
+            <li>Null output stream - that silently absorbs all data sent to it</li>
+            <li>Tee output stream - that sends output data to two streams instead of one</li>
+            <li>Byte array output stream - that is a faster version of the JDK class</li>
+            <li>Counting streams - that count the number of bytes passed</li>
+            <li>Proxy streams - that delegate to the correct method in the proxy</li>
+            <li>Lockable writer - that provides synchronization of writes using a lock file</li>
+            </ul>
+        </p>
+        <p>
+            See the
+            <a href="apidocs/index.html?org/apache/commons/io/input/package-summary.html">input</a> or
+            <a href="apidocs/index.html?org/apache/commons/io/output/package-summary.html">output</a>
+            package javadoc for more details.
+        </p>
+    </section>
+
+  </body>
+
+</document>
diff --git a/src/site/xdoc/download_io.xml b/src/site/xdoc/download_io.xml
new file mode 100644
index 0000000..bd6c097
--- /dev/null
+++ b/src/site/xdoc/download_io.xml
@@ -0,0 +1,156 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<!--
+ +======================================================================+
+ |****                                                              ****|
+ |****      THIS FILE IS GENERATED BY THE COMMONS BUILD PLUGIN      ****|
+ |****                    DO NOT EDIT DIRECTLY                      ****|
+ |****                                                              ****|
+ +======================================================================+
+ | TEMPLATE FILE: download-page-template.xml                            |
+ | commons-build-plugin/trunk/src/main/resources/commons-xdoc-templates |
+ +======================================================================+
+ |                                                                      |
+ | 1) Re-generate using: mvn commons-build:download-page                |
+ |                                                                      |
+ | 2) Set the following properties in the component's pom:              |
+ |    - commons.componentid     (required, alphabetic, lower case)      |
+ |    - commons.release.version (required)                              |
+ |    - commons.release.name    (required)                              |
+ |    - commons.binary.suffix   (optional)                              |
+ |      (defaults to "-bin", set to "" for pre-maven2 releases)         |
+ |    - commons.release.desc    (optional)                              |
+ |    - commons.release.subdir  (optional)                              |
+ |    - commons.release.hash    (optional, lowercase, default sha512)   |
+ |                                                                      |
+ |    - commons.release.[234].version       (conditional)               |
+ |    - commons.release.[234].name          (conditional)               |
+ |    - commons.release.[234].binary.suffix (optional)                  |
+ |    - commons.release.[234].desc          (optional)                  |
+ |    - commons.release.[234].subdir        (optional)                  |
+ |    - commons.release.[234].hash       (optional, lowercase, [sha512])|
+ |                                                                      |
+ | 3) Example Properties                                                |
+ |    (commons.release.name inherited by parent:                        |
+ |     ${project.artifactId}-${commons.release.version}                 |
+ |                                                                      |
+ |  <properties>                                                        |
+ |    <commons.componentid>math</commons.componentid>                   |
+ |    <commons.release.version>1.2</commons.release.version>            |
+ |  </properties>                                                       |
+ |                                                                      |
+ +======================================================================+
+-->
+<document>
+  <properties>
+    <title>Download Apache Commons IO</title>
+    <author email="dev@commons.apache.org">Apache Commons Documentation Team</author>
+  </properties>
+  <body>
+    <section name="Download Apache Commons IO">
+    <subsection name="Using a Mirror">
+      <p>
+        We recommend you use a mirror to download our release
+        builds, but you <strong>must</strong> <a href="https://www.apache.org/info/verification.html">verify the integrity</a> of
+        the downloaded files using signatures downloaded from our main
+        distribution directories. Recent releases (48 hours) may not yet
+        be available from all the mirrors.
+      </p>
+
+      <p>
+        You are currently using <b>[preferred]</b>.  If you
+        encounter a problem with this mirror, please select another
+        mirror.  If all mirrors are failing, there are <i>backup</i>
+        mirrors (at the end of the mirrors list) that should be
+        available.
+        <br></br>
+        [if-any logo]<a href="[link]"><img align="right" src="[logo]" border="0"></img></a>[end]
+      </p>
+
+      <form action="[location]" method="get" id="SelectMirror">
+        <p>
+          Other mirrors:
+          <select name="Preferred">
+          [if-any http]
+            [for http]<option value="[http]">[http]</option>[end]
+          [end]
+          [if-any ftp]
+            [for ftp]<option value="[ftp]">[ftp]</option>[end]
+          [end]
+          [if-any backup]
+            [for backup]<option value="[backup]">[backup] (backup)</option>[end]
+          [end]
+          </select>
+          <input type="submit" value="Change"></input>
+        </p>
+      </form>
+
+      <p>
+        It is essential that you
+        <a href="https://www.apache.org/info/verification.html">verify the integrity</a>
+        of downloaded files, preferably using the <code>PGP</code> signature (<code>*.asc</code> files);
+        failing that using the <code>SHA512</code> hash (<code>*.sha512</code> checksum files).
+      </p>
+      <p>
+        The <a href="https://www.apache.org/dist/commons/KEYS">KEYS</a>
+        file contains the public PGP keys used by Apache Commons developers
+        to sign releases.
+      </p>
+    </subsection>
+    </section>
+    <section name="Apache Commons IO 2.11.0 (requires Java 8)">
+      <subsection name="Binaries">
+        <table>
+          <tr>
+              <td><a href="[preferred]/commons/io/binaries/commons-io-2.11.0-bin.tar.gz">commons-io-2.11.0-bin.tar.gz</a></td>
+              <td><a href="https://www.apache.org/dist/commons/io/binaries/commons-io-2.11.0-bin.tar.gz.sha512">sha512</a></td>
+              <td><a href="https://www.apache.org/dist/commons/io/binaries/commons-io-2.11.0-bin.tar.gz.asc">pgp</a></td>
+          </tr>
+          <tr>
+              <td><a href="[preferred]/commons/io/binaries/commons-io-2.11.0-bin.zip">commons-io-2.11.0-bin.zip</a></td>
+              <td><a href="https://www.apache.org/dist/commons/io/binaries/commons-io-2.11.0-bin.zip.sha512">sha512</a></td>
+              <td><a href="https://www.apache.org/dist/commons/io/binaries/commons-io-2.11.0-bin.zip.asc">pgp</a></td>
+          </tr>
+        </table>
+      </subsection>
+      <subsection name="Source">
+        <table>
+          <tr>
+              <td><a href="[preferred]/commons/io/source/commons-io-2.11.0-src.tar.gz">commons-io-2.11.0-src.tar.gz</a></td>
+              <td><a href="https://www.apache.org/dist/commons/io/source/commons-io-2.11.0-src.tar.gz.sha512">sha512</a></td>
+              <td><a href="https://www.apache.org/dist/commons/io/source/commons-io-2.11.0-src.tar.gz.asc">pgp</a></td>
+          </tr>
+          <tr>
+              <td><a href="[preferred]/commons/io/source/commons-io-2.11.0-src.zip">commons-io-2.11.0-src.zip</a></td>
+              <td><a href="https://www.apache.org/dist/commons/io/source/commons-io-2.11.0-src.zip.sha512">sha512</a></td>
+              <td><a href="https://www.apache.org/dist/commons/io/source/commons-io-2.11.0-src.zip.asc">pgp</a></td>
+          </tr>
+        </table>
+      </subsection>
+    </section>
+    <section name="Archives">
+        <p>
+          Older releases can be obtained from the archives.
+        </p>
+        <ul>
+          <li class="download"><a href="[preferred]/commons/io/">browse download area</a></li>
+          <li><a href="https://archive.apache.org/dist/commons/io/">archives...</a></li>
+        </ul>
+    </section>
+  </body>
+</document>
diff --git a/src/site/xdoc/index.xml b/src/site/xdoc/index.xml
new file mode 100644
index 0000000..3733b19
--- /dev/null
+++ b/src/site/xdoc/index.xml
@@ -0,0 +1,249 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Commons IO Overview</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+<body>
+<!-- ================================================== -->
+<section name="Apache Commons IO">
+<p>
+Apache Commons IO is a library of utilities to assist with developing IO functionality.
+</p>
+<p>
+There are six main areas included:
+</p>
+<ul>
+	<li>
+		<a
+			href="apidocs/index.html?org/apache/commons/io/package-summary.html">io</a>
+		- This package defines utility classes for working with streams, readers, writers and files.
+	</li>
+	<li>
+		<a
+			href="apidocs/index.html?org/apache/commons/io/comparator/package-summary.html">comparator</a>
+		- This package provides various Comparator implementations for Files.
+	</li>
+	<li>
+		<a
+			href="apidocs/index.html?org/apache/commons/io/file/package-summary.html">file</a>
+		- This package provides extensions in the realm of java.nio.file.
+	</li>
+	<li>
+		<a
+			href="apidocs/index.html?org/apache/commons/io/filefilter/package-summary.html">filefilter</a>
+		- This package defines an interface (IOFileFilter) that combines both FileFilter and FilenameFilter.
+	</li>
+	<li>
+		<a
+			href="apidocs/index.html?org/apache/commons/io/function/package-summary.html">function</a>
+		- This package defines IO-only related functional interfaces for lambda expressions and method references.
+	</li>
+	<li>
+		<a
+			href="apidocs/index.html?org/apache/commons/io/input/package-summary.html">input</a>
+		- This package provides implementations of input classes, such as InputStream and Reader.
+	</li>
+	<li>
+		<a
+			href="apidocs/index.html?org/apache/commons/io/input/buffer/package-summary.html">input.buffer</a>
+		- This package provides implementations of buffered input classes, such as CircularBufferInputStream and PeekableInputStream.
+	</li>
+	<li>
+		<a
+			href="apidocs/index.html?org/apache/commons/io/monitor/package-summary.html">monitor</a>
+		- This package provides a component for monitoring file system events (directory and file create, update and delete events).
+	</li>
+	<li>
+		<a
+			href="apidocs/index.html?org/apache/commons/io/output/package-summary.html">output</a>
+		- This package provides implementations of output classes, such as OutputStream and Writer.
+	</li>
+	<li>
+		<a
+			href="apidocs/index.html?org/apache/commons/io/serialization/package-summary.html">serialization</a>
+		- This package provides a framework for controlling the deserialization of classes.
+	</li>
+</ul>
+</section>
+<!-- ================================================== -->
+<section name="Releases">
+
+    <subsection name="Commons IO 2.12.0 (requires Java 8)">
+        <p>
+            Commons IO 2.12.0 requires a minimum of Java 8 -
+            <a href="https://commons.apache.org/io/download_io.cgi">Download now!</a>
+        </p>
+        <p>
+            View the
+            <a href="changes-report.html">Release Notes</a>
+            and
+            <a href="apidocs/index.html">Javadoc API documents</a>
+        </p>
+    </subsection>
+
+    <subsection name="Commons IO 2.11.0 (requires Java 8)">
+        <p>
+            Commons IO 2.11.0 requires a minimum of Java 8 -
+            <a href="https://commons.apache.org/io/download_io.cgi">Download now!</a>
+        </p>
+        <p>
+            View the
+            <a href="changes-report.html">Release Notes</a>
+            and
+            <a href="https://javadoc.io/doc/commons-io/commons-io/2.11.0/index.html">Javadoc API documents</a>
+        </p>
+    </subsection>
+
+    <subsection name="Commons IO 2.10.0 (requires Java 8)">
+        <p>
+            Commons IO 2.10.0 requires a minimum of Java 8 -
+            <a href="https://commons.apache.org/io/download_io.cgi">Download now!</a>
+        </p>
+        <p>
+            View the
+            <a href="changes-report.html">Release Notes</a>
+            and
+            <a href="https://javadoc.io/doc/commons-io/commons-io/2.10.0/index.html">Javadoc API documents</a>
+        </p>
+    </subsection>
+
+    <subsection name="Commons IO 2.9.0 (requires Java 8)">
+        <p>
+            Commons IO 2.9.0 requires a minimum of Java 8 -
+            <a href="https://commons.apache.org/io/download_io.cgi">Download now!</a>
+        </p>
+        <p>
+            View the
+            <a href="changes-report.html">Release Notes</a>
+            and
+            <a href="https://javadoc.io/doc/commons-io/commons-io/2.9.0/index.html">Javadoc API documents</a>
+        </p>
+    </subsection>
+
+    <subsection name="Commons IO 2.8.0 (requires Java 8)">
+        <p>
+            Commons IO 2.8.0 requires a minimum of Java 8 -
+            <a href="https://commons.apache.org/io/download_io.cgi">Download now!</a>
+        </p>
+        <p>
+            View the
+            <a href="changes-report.html">Release Notes</a>
+            and
+            <a href="https://javadoc.io/doc/commons-io/commons-io/2.8.0/index.html">Javadoc API documents</a>
+        </p>
+    </subsection>
+
+    <subsection name="Commons IO 2.7 (requires Java 8)">
+        <p>
+            Commons IO 2.7 requires a minimum of Java 8 -
+            <a href="https://commons.apache.org/io/download_io.cgi">Download now!</a>
+        </p>
+        <p>
+            View the
+            <a href="changes-report.html">Release Notes</a>
+            and
+            <a href="https://javadoc.io/doc/commons-io/commons-io/2.7/index.html">Javadoc API documents</a>
+        </p>
+    </subsection>
+
+    <subsection name="Commons IO 2.6 (requires Java 7)">
+        <p>
+            Commons IO 2.6 requires a minimum of Java 7 -
+            <a href="https://commons.apache.org/io/download_io.cgi">Download now!</a>
+        </p>
+        <p>
+            View the
+            <a href="upgradeto2_6.html">Release Notes</a>
+            and
+            <a href="https://javadoc.io/doc/commons-io/commons-io/2.6/index.html">Javadoc API documents</a>
+        </p>
+    </subsection>
+
+    <subsection name="Commons IO 2.5 (requires Java 6)">
+        <p>
+            Commons IO 2.5 requires a minimum of Java 6 -
+            <a href="https://commons.apache.org/io/download_io.cgi">Download now!</a>
+        </p>
+        <p>
+            View the
+            <a href="upgradeto2_5.html">Release Notes</a>
+            and
+            <a href="https://javadoc.io/doc/commons-io/commons-io/2.5/index.html">Javadoc API documents</a>
+        </p>
+    </subsection>
+
+    <subsection name="Commons IO 2.4 (requires Java 6)">
+<p>
+    Commons IO 2.4 requires a minimum of JDK 1.6 -
+<a href="https://commons.apache.org/io/download_io.cgi">Download now!</a>
+</p>
+<p>
+View the <a href="upgradeto2_4.html">Release Notes</a> and
+<a href="https://javadoc.io/doc/commons-io/commons-io/2.4/index.html">Javadoc API documents</a>
+</p>
+</subsection>
+
+    <subsection name="Commons IO 2.3 (requires Java 6)">
+<p>
+    Commons IO 2.3 requires a minimum of JDK 1.6 -
+<a href="https://commons.apache.org/io/download_io.cgi">Download now!</a>
+</p>
+<p>
+View the <a href="upgradeto2_3.html">Release Notes</a> and
+<a href="https://javadoc.io/doc/commons-io/commons-io/2.3/index.html">Javadoc API documents</a>
+</p>
+</subsection>
+
+<subsection name="Commons IO 2.2 (requires Java 5)">
+<p>
+Commons IO 2.2 requires a minimum of JDK 1.5 -
+<a href="https://commons.apache.org/io/download_io.cgi">Download now!</a>
+</p> 
+<p>
+View the <a href="upgradeto2_2.html">Release Notes</a> and
+<a href="https://javadoc.io/doc/commons-io/commons-io/2.2/index.html">Javadoc API documents</a>
+</p> 
+</subsection>
+
+<subsection name="Older Releases">
+<p>
+For previous releases, see the <a href="https://archive.apache.org/dist/commons/io/">Apache Archive</a>
+and <a href="https://javadoc.io/doc/commons-io/commons-io/">Javadoc Archive</a>
+</p> 
+</subsection>
+
+</section>
+<!-- ================================================== -->
+<section name="Support">
+<p>
+The <a href="mail-lists.html">commons mailing lists</a> act as the main support forum.
+The user list is suitable for most library usage queries.
+The dev list is intended for the development discussion.
+Please remember that the lists are shared between all commons components,
+so prefix your email by [io].
+</p>
+<p>
+Issues may be reported via <a href="issue-tracking.html">ASF JIRA</a>.
+Please read the instructions carefully to submit a useful bug report or enhancement request.
+</p>
+</section>
+<!-- ================================================== -->
+</body>
+</document>
diff --git a/src/site/xdoc/issue-tracking.xml b/src/site/xdoc/issue-tracking.xml
new file mode 100644
index 0000000..491b787
--- /dev/null
+++ b/src/site/xdoc/issue-tracking.xml
@@ -0,0 +1,102 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<!--
+ +======================================================================+
+ |****                                                              ****|
+ |****      THIS FILE IS GENERATED BY THE COMMONS BUILD PLUGIN      ****|
+ |****                    DO NOT EDIT DIRECTLY                      ****|
+ |****                                                              ****|
+ +======================================================================+
+ | TEMPLATE FILE: issue-tracking-template.xml                           |
+ | commons-build-plugin/trunk/src/main/resources/commons-xdoc-templates |
+ +======================================================================+
+ |                                                                      |
+ | 1) Re-generate using: mvn commons-build:jira-page                    |
+ |                                                                      |
+ | 2) Set the following properties in the component's pom:              |
+ |    - commons.jira.id  (required, alphabetic, upper case)             |
+ |    - commons.jira.pid (required, numeric)                            |
+ |                                                                      |
+ | 3) Example Properties                                                |
+ |                                                                      |
+ |  <properties>                                                        |
+ |    <commons.jira.id>MATH</commons.jira.id>                           |
+ |    <commons.jira.pid>12310485</commons.jira.pid>                     |
+ |  </properties>                                                       |
+ |                                                                      |
+ +======================================================================+
+-->
+<document>
+  <properties>
+    <title>Apache Commons IO Issue tracking</title>
+    <author email="dev@commons.apache.org">Apache Commons Documentation Team</author>
+  </properties>
+  <body>
+
+    <section name="Apache Commons IO Issue tracking">
+      <p>
+      Apache Commons IO uses <a href="https://issues.apache.org/jira/">ASF JIRA</a> for tracking issues.
+      See the <a href="https://issues.apache.org/jira/browse/IO">Apache Commons IO JIRA project page</a>.
+      </p>
+
+      <p>
+      To use JIRA you may need to <a href="https://issues.apache.org/jira/secure/Signup!default.jspa">create an account</a>
+      (if you have previously created/updated Commons issues using Bugzilla an account will have been automatically
+      created and you can use the <a href="https://issues.apache.org/jira/secure/ForgotPassword!default.jspa">Forgot Password</a>
+      page to get a new password).
+      </p>
+
+      <p>
+      If you would like to report a bug, or raise an enhancement request with
+      Apache Commons IO please do the following:
+      <ol>
+        <li><a href="https://issues.apache.org/jira/secure/IssueNavigator.jspa?reset=true&amp;pid=12310477&amp;sorter/field=issuekey&amp;sorter/order=DESC&amp;status=1&amp;status=3&amp;status=4">Search existing open bugs</a>.
+            If you find your issue listed then please add a comment with your details.</li>
+        <li><a href="mail-lists.html">Search the mailing list archive(s)</a>.
+            You may find your issue or idea has already been discussed.</li>
+        <li>Decide if your issue is a bug or an enhancement.</li>
+        <li>Submit either a <a href="https://issues.apache.org/jira/secure/CreateIssueDetails!init.jspa?pid=12310477&amp;issuetype=1&amp;priority=4&amp;assignee=-1">bug report</a>
+            or <a href="https://issues.apache.org/jira/secure/CreateIssueDetails!init.jspa?pid=12310477&amp;issuetype=4&amp;priority=4&amp;assignee=-1">enhancement request</a>.</li>
+      </ol>
+      </p>
+
+      <p>
+      Please also remember these points:
+      <ul>
+        <li>the more information you provide, the better we can help you</li>
+        <li>test cases are vital, particularly for any proposed enhancements</li>
+        <li>the developers of Apache Commons IO are all unpaid volunteers</li>
+      </ul>
+      </p>
+
+      <p>
+      For more information on subversion and creating patches see the
+      <a href="https://www.apache.org/dev/contributors.html">Apache Contributors Guide</a>.
+      </p>
+
+      <p>
+      You may also find these links useful:
+      <ul>
+        <li><a href="https://issues.apache.org/jira/secure/IssueNavigator.jspa?reset=true&amp;pid=12310477&amp;sorter/field=issuekey&amp;sorter/order=DESC&amp;status=1&amp;status=3&amp;status=4">All Open Apache Commons IO bugs</a></li>
+        <li><a href="https://issues.apache.org/jira/secure/IssueNavigator.jspa?reset=true&amp;pid=12310477&amp;sorter/field=issuekey&amp;sorter/order=DESC&amp;status=5&amp;status=6">All Resolved Apache Commons IO bugs</a></li>
+        <li><a href="https://issues.apache.org/jira/secure/IssueNavigator.jspa?reset=true&amp;pid=12310477&amp;sorter/field=issuekey&amp;sorter/order=DESC">All Apache Commons IO bugs</a></li>
+      </ul>
+      </p>
+    </section>
+  </body>
+</document>
diff --git a/src/site/xdoc/mail-lists.xml b/src/site/xdoc/mail-lists.xml
new file mode 100644
index 0000000..36189d5
--- /dev/null
+++ b/src/site/xdoc/mail-lists.xml
@@ -0,0 +1,215 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<!--
+ +======================================================================+
+ |****                                                              ****|
+ |****      THIS FILE IS GENERATED BY THE COMMONS BUILD PLUGIN      ****|
+ |****                    DO NOT EDIT DIRECTLY                      ****|
+ |****                                                              ****|
+ +======================================================================+
+ | TEMPLATE FILE: mail-lists-template.xml                               |
+ | commons-build-plugin/trunk/src/main/resources/commons-xdoc-templates |
+ +======================================================================+
+ |                                                                      |
+ | 1) Re-generate using: mvn commons-build:mail-page                    |
+ |                                                                      |
+ | 2) Set the following properties in the component's pom:              |
+ |    - commons.componentid (required, alphabetic, lower case)          |
+ |                                                                      |
+ | 3) Example Properties                                                |
+ |                                                                      |
+ |  <properties>                                                        |
+ |    <commons.componentid>math</commons.componentid>                   |
+ |  </properties>                                                       |
+ |                                                                      |
+ +======================================================================+
+-->
+<document>
+  <properties>
+    <title>Apache Commons IO Mailing Lists</title>
+    <author email="dev@commons.apache.org">Apache Commons Documentation Team</author>
+  </properties>
+  <body>
+
+    <section name="Overview">
+      <p>
+        <a href="index.html">Apache Commons IO</a> shares mailing lists with all the other
+        <a href="https://commons.apache.org/components.html">Commons Components</a>.
+        To make it easier for people to only read messages related to components they are interested in,
+        the convention in Commons is to prefix the subject line of messages with the component's name,
+        for example:
+        <ul>
+          <li>[io] Problem with the ...</li>
+        </ul>
+      </p>
+      <p>
+        Questions related to the usage of Apache Commons IO should be posted to the
+        <a href="https://mail-archives.apache.org/mod_mbox/commons-user/">User List</a>.
+        <br />
+        The <a href="https://mail-archives.apache.org/mod_mbox/commons-dev/">Developer List</a>
+        is for questions and discussion related to the development of Apache Commons IO.
+        <br />
+        Please do not cross-post; developers are also subscribed to the user list.
+        <br />
+        You must be subscribed to post to the mailing lists.  Follow the Subscribe links below
+        to subscribe.
+      </p>
+      <p>
+        <strong>Note:</strong> please don't send patches or attachments to any of the mailing lists.
+        Patches are best handled via the <a href="issue-tracking.html">Issue Tracking</a> system.
+        Otherwise, please upload the file to a public server and include the URL in the mail.
+      </p>
+    </section>
+
+    <section name="Apache Commons IO Mailing Lists">
+      <p>
+        <strong>Please prefix the subject line of any messages for <a href="index.html">Apache Commons IO</a>
+        with <i>[io]</i></strong> - <i>thanks!</i>
+        <br />
+        <br />
+      </p>
+
+      <table>
+        <tr>
+          <th>Name</th>
+          <th>Subscribe</th>
+          <th>Unsubscribe</th>
+          <th>Post</th>
+          <th>Archive</th>
+          <th>Other Archives</th>
+        </tr>
+
+
+        <tr>
+          <td>
+            <strong>Commons User List</strong>
+            <br /><br />
+            Questions on using Apache Commons IO.
+            <br /><br />
+          </td>
+          <td><a href="mailto:user-subscribe@commons.apache.org">Subscribe</a></td>
+          <td><a href="mailto:user-unsubscribe@commons.apache.org">Unsubscribe</a></td>
+          <td><a href="mailto:user@commons.apache.org?subject=[io]">Post</a></td>
+          <td><a href="https://mail-archives.apache.org/mod_mbox/commons-user/">mail-archives.apache.org</a><br />
+              <a href="https://lists.apache.org/list.html?user@commons.apache.org">lists.apache.org</a>
+          </td>
+          <td><a href="https://markmail.org/list/org.apache.commons.users/">markmail.org</a><br />
+              <a href="https://www.mail-archive.com/user@commons.apache.org/">www.mail-archive.com</a><br />
+              <a href="https://news.gmane.org/gmane.comp.jakarta.commons.devel">news.gmane.org</a>
+          </td>
+        </tr>
+
+
+        <tr>
+          <td>
+            <strong>Commons Developer List</strong>
+            <br /><br />
+            Discussion of development of Apache Commons IO.
+            <br /><br />
+          </td>
+          <td><a href="mailto:dev-subscribe@commons.apache.org">Subscribe</a></td>
+          <td><a href="mailto:dev-unsubscribe@commons.apache.org">Unsubscribe</a></td>
+          <td><a href="mailto:dev@commons.apache.org?subject=[io]">Post</a></td>
+          <td><a href="https://mail-archives.apache.org/mod_mbox/commons-dev/">mail-archives.apache.org</a><br />
+              <a href="https://lists.apache.org/list.html?dev@commons.apache.org">lists.apache.org</a>
+          </td>
+          <td><a href="https://markmail.org/list/org.apache.commons.dev/">markmail.org</a><br />
+              <a href="https://www.mail-archive.com/dev@commons.apache.org/">www.mail-archive.com</a><br />
+              <a href="https://news.gmane.org/gmane.comp.jakarta.commons.devel">news.gmane.org</a>
+          </td>
+        </tr>
+
+
+        <tr>
+          <td>
+            <strong>Commons Issues List</strong>
+            <br /><br />
+            Only for e-mails automatically generated by the <a href="issue-tracking.html">issue tracking</a> system.
+            <br /><br />
+          </td>
+          <td><a href="mailto:issues-subscribe@commons.apache.org">Subscribe</a></td>
+          <td><a href="mailto:issues-unsubscribe@commons.apache.org">Unsubscribe</a></td>
+          <td><i>read only</i></td>
+          <td><a href="https://mail-archives.apache.org/mod_mbox/commons-issues/">mail-archives.apache.org</a><br />
+              <a href="https://lists.apache.org/list.html?issues@commons.apache.org">lists.apache.org</a>
+          </td>
+          <td><a href="https://markmail.org/list/org.apache.commons.issues/">markmail.org</a><br />
+              <a href="https://www.mail-archive.com/issues@commons.apache.org/">www.mail-archive.com</a>
+          </td>
+        </tr>
+
+
+        <tr>
+          <td>
+            <strong>Commons Commits List</strong>
+            <br /><br />
+            Only for e-mails automatically generated by the <a href="scm.html">source control</a> system.
+            <br /><br />
+          </td>
+          <td><a href="mailto:commits-subscribe@commons.apache.org">Subscribe</a></td>
+          <td><a href="mailto:commits-unsubscribe@commons.apache.org">Unsubscribe</a></td>
+          <td><i>read only</i></td>
+          <td><a href="https://mail-archives.apache.org/mod_mbox/commons-commits/">mail-archives.apache.org</a><br />
+              <a href="https://lists.apache.org/list.html?commits@commons.apache.org">lists.apache.org</a>
+          </td>
+          <td><a href="https://markmail.org/list/org.apache.commons.commits/">markmail.org</a><br />
+              <a href="https://www.mail-archive.com/commits@commons.apache.org/">www.mail-archive.com</a>
+          </td>
+        </tr>
+
+      </table>
+
+    </section>
+    <section name="Apache Mailing Lists">
+      <p>
+        Other mailing lists which you may find useful include:
+      </p>
+
+      <table>
+        <tr>
+          <th>Name</th>
+          <th>Subscribe</th>
+          <th>Unsubscribe</th>
+          <th>Post</th>
+          <th>Archive</th>
+          <th>Other Archives</th>
+        </tr>
+        <tr>
+          <td>
+            <strong>Apache Announce List</strong>
+            <br /><br />
+            General announcements of Apache project releases.
+            <br /><br />
+          </td>
+          <td><a class="externalLink" href="mailto:announce-subscribe@apache.org">Subscribe</a></td>
+          <td><a class="externalLink" href="mailto:announce-unsubscribe@apache.org">Unsubscribe</a></td>
+          <td><i>read only</i></td>
+          <td><a class="externalLink" href="https://mail-archives.apache.org/mod_mbox/www-announce/">mail-archives.apache.org</a><br />
+              <a class="externalLink" href="https://lists.apache.org/list.html?announce@apache.org">lists.apache.org</a>
+          </td>
+          <td><a class="externalLink" href="https://markmail.org/list/org.apache.announce/">markmail.org</a><br />
+              <a class="externalLink" href="https://old.nabble.com/Apache-News-and-Announce-f109.html">old.nabble.com</a><br />
+              <a class="externalLink" href="https://www.mail-archive.com/announce@apache.org/">www.mail-archive.com</a><br />
+              <a class="externalLink" href="https://news.gmane.org/gmane.comp.apache.announce">news.gmane.org</a>
+          </td>
+        </tr>
+      </table>
+
+    </section>
+  </body>
+</document>
diff --git a/src/site/xdoc/proposal.xml b/src/site/xdoc/proposal.xml
new file mode 100644
index 0000000..0856234
--- /dev/null
+++ b/src/site/xdoc/proposal.xml
@@ -0,0 +1,87 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Proposal</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+ <body>
+  
+ 
+<section name="Proposal for IO Package">
+ 
+  
+
+<subsection name="(0) Rationale">
+  
+<p>Many software projects have a need to perform I/O in various ways, and
+the JDK class libraries provide a lot of functionality, but sometimes you
+need just a little bit more.  The io package seeks to  encapsulate some of
+the most popular i/o base classes into one easy to  use package.</p>
+   
+</subsection>
+<subsection name="(1) Scope of the Package">
+  
+<p>This proposal is to create a package of Java utility classes for  various
+types of i/o related activity.</p>
+   
+</subsection>
+<subsection name="(1.5) Interaction With Other Packages">
+  
+<p><em>IO</em> relies only on standard JDK 1.2 (or later) APIs for production
+deployment.  It utilizes the JUnit unit testing framework for developing
+and executing unit tests, but this is of interest only to developers of the
+component.  IO will be a dependency for several existing components in the
+open source world.</p>
+  
+<p>No external configuration files are utilized.</p>
+   
+</subsection>
+<subsection name="(2) Initial Source of the Package">
+  
+<p>The original Java classes are splashed around various Apache  subprojects.
+ We intend to seek them out and integrate them.</p>
+  
+<p>The proposed package name for the new component is <code>org.apache.commons.io</code>.</p>
+   
+</subsection>
+<subsection name="(3)  Required Jakarta-Commons Resources">
+  
+<ul>
+ <li>CVS Repository - New directory <code>io</code> in the     <code>jakarta-commons</code>
+CVS repository.</li>
+ <li>Mailing List - Discussions will take place on the general     <em>dev@commons.apache.org</em>
+mailing list.  To help     list subscribers identify messages of interest,
+it is suggested that     the message subject of messages about this component
+be prefixed with     [IO].</li>
+ <li>Bugzilla - New component "IO" under the "Commons" product     category,
+with appropriate version identifiers as needed.</li>
+ <li>Jyve FAQ - New category "commons-io" (when available).</li>
+ 
+</ul>
+   
+</subsection>
+<subsection name="(4) Initial Committers">
+  
+<p>The initial committers on the IO component shall be Scott Sanders and
+Nicola Ken Barozzi and Henri Yandell</p>
+    
+</subsection>
+</section>
+</body>
+</document>
diff --git a/src/site/xdoc/tasks.xml b/src/site/xdoc/tasks.xml
new file mode 100644
index 0000000..76919fd
--- /dev/null
+++ b/src/site/xdoc/tasks.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Tasks</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+  <body>
+    <section name="Tasks and Ideas for the future">
+      <p>
+        The following are some of the proposed ideas and tasks for commons-io:
+      </p>
+      <ul>
+        <li>A proper user guide</li>
+        <li>A URLUtils class that has many of the FileUtils operations, but for a URL</li>
+        <li>FilePoller for telling when a file changes. Look in Tomcat, or GenJava[bayard] (One implemented in bugzilla awaiting investigation)</li>
+        <li>A "hot folder" handler which triggers an action when a new file has been uploaded to an FTP directory, for example.</li>
+        <li>JoinReader/ConcatReader. One in GenJava, one submitted to Bayard</li>
+        <li>FormattedWriter, when it writes out values it uses Format objects to output them. </li>
+        <li>FixedWidthReader. Reads in files with a known width, ie) mainframe like. </li>
+      </ul>
+    </section>
+  </body>
+</document>
diff --git a/src/site/xdoc/upgradeto1_1.xml b/src/site/xdoc/upgradeto1_1.xml
new file mode 100644
index 0000000..5920adf
--- /dev/null
+++ b/src/site/xdoc/upgradeto1_1.xml
@@ -0,0 +1,207 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Upgrade from 1.0 to 1.1</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+<body>
+
+<section name="Upgrade">
+<p>
+These are the release notes and advice for upgrading Commons-IO from
+version 1.0 to version 1.1.
+<source>
+Commons IO is a package of Java utility classes for java.io's hierarchy.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+Commons IO contains utility classes, stream implementations, file filters, 
+and endian transformation classes.
+
+
+Incompatible changes from 1.0
+-----------------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes, except:
+- FileUtils.writeStringToFile()
+    A null encoding previously used 'ISO-8859-1', now it uses the platform default
+    Generally this will make no difference
+
+- LockableFileWriter
+    Improved validation and now create directories if necessary
+
+plus these bug fixes may affect you semantically:
+- FileUtils.touch()  (Bug fix 29821)
+    Now creates the file if it did not previously exist
+
+- FileUtils.toFile(URL) (Bug fix 32575)
+    Now handles escape syntax such as %20
+
+- FileUtils.sizeOfDirectory()  (Bug fix 36801)
+    May now return a size of 0 if the directory is security restricted
+
+
+Deprecations from 1.0
+---------------------
+- CopyUtils has been deprecated.
+    Its methods have been moved to IOUtils.
+    The new IOUtils methods handle nulls better, and have clearer names.
+
+- IOUtils.toByteArray(String) - Use {@link String#getBytes()}
+- IOUtils.toString(byte[]) - Use {@link String#String(byte[])}
+- IOUtils.toString(byte[],String) - Use {@link String#String(byte[],String)}
+
+
+Bug fixes from 1.0
+------------------
+- FileUtils - touch()  [29821]
+    Now creates the file if it did not previously exist
+
+- FileUtils - toFile(URL)  [32575]
+    Now handles escape syntax such as %20
+
+- FileFilterUtils - makeCVSAware(IOFileFilter)  [33023]
+    Fixed bug that caused method to be completely broken
+
+- CountingInputStream  [33336]
+    Fixed bug that caused the count to reduce by one at the end of the stream
+
+- CountingInputStream - skip(long)  [34311]
+    Bytes from calls to this method were not previously counted
+
+- NullOutputStream  [33481]
+    Remove unnecessary synchronization
+
+- AbstractFileFilter - accept(File, String)  [30992]
+    Fixed broken implementation
+
+- FileUtils  [36801]
+    Previously threw NPE when listing files in a security restricted directory
+    Now throw IOException with a better message
+
+- FileUtils - writeStringToFile()
+    Null encoding now correctly uses the platform default
+
+
+Enhancements from 1.0
+---------------------
+- FilenameUtils - new class  [33303,29351]
+    A static utility class for working with filenames
+    Seeks to ease the pain of developing on Windows and deploying on Unix
+
+- FileSystemUtils - new class  [32982,36325]
+    A static utility class for working with file systems
+    Provides one method at present, to get the free space on the filing system
+
+- IOUtils - new public constants
+    Constants for directory and line separators on Windows and Unix
+
+- IOUtils - toByteArray(Reader,encoding)
+    Handles encodings when reading to a byte array
+
+- IOUtils - toCharArray(InputStream)  [28979]
+          - toCharArray(InputStream,encoding)
+          - toCharArray(Reader)
+    Reads a stream/reader into a character array
+
+- IOUtils - readLines(InputStream)  [36214]
+          - readLines(InputStream,encoding)
+          - readLines(Reader)
+    Reads a stream/reader line by line into a List of Strings
+
+- IOUtils - toInputStream(String)  [32958]
+          - toInputStream(String,encoding)
+    Creates an input stream that uses the string as a source of data
+
+- IOUtils - writeLines(Collection,lineEnding,OutputStream)  [36214]
+          - writeLines(Collection,lineEnding,OutputStream,encoding)
+          - writeLines(Collection,lineEnding,Writer)
+    Writes a collection to a stream/writer line by line
+
+- IOUtils - write(...)
+    Write data to a stream/writer (moved from CopyUtils with better null handling)
+
+- IOUtils - copy(...)
+    Copy data between streams (moved from CopyUtils with better null handling)
+
+- IOUtils - contentEquals(Reader,Reader)
+    Method to compare the contents of two readers
+
+- FileUtils - toFiles(URL[])
+    Converts an array of URLs to an array of Files
+
+- FileUtils - copyDirectory()  [32944]
+    New methods to copy a directory
+
+- FileUtils - readFileToByteArray(File)
+    Reads an entire file into a byte array
+
+- FileUtils - writeByteArrayToFile(File,byte[])
+    Writes a byte array to a file
+
+- FileUtils - readLines(File,encoding)  [36214]
+    Reads a file line by line into a List of Strings
+
+- FileUtils - writeLines(File,encoding,List)
+              writeLines(File,encoding,List,lineEnding)
+    Writes a collection to a file line by line
+
+- FileUtils - EMPTY_FILE_ARRAY
+    Constant for an empty array of File objects
+
+- ConditionalFileFilter - new interface  [30705]
+    Defines the behavior of list based filters
+
+- AndFileFilter, OrFileFilter  [30705]
+    Now support a list of filters to and/or
+
+- WildcardFilter  [31115]
+    New filter that can match using wildcard file names
+
+- FileFilterUtils - makeSVNAware(IOFileFilter)
+    New method, like makeCVSAware, that ignores Subversion source control directories
+
+- ClassLoaderObjectInputStream
+    An ObjectInputStream that supports a ClassLoader
+
+- CountingInputStream,CountingOutputStream - resetCount()  [28976]
+    Adds the ability to reset the count part way through reading/writing the stream
+
+- DeferredFileOutputStream - writeTo(OutputStream)  [34173]
+    New method to allow current contents to be written to a stream
+
+- DeferredFileOutputStream  [34142]
+    Performance optimizations avoiding double buffering
+
+- LockableFileWriter - encoding support [36825]
+    Add support for character encodings to LockableFileWriter
+    Improve the validation
+    Create directories if necessary
+
+- IOUtils and EndianUtils are no longer final  [28978]
+    Allows developers to have subclasses if desired
+</source>
+</p>
+</section>
+
+</body>
+</document>
diff --git a/src/site/xdoc/upgradeto1_2.xml b/src/site/xdoc/upgradeto1_2.xml
new file mode 100644
index 0000000..a38e89d
--- /dev/null
+++ b/src/site/xdoc/upgradeto1_2.xml
@@ -0,0 +1,90 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Upgrade from 1.1 to 1.2</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+<body>
+
+<section name="Upgrade">
+<p>
+These are the release notes and advice for upgrading Commons-IO from
+version 1.1 to version 1.2.
+<source>
+Commons IO is a package of Java utility classes for java.io's hierarchy.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+Commons IO contains utility classes, stream implementations, file filters, 
+and endian transformation classes.
+
+
+Compatibility with 1.1
+----------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+
+
+Deprecations from 1.1
+---------------------
+
+
+Bug fixes from 1.1
+------------------
+- FileSystemUtils.freeSpace(drive)
+  Fix to allow Windows based command to function in French locale
+
+- FileUtils.read*
+  Increase certainty that files are closed in case of error
+
+- LockableFileWriter
+  Locking mechanism was broken and only provided limited protection [38942]
+  File deletion and locking in case of constructor error was broken
+
+
+Enhancements from 1.1
+---------------------
+- AgeFileFilter/SizeFileFilter
+  New file filters that compare against the age and size of the file
+
+- FileSystemUtils.freeSpaceKb(drive)
+  New method that unifies result to be in kilobytes [38574]
+
+- FileUtils.contentEquals(File,File)
+  Performance improved by adding length and file location checking
+
+- FileUtils.iterateFiles
+  Two new method to provide direct access to iterators over files
+
+- FileUtils.lineIterator
+  IOUtils.lineIterator
+  New methods to provide an iterator over the lines in a file [38083]
+
+- FileUtils.copyDirectoryToDirectory
+  New method to copy a directory to within another directory [36315]
+
+</source>
+</p>
+</section>
+
+</body>
+</document>
diff --git a/src/site/xdoc/upgradeto1_3.xml b/src/site/xdoc/upgradeto1_3.xml
new file mode 100644
index 0000000..91a1808
--- /dev/null
+++ b/src/site/xdoc/upgradeto1_3.xml
@@ -0,0 +1,228 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Upgrade from 1.2 to 1.3</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+<body>
+
+<section name="Upgrade">
+<p>
+These are the release notes and advice for upgrading Commons-IO from
+version 1.2 to version 1.3.
+<source>
+Commons IO is a package of Java utility classes for java.io's hierarchy.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+Commons IO contains utility classes, stream implementations, file filters, 
+and endian transformation classes.
+
+
+Compatibility with 1.2
+----------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+  Check the bug fixes section for semantic bug fixes
+
+
+Deprecations from 1.2
+---------------------
+- WildcardFilter deprecated, replaced by WildcardFileFilter
+  - old class only accepted files, thus had a confusing dual purpose
+
+- FileSystemUtils.freeSpace deprecated, replaced by freeSpaceKb
+  - freeSpace returns a result that varies by operating system and
+    thus isn't that useful
+  - freeSpaceKb returns much better and more consistent results
+  - freeSpaceKb existed in v1.2, so this is a gentle cutover
+
+
+Bug fixes from 1.2
+------------------
+- LineIterator now implements Iterator
+  - It was always supposed to...
+
+- FileSystemUtils.freeSpace/freeSpaceKb [IO-83]
+  - These should now work on AIX and HP-UX
+
+- FileSystemUtils.freeSpace/freeSpaceKb [IO-90]
+  - Avoid infinite looping in Windows
+  - Catch more errors with nice messages
+
+- FileSystemUtils.freeSpace [IO-91]
+  - This is now documented not to work on SunOS 5
+
+- FileSystemUtils [IO-93]
+  - Fixed resource leak leading to 'Too many open files' error
+  - Previously did not destroy Process instances (as JDK Javadoc is so poor)
+  - http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4801027
+
+- FileUtils.touch [IO-100]
+  - The touch method previously gave no indication when the file could not
+    be touched successfully (such as due to access restrictions) - it now
+    throws an IOException if the last modified date cannot be changed
+
+- FileCleaner
+  - This now handles the situation where an error occurs when deleting the file
+
+- IOUtils.copy [IO-84]
+  - Copy methods could return inaccurate byte/char count for large streams
+  - The copy(InputStream, OutputStream) method now returns -1 if the count is greater than an int
+  - The copy(Reader, Writer) method now returns -1 if the count is greater than an int
+  - Added a new copyLarge(InputStream, OutputStream) method that returns a long
+  - Added a new copyLarge(Reader, Writer) method that returns a long
+
+- CountingInputStream/CountingOutputStream [IO-84]
+  - Methods were declared as int thus the count was inaccurate for large streams
+  - new long based methods getByteCount()/resetByteCount() added
+  - existing methods changed to throw an exception if the count is greater than an int
+
+- FileBasedTestCase
+  - Fixed bug in compare content methods identified by GNU classpath
+
+- EndianUtils.writeSwappedLong(byte[], int) [IO-101]
+  - An int overrun in the bit shifting when it should have been a long
+
+- EndianUtils.writeSwappedLong(InputStream) [IO-102]
+  - The return of input.read(byte[]) was not being checked to ensure all 8 bytes were read
+
+Enhancements from 1.2
+---------------------
+- DirectoryWalker [IO-86]
+  - New class designed for subclassing to walk through a set of files.
+    DirectoryWalker provides the walk of the directories, filtering of
+    directories and files, and cancellation support. The subclass must provide
+    the specific behavior, such as text searching or image processing.
+
+- IOCase
+  - New class/enumeration for case-sensitivity control
+
+- FilenameUtils
+  - New methods to handle case-sensitivity
+  - wildcardMatch - new method that has IOCase as a parameter
+  - equals - new method that has IOCase as a parameter
+
+- FileUtils [IO-108] - new default encoding methods for:
+  - readFileToString(File)
+  - readLines(File)
+  - lineIterator(File)
+  - writeStringToFile(File, String)
+  - writeLines(File, Collection)
+  - writeLines(File, Collection, String)
+
+- FileUtils.openOutputStream  [IO-107]
+  - new method to open a FileOutputStream, creating parent directories if required
+- FileUtils.touch
+- FileUtils.copyURLToFile
+- FileUtils.writeStringToFile
+- FileUtils.writeByteArrayToFile
+- FileUtils.writeLines
+  - enhanced to create parent directories if required
+- FileUtils.openInputStream  [IO-107]
+  - new method to open a FileInputStream, providing better error messages than the JDK
+
+- FileUtils.isFileOlder
+  - new methods to check if a file is older (i.e. isFileOlder()) - counterparts
+    to the existing isFileNewer() methods.
+
+- FileUtils.checksum, FileUtils.checksumCRC32
+  - new methods to create a checksum of a file
+
+- FileUtils.copyFileToDirectory  [IO-104]
+  - new variant that optionally retains the file date
+
+- FileDeleteStrategy
+- FileCleaner    [IO-56,IO-70]
+  - FileDeleteStrategy is a strategy for handling file deletion
+  - This can be used as a callback in FileCleaner
+  - Together these allow FileCleaner to do a forceDelete to kill directories
+
+- FileCleaner.exitWhenFinished [IO-99]
+  - A new method that allows the internal cleaner thread to be cleanly terminated
+
+- WildcardFileFilter
+  - Replacement for WildcardFilter
+  - Accepts both files and directories
+  - Ability to control case-sensitivity
+
+- NameFileFilter
+  - Ability to control case-sensitivity
+
+- FileFileFilter
+  - New IOFileFilter implementation
+  - Accepts files where File.isFile() is true
+  - In other words it filters out directories
+  - Singleton instance provided (FILE)
+
+- CanReadFileFilter
+  - New IOFileFilter implementation
+  - Accepts files where File.canRead() is true
+  - Singleton instances provided (CAN_READ/CANNOT_READ/READ_ONLY)
+
+- CanWriteFileFilter
+  - New IOFileFilter implementation
+  - Accepts files where File.canWrite() is true
+  - Singleton instances provided (CAN_WRITE/CANNOT_WRITE)
+
+- HiddenFileFilter
+  - New IOFileFilter implementation
+  - Accepts files where File.isHidden() is true
+  - Singleton instances provided (HIDDEN/VISIBLE)
+
+- EmptyFileFilter
+  - New IOFileFilter implementation
+  - Accepts files or directories that are empty
+  - Singleton instances provided (EMPTY/NOT_EMPTY)
+
+- TrueFileFilter/FalseFileFilter/DirectoryFileFilter
+  - New singleton instance constants (TRUE/FALSE/DIRECTORY)
+  - The new constants are more JDK 1.5 friendly with regards to static imports
+    (whereas if everything uses INSTANCE, then they just clash)
+  - The old INSTANCE constants are still present and have not been deprecated
+
+- FileFilterUtils.sizeRangeFileFilter
+  - new sizeRangeFileFilter(long minimumSize, long maximumSize) method which 
+    creates a filter that accepts files within the specified size range.
+
+- FileFilterUtils.makeDirectoryOnly/makeFileOnly
+  - two new methods that decorate a file filter to make it apply to
+    directories only or files only
+
+- NullWriter
+  - New writer that acts as a sink for all data, as per /dev/null
+
+- NullInputStream
+  - New input stream that emulates a stream of a specified size
+
+- NullReader
+  - New reader that emulates a reader of a specified size
+
+- ByteArrayOutputStream  [IO-97]
+  - Performance enhancements
+
+</source>
+</p>
+</section>
+
+</body>
+</document>
diff --git a/src/site/xdoc/upgradeto1_3_1.xml b/src/site/xdoc/upgradeto1_3_1.xml
new file mode 100644
index 0000000..baec5c1
--- /dev/null
+++ b/src/site/xdoc/upgradeto1_3_1.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Upgrade from 1.3 to 1.3.1</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+<body>
+
+<section name="Upgrade">
+<p>
+These are the release notes and advice for upgrading Commons-IO from
+version 1.3 to version 1.3.1.
+<source>
+Commons IO is a package of Java utility classes for java.io's hierarchy.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+Commons IO contains utility classes, stream implementations, file filters, 
+and endian transformation classes.
+
+
+Compatibility with 1.3
+----------------------
+Binary compatible - No
+  See [IO-113]
+
+Source compatible - No
+  See [IO-113]
+
+Semantic compatible - Yes
+
+
+Bug fixes from 1.3
+------------------
+
+- FileUtils
+  - NPE in openOutputStream(File) when file has no parent in path [IO-112]
+  - readFileToString(File) is not static [IO-113]
+</source>
+</p>
+</section>
+
+</body>
+</document>
diff --git a/src/site/xdoc/upgradeto1_3_2.xml b/src/site/xdoc/upgradeto1_3_2.xml
new file mode 100644
index 0000000..4baebea
--- /dev/null
+++ b/src/site/xdoc/upgradeto1_3_2.xml
@@ -0,0 +1,87 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Upgrade from 1.3, or 1.3.1, to 1.3.2</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+<body>
+
+<section name="Upgrade">
+<p>
+These are the release notes and advice for upgrading Commons-IO from
+version 1.3, or 1.3.1, to version 1.3.2.
+<source>
+Commons IO is a package of Java utility classes for java.io's hierarchy.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+Commons IO contains utility classes, stream implementations, file filters, 
+and endian transformation classes.
+
+
+Compatibility with 1.3.1
+------------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+  Check the bug fixes section for semantic bug fixes
+
+
+Compatibility with 1.3
+----------------------
+Binary compatible - No
+  See [IO-113]
+
+Source compatible - No
+  See [IO-113]
+
+Semantic compatible - Yes
+
+
+Enhancements since 1.3.1
+------------------------
+
+- Created the FileCleaningTracker, basically a non-static version of the
+  FileCleaner, which can be controlled by the user. [IO-116]
+- The FileCleaner is deprecated. (For reasons of compatibility, the
+  deprecation warnings are hidden within the 1.3 branch. They'll be
+  visible, as of version 1.4.)
+
+
+Bug fixes from 1.3.1
+--------------------
+
+- Some tests, which are implicitly assuming a Unix-like file system, are
+  now skipped on Windows. [IO-115]
+
+
+Bug fixes from 1.3
+------------------
+
+- FileUtils
+  - NPE in openOutputStream(File) when file has no parent in path [IO-112]
+  - readFileToString(File) is not static [IO-113]
+</source>
+</p>
+</section>
+
+</body>
+</document>
diff --git a/src/site/xdoc/upgradeto1_4.xml b/src/site/xdoc/upgradeto1_4.xml
new file mode 100644
index 0000000..de0576f
--- /dev/null
+++ b/src/site/xdoc/upgradeto1_4.xml
@@ -0,0 +1,142 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Upgrade from 1.3.2 to 1.4</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+<body>
+
+<section name="Upgrade">
+<p>
+These are the release notes and advice for upgrading Commons-IO from
+version 1.3.2 to version 1.4.
+<source>
+Commons IO is a package of Java utility classes for java.io's hierarchy.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+Commons IO contains utility classes, stream implementations, file filters, 
+file comparators and endian transformation classes.
+
+
+Compatibility with 1.3.2
+------------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+  Check the bug fixes section for semantic bug fixes
+
+Commons IO 1.4 introduces four new implementations which depend on JDK 1.4 features
+(CharSequenceReader, FileWriterWithEncoding, IOExceptionWithCause and RegexFileFilter).
+It has been built with the JDK source and target options set to JDK 1.3 and, except for
+those implementations, can be used with JDK 1.3 (see IO-127).
+
+
+Deprecations from 1.3.2
+-----------------------
+- FileCleaner deprecated in favour of FileCleaningTracker [see IO-116]
+
+
+Bug fixes from 1.3.2
+--------------------
+- FileUtils
+  - forceDelete of orphaned Softlinks does not work [IO-147]
+  - Infinite loop on FileUtils.copyDirectory when the destination directory is within
+    the source directory [IO-141]
+
+- HexDump
+  - HexDump's use of static StringBuffers isn't thread-safe [IO-136]
+
+
+Enhancements from 1.3.2
+-----------------------
+- FileUtils
+  - Add a deleteQuietly method [IO-135]
+  - Add a copyDirectory() method that makes use of FileFilter [IO-105]
+  - Add moveDirectory() and moveFile() methods [IO-77]
+
+- FilenameUtils
+  - Add file name extension separator constants[IO-149]
+
+- IOExceptionWithCause [IO-148]
+  - Add a new IOException implementation with constructors which take a cause
+
+- TeeInputStream [IO-129]
+  - Add new Tee input stream implementation
+
+- FileWriterWithEncoding [IO-153]
+  - Add new File Writer implementation that accepts an encoding
+
+- CharSequenceReader [IO-138]
+  - Add new Reader implementation that handles any CharSequence (String,
+    StringBuffer, StringBuilder or CharBuffer) 
+
+- ThresholdingOutputStream [IO-121]
+  - Add a reset() method which sets the count of the bytes written back to zero.
+
+- DeferredFileOutputStream [IO-130]
+  - Add support for temporary files
+
+- ByteArrayOutputStream
+  - Add a new write(InputStream) method [IO-152]
+
+- New Closed Input/Output stream implementations [IO-122]
+  - AutoCloseInputStream - automatically closes and discards the underlying input stream
+  - ClosedInputStream - returns -1 for any read attempts
+  - ClosedOutputStream - throws an IOException for any write attempts
+  - CloseShieldInputStream - prevents the underlying input stream from being closed.
+  - CloseShieldOutputStream - prevents the underlying output stream from being closed.
+
+- Add Singleton Constants to several stream classes [IO-143]
+
+- PrefixFileFilter [IO-126]
+  - Add facility to specify case sensitivity on prefix matching
+
+- SuffixFileFilter [IO-126]
+  - Add facility to specify case sensitivity on suffix matching
+
+- RegexFileFilter [IO-74]
+  - Add new regular expression file filter implementation
+
+- Make IOFileFilter implementations Serializable [IO-131]
+
+- Improve IOFileFilter toString() methods [IO-120]
+
+- Make fields final so classes are immutable/threadsafe [IO-133]
+  - changes to Age, Delegate, Name, Not, Prefix, Regex, Size, Suffix and Wildcard IOFileFilter
+    implementations.
+
+- IOCase
+  - Add a compare method to IOCase [IO-144]
+
+- Add a package of java.util.Comparator implementations for files [IO-145]
+  - DefaultFileComparator - compare files using the default File.compareTo(File) method.
+  - ExtensionFileComparator - compares files using file name extensions.
+  - LastModifiedFileComparator - compares files using the last modified date/time.
+  - NameFileComparator - compares files using file names.
+  - PathFileComparator - compares files using file paths.
+  - SizeFileComparator - compares files using file sizes.
+</source>
+</p>
+</section>
+
+</body>
+</document>
diff --git a/src/site/xdoc/upgradeto2_0.xml b/src/site/xdoc/upgradeto2_0.xml
new file mode 100644
index 0000000..78769bf
--- /dev/null
+++ b/src/site/xdoc/upgradeto2_0.xml
@@ -0,0 +1,157 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Upgrade from 1.4 to 2.0</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+<body>
+
+<section name="Upgrade">
+<p>
+These are the release notes and advice for upgrading Commons-IO from
+version 1.4 to version 2.0.
+<source>
+Commons IO is a package of Java utility classes for java.io's hierarchy.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+Commons IO contains utility classes, stream implementations, file filters, 
+file comparators and endian transformation classes.
+
+
+Compatibility with 1.4
+----------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+  Check the bug fixes section for semantic bug fixes
+
+Commons IO 2.0 requires a minimum of JDK 1.5
+ (Commons IO 1.4 had a minimum of JDK 1.3) 
+
+
+Deprecations from 1.4
+---------------------
+
+- IOUtils
+  - write(StringBuffer, Writer) in favour of write(CharSequence, Writer)
+  - write(StringBuffer, OutputStream)  in favour of write(CharSequence, OutputStream)
+  - write(StringBuffer, OutputStream, String) in favour of write(CharSequence, OutputStream, String)
+
+- FileFilterUtils
+  - andFileFilter(IOFileFilter, IOFileFilter) in favour of and(IOFileFilter...) 
+  - orFileFilter(IOFileFilter, IOFileFilter)  in favour of or(IOFileFilter...)
+
+
+Enhancements from 1.4
+---------------------
+
+  * [IO-140] Move minimum Java requirement from JDK 1.3 to JDK 1.5
+             - use Generics
+             - add new CharSequence write() flavour methods to IOUtils and FileUtils
+             - replace StringBuffer with StringBuilder, where appropriate
+             - add new Reader/Writer methods to ProxyReader and ProxyWriter
+             - Annotate with @Override and @Deprecated
+
+  * [IO-178] New BOMInputStream and ByteOrderMark implementations - to detect and optionally exclude an initial Byte Order mark (BOM)
+  * [IO-197] New BoundedInputStream (copied from Apache JackRabbit)
+  * [IO-193] New Broken Input and Output streams
+  * [IO-132] New File Listener/Monitor facility
+  * [IO-158] New ReaderInputStream and WriterOutputStream implementations
+  * [IO-139] New StringBuilder Writer implementation
+  * [IO-192] New Tagged Input and Output streams
+  * [IO-177] New Tailer class - simple implementation of the Unix "tail -f" functionality
+  * [IO-162] New XML Stream Reader/Writer implementations (from ROME via plexus-utils)
+
+  * [IO-142] Comparators - add facility to sort file lists/arrays
+  * [IO-186] Comparators - new Composite and Directory File Comparator implementations
+  * [IO-176] DirectoryWalker - add filterDirectoryContents() callback method for filtering directory contents
+  * [IO-210] FileFilter - new Magic Number FileFilter
+  * [IO-221] FileFilterUtils - add methods for suffix and prefix filters which take an IOCase object
+  * [IO-232] FileFilterUtils - add method for name filters which take an IOCase object
+  * [IO-229] FileFilterUtils - add varargs and() and or() methods
+  * [IO-198] FileFilterUtils - add ability to apply file filters to collections and arrays
+  * [IO-156] FilenameUtils - add normalize() and normalizeNoEndSeparator() methods which allow the separator character to be specified
+  * [IO-194] FileSystemUtils - add freeSpaceKb() method with no input arguments
+  * [IO-185] FileSystemUtils - add freeSpaceKb() methods that take a timeout parameter - fixes freeSpaceWindows() blocks
+  * [IO-155] FileUtils - use NIO to copy files
+  * [IO-168] FileUtils - add new isSymlink() method
+  * [IO-219] FileUtils - throw FileExistsException when moving a file or directory if the destination already exists
+  * [IO-234] FileUtils - add Methods for retrieving System User/Temp directories/paths
+  * [IO-208] FileUtils - add timeout (connection and read) support for copyURLToFile() method 
+  * [IO-238] FileUtils - add sizeOf(File) method
+  * [IO-181] LineIterator now implements Iterable
+  * [IO-224] IOUtils - add closeQuietly(Closeable) and closeQuietly(Socket) methods
+  * [IO-203] IOUtils - add skipFully() method for InputStreams
+  * [IO-137] IOUtils and ByteArrayOutputStream - add toBufferedInputStream() method to avoid unnecessary array allocation/copy
+  * [IO-195] Proxy streams/Reader/Writer - provide exception handling methods
+  * [IO-211] Proxy Input/Output streams - add pre/post processing support
+  * [IO-242] Proxy Reader/Writer - add pre/post processing support
+
+
+Bug fixes from 1.4
+------------------
+  * [IO-214] ByteArrayOutputStream - fix inconsistent synchronization of fields
+  * [IO-201] Counting Input/Output streams - fix inconsistent synchronization
+  * [IO-159] FileCleaningTracker - fix remove() never returns null
+  * [IO-220] FileCleaningTracker - fix Vector performs badly under load
+  * [IO-167] FilenameUtils - fix case-insensitive string handling in FilenameUtils and FilesystemUtils
+  * [IO-179] FilenameUtils - fix StringIndexOutOfBounds exception in getPathNoEndSeparator()
+  * [IO-248] FilenameUtils - fix getFullPathNoEndSeparator() returns empty while path is a one level directory
+  * [IO-246] FilenameUtils - fix wildcardMatch gives incorrect results 
+  * [IO-187] FileSystemUtils - fix freeSpaceKb() doesn't work with relative paths on Linux
+  * [IO-160] FileSystemUtils - fix freeSpace() fails on solaris
+  * [IO-209] FileSystemUtils - fix freeSpaceKb() fails to return correct size for a windows mount point
+  * [IO-163] FileUtils - fix toURLs() using deprecated method of conversion to URL
+  * [IO-168] FileUtils - fix Symbolic links followed when deleting directory
+  * [IO-231] FileUtils - fix wrong exception message generated in isFileNewer() method
+  * [IO-207] FileUtils - fix race condition in forceMkdir() method
+  * [IO-217] FileUtils - fix copyDirectoryToDirectory() makes infinite loops
+  * [IO-166] FileUtils - fix URL decoding in toFile(URL)
+  * [IO-190] FileUtils - fix copyDirectory not preserving lastmodified date on subdirectories
+  * [IO-240] FileFilterUtils - ensure cvsFilter and svnFilter are only created once.
+  * [IO-175] IOUtils - fix copyFile() issues with very large files
+  * [IO-191] Improvements from static analysis
+  * [IO-216] LockableFileWriter - delete files quietly when an exception is thrown during initialization
+  * [IO-243] SwappedDataInputStream - fix readBoolean is inverted
+  * [IO-235] Tests - remove unused YellOnFlushAndCloseOutputStream from CopyUtilsTest
+  * [IO-161] Tests - fix FileCleaningTrackerTestCase hanging
+
+
+Documentation changes from 1.4
+------------------------------
+  * [IO-183 FilenameUtils.getExtension() method documentation improvements
+  * [IO-226 FileUtils.byteCountToDisplaySize() documentation corrections
+  * [IO-205 FileUtils.forceMkdir() documentation improvements
+  * [IO-215 FileUtils copy file/directory improve documentation regarding preserving the last modified date
+  * [IO-189 HexDump.dump() method documentation improvements
+  * [IO-171 IOCase document that it assumes there are only two OSes: Windows and Unix
+  * [IO-223 IOUtils.copy() documentation corrections
+  * [IO-247 IOUtils.closeQuietly() improve documentation with examples
+  * [IO-202 NotFileFilter documentation corrections
+  * [IO-206 ProxyInputStream - fix misleading parameter names
+  * [IO-212 ProxyInputStream.skip() documentation corrections
+</source>
+</p>
+</section>
+
+</body>
+</document>
diff --git a/src/site/xdoc/upgradeto2_0_1.xml b/src/site/xdoc/upgradeto2_0_1.xml
new file mode 100644
index 0000000..5f6f53e
--- /dev/null
+++ b/src/site/xdoc/upgradeto2_0_1.xml
@@ -0,0 +1,67 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Upgrade from 2.0 to 2.0.1</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+<body>
+
+<section name="Upgrade">
+<p>
+These are the release notes and advice for upgrading Commons-IO from
+version 2.0 to version 2.0.1
+<source>
+Commons IO is a package of Java utility classes for java.io's hierarchy.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+Commons IO contains utility classes, stream implementations, file filters, 
+file comparators and endian transformation classes.
+
+
+Compatibility with 2.0 and 1.4
+------------------------------
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+  Check the bug fixes section for semantic bug fixes
+
+Commons IO 2.0.1 requires a minimum of JDK 1.5
+ (Commons IO 1.4 had a minimum of JDK 1.3) 
+
+
+Enhancements from 2.0
+---------------------
+
+   * [IO-256] - Provide thread factory for FileAlternationMonitor
+
+
+Bug fixes from 2.0
+------------------
+
+   * [IO-257] - BOMInputStream.read(byte[]) can return 0 which it should not
+   * [IO-258] - XmlStreamReader consumes the stream during encoding detection
+</source>
+</p>
+</section>
+
+</body>
+</document>
diff --git a/src/site/xdoc/upgradeto2_1.xml b/src/site/xdoc/upgradeto2_1.xml
new file mode 100644
index 0000000..cf80d55
--- /dev/null
+++ b/src/site/xdoc/upgradeto2_1.xml
@@ -0,0 +1,82 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Upgrade from 2.0.1 to 2.1</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+<body>
+
+<section name="Upgrade">
+<p>
+These are the release notes and advice for upgrading Commons-IO from
+version 2.0.1 to version 2.1
+<source>
+Commons IO is a package of Java utility classes for java.io's hierarchy.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+Commons IO contains utility classes, stream implementations, file filters, 
+file comparators and endian transformation classes.
+
+Compatibility with 2.0.1 and 1.4
+--------------------------------
+
+Binary compatible - Yes
+
+Source compatible - Yes
+
+Semantic compatible - Yes
+  Check the bug fixes section for semantic bug fixes
+
+Commons IO 2.1 requires a minimum of JDK 1.5
+  (Commons IO 1.4 had a minimum of JDK 1.3) 
+
+
+New features since 2.0.1
+------------------------
+
+o Use standard Maven directory layout  Issue: IO-285. Thanks to ggregory. 
+o Add IOUtils API toString for URL and URI to get contents  Issue: IO-284. Thanks to ggregory. 
+o Add API FileUtils.copyFile(File input, OutputStream output)  Issue: IO-282. Thanks to ggregory. 
+o FileAlterationObserver has no getter for FileFilter  Issue: IO-262. 
+o Add FileUtils.getFile API with varargs parameter  Issue: IO-261. 
+o Add new APPEND parameter for writing string into files  Issue: IO-182. 
+o Add new read method "toByteArray" to handle InputStream with known size.  Issue: IO-251. Thanks to Marco Albini. 
+
+Fixed Bugs since 2.0.1
+----------------------
+
+o Dubious use of mkdirs() return code  Issue: IO-280. Thanks to sebb. 
+o ReaderInputStream enters infinite loop when it encounters an unmappable character  Issue: IO-277. 
+o FileUtils.moveFile() Javadoc should specify FileExistsException thrown  Issue: IO-264. 
+o ClassLoaderObjectInputStream does not handle Proxy classes  Issue: IO-260. 
+o Tailer returning partial lines when reaching EOF before EOL  Issue: IO-274. Thanks to Frank Grimes. 
+o FileUtils.copyFile() throws IOException when copying large files to a shared directory (on Windows)  Issue: IO-266. Thanks to Igor Smereka. 
+o FileSystemUtils.freeSpaceKb throws exception for Windows volumes with no visible files.
+        Improve coverage by also looking for hidden files.  Issue: IO-263. Thanks to Gil Adam. 
+
+Changes since 2.0.1
+-------------------
+o FileAlterationMonitor.stop(boolean allowIntervalToFinish)  Issue: IO-259. 
+</source>
+</p>
+</section>
+
+</body>
+</document>
diff --git a/src/site/xdoc/upgradeto2_2.xml b/src/site/xdoc/upgradeto2_2.xml
new file mode 100644
index 0000000..45497e6
--- /dev/null
+++ b/src/site/xdoc/upgradeto2_2.xml
@@ -0,0 +1,82 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Upgrade from 2.1 to 2.2</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+<body>
+
+<section name="Upgrade">
+<p>
+These are the release notes and advice for upgrading Commons-IO from
+version 2.1 to version 2.2
+<source>
+Commons IO is a package of Java utility classes like java.io.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+Commons IO contains utility classes, stream implementations, file filters, 
+file comparators, endian transformation classes, and much more.
+
+==============================================================================
+Commons IO Package Version 2.2
+==============================================================================
+
+Changes in this version include:
+
+New features:
+o IO-305:  New copyLarge() method in IOUtils that takes additional offset, length arguments Thanks to Manoj Mokashi. 
+o IO-287:  Use terabyte (TB) , petabyte (PB) and exabyte (EB) in FileUtils.byteCountToDisplaySize(long size) Thanks to Ron Kuris, Gary Gregory. 
+o IO-173:  FileUtils.listFiles() doesn't return directories Thanks to Marcos Vinícius da Silva. 
+o IO-297:  CharSequenceInputStream to efficiently stream content of a CharSequence Thanks to Oleg Kalnichevski. 
+o IO-304:  The second constructor of Tailer class does not pass 'delay' to the third one Thanks to liangly. 
+o IO-303:  TeeOutputStream does not call branch.close() when main.close() throws an exception Thanks to fabian.barney. 
+o IO-302:  ArrayIndexOutOfBoundsException in BOMInputStream when reading a file without BOM multiple times Thanks to jsteuerwald, detinho. 
+o IO-301:  Add IOUtils.closeQuietly(Selector) necessary Thanks to kaykay.unique. 
+o IO-292:  IOUtils.closeQuietly() should take a ServerSocket as a parameter Thanks to sebb. 
+o IO-290:  Add read/readFully methods to IOUtils Thanks to sebb. 
+o IO-288:  Supply a ReversedLinesFileReader Thanks to Georg Henzler. 
+o IO-291:  Add new function FileUtils.directoryContains. Thanks to ggregory. 
+o IO-275:  FileUtils.contentEquals and IOUtils.contentEquals - Add option to ignore "line endings"
+        Added contentEqualsIgnoreEOL methods to both classes Thanks to CJ Aspromgos. 
+
+Fixed Bugs:
+o IO-300:  FileUtils.moveDirectoryToDirectory removes source directory if destination is a subdirectory 
+o IO-307:  ReaderInputStream#read(byte[] b, int off, int len) should check for valid parameters 
+o IO-306:  ReaderInputStream#read(byte[] b, int off, int len) should always return 0 for length == 0 
+o IO-276:  "FileUtils#deleteDirectoryOnExit(File)" does not work Thanks to nkami. 
+o IO-273:  BoundedInputStream.read() treats max differently from BoundedInputStream.read(byte[]...) Thanks to sebb. 
+o IO-298:  Various methods of class 'org.apache.commons.io.FileUtils' incorrectly suppress 'java.io.IOException' Thanks to Christian Schulte. 
+
+Changes:
+o IO-296:  ReaderInputStream optimization: more efficient reading of small chunks of data Thanks to Oleg Kalnichevski. 
+
+Compatibility with 2.1 and 1.4:
+Binary compatible: Yes
+Source compatible: Yes
+Semantic compatible: Yes. Check the bug fixes section for semantic bug fixes
+
+Commons IO 2.2 requires a minimum of JDK 1.5. 
+Commons IO 1.4 requires a minimum of JDK 1.3. 
+</source>
+</p>
+</section>
+
+</body>
+</document>
diff --git a/src/site/xdoc/upgradeto2_3.xml b/src/site/xdoc/upgradeto2_3.xml
new file mode 100644
index 0000000..667ae44
--- /dev/null
+++ b/src/site/xdoc/upgradeto2_3.xml
@@ -0,0 +1,61 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Upgrade from 2.2 to 2.3</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+<body>
+
+<section name="Upgrade">
+<p>
+These are the release notes and advice for upgrading Commons-IO from
+version 2.2 to version 2.3
+<source>
+Commons IO is a package of Java utility classes like java.io.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+The Commons IO library contains utility classes, stream implementations, file filters, 
+file comparators, endian transformation classes, and much more.
+
+==============================================================================
+Apache Commons IO Version 2.3-SNAPSHOT
+==============================================================================
+
+Changes in this version include:
+
+New features:
+o IO-322:  Add and use class Charsets. Thanks to ggregory. 
+o IO-321:  ByteOrderMark UTF_32LE is incorrect. Thanks to ggregory. 
+o IO-318:  Add Charset sister APIs to method that take a String charset name. Thanks to ggregory. 
+
+Compatibility with 2.2 and 1.4:
+Binary compatible: Yes.
+Source compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+Semantic compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+
+Commons IO 2.3 requires JDK 1.6 or later.
+Commons IO 2.2 requires JDK 1.5 or later.
+Commons IO 1.4 requires JDK 1.3 or later.
+</source>
+</p>
+</section>
+
+</body>
+</document>
diff --git a/src/site/xdoc/upgradeto2_4.xml b/src/site/xdoc/upgradeto2_4.xml
new file mode 100644
index 0000000..9f46468
--- /dev/null
+++ b/src/site/xdoc/upgradeto2_4.xml
@@ -0,0 +1,81 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+ <properties>
+  <title>Upgrade from 2.3 to 2.4</title>
+  <author email="dev@commons.apache.org">Commons Documentation Team</author>
+ </properties>
+<body>
+
+<section name="Upgrade">
+<p>
+These are the release notes and advice for upgrading Commons-IO from
+version 2.3 to version 2.4
+<source>
+Commons IO is a package of Java utility classes like java.io.  
+Classes in this package are considered to be so standard and of such high 
+reuse as to justify existence in java.io.
+
+The Commons IO library contains utility classes, stream implementations, file filters, 
+file comparators, endian transformation classes, and much more.
+
+==============================================================================
+Apache Commons IO Version 2.4-SNAPSHOT
+==============================================================================
+
+Changes in this version include:
+
+New features:
+o IO-269:  Tailer locks file from deletion/rename on Windows. Thanks to sebb. 
+o IO-333:  Export OSGi packages at version 1.x in addition to 2.x. Thanks to fmeschbe. 
+o IO-320:  Add XmlStreamReader support for UTF-32. Thanks to ggregory. 
+o IO-331:  BOMInputStream wrongly detects UTF-32LE_BOM files as UTF-16LE_BOM files in method getBOM(). Thanks to ggregory. 
+o IO-327:  Add byteCountToDisplaySize(BigInteger). Thanks to ggregory. 
+o IO-326:  Add new FileUtils.sizeOf[Directory] APIs to return BigInteger. Thanks to ggregory. 
+o IO-325:  Add IOUtils.toByteArray methods to work with URL and URI. Thanks to raviprak. 
+o IO-324:  Add missing Charset sister APIs to method that take a String charset name. Thanks to raviprak. 
+
+Fixed Bugs:
+o IO-279:  Tailer erroneously considers file as new. Thanks to Sergio Bossa, Chris Baron. 
+o IO-335:  Tailer#readLines - incorrect CR handling. 
+o IO-334:  FileUtils.toURLs throws NPE for null parameter; document the behavior. 
+o IO-332:  Improve tailer's reading performance. Thanks to liangly. 
+o IO-279:  Improve Tailer performance with buffered reads (see IO-332). 
+o IO-329:  FileUtils.writeLines uses unbuffered IO. Thanks to tivv. 
+o IO-319:  FileUtils.sizeOfDirectory follows symbolic links. Thanks to raviprak. 
+
+Compatibility with 2.3:
+Binary compatible: Yes.
+Source compatible: Yes.
+Semantic compatible: Yes.
+
+Compatibility with 2.2 and 1.4:
+Binary compatible: Yes.
+Source compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+Semantic compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+
+Commons IO 2.4 requires JDK 1.6 or later.
+Commons IO 2.3 requires JDK 1.6 or later.
+Commons IO 2.2 requires JDK 1.5 or later.
+Commons IO 1.4 requires JDK 1.3 or later.
+</source>
+</p>
+</section>
+
+</body>
+</document>
diff --git a/src/site/xdoc/upgradeto2_5.xml b/src/site/xdoc/upgradeto2_5.xml
new file mode 100644
index 0000000..b8eb402
--- /dev/null
+++ b/src/site/xdoc/upgradeto2_5.xml
@@ -0,0 +1,152 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+    <properties>
+        <title>Upgrade from 2.4 to 2.5</title>
+        <author email="dev@commons.apache.org">Commons Documentation Team</author>
+    </properties>
+    <body>
+
+        <section name="Upgrade">
+            <p>
+                These are the release notes and advice for upgrading Commons-IO from
+                version 2.4 to version 2.5
+                <source>
+                    Commons IO is a package of Java utility classes like java.io.
+                    Classes in this package are considered to be so standard and of such high
+                    reuse as to justify existence in java.io.
+
+                    The Apache Commons IO library contains utility classes, stream implementations, file filters,
+                    file comparators, endian transformation classes, and much more.
+
+                    ==============================================================================
+                    Apache Commons IO Version 2.5
+                    ==============================================================================
+                    New features and bug fixes.
+
+                    Changes in this version include:
+
+                    New features:
+                    o IO-487:  Add ValidatingObjectInputStream for controlled deserialization
+                    o IO-471:  Support for additional encodings in ReversedLinesFileReader Thanks to Leandro Reis.
+                    o IO-425:  Setter method for threshold on ThresholdingOutputStream Thanks to Craig Swank.
+                    o IO-406:  Introduce new class AppendableOutputStream Thanks to Niall Pemberton.
+                    o IO-459:  Add WindowsLineEndingInputStream and UnixLineEndingInputStream. Thanks to Kristian Rosenvold.
+                    o IO-457:  Add a BoundedReader, a wrapper that can be used to constrain access
+                            to an underlying stream when used with mark/reset -
+                            to avoid overflowing the mark limit of the underlying buffer. Thanks to Kristian Rosenvold.
+                    o IO-426:  Add API IOUtils.closeQuietly(Closeable...)
+                    o IO-410:  Readfully() That Returns A Byte Array Thanks to Beluga Behr.
+                    o IO-395:  Overload IOUtils buffer methods to accept buffer size Thanks to Beluga Behr.
+                    o IO-382:  Chunked IO for large arrays.
+                             Added writeChunked(byte[], OutputStream) and writeChunked(char[] Writer)
+                             Added ChunkedOutputStream, ChunkedWriter
+                    o IO-233:  Add Methods for Buffering Streams/Writers To IOUtils
+                             Added overloaded buffer() methods - see also IO-330
+                    o IO-330:  IOUtils#toBufferedOutputStream/toBufferedWriter to conditionally wrap the output
+                             Added overloaded buffer() methods - see also IO-233
+                    o IO-381:  Add FileUtils.copyInputStreamToFile API with option to leave the source open.
+                            See copyInputStreamToFile(final InputStream source, final File destination, boolean closeSource)
+                    o IO-379:  CharSequenceInputStream - add tests for available()
+                             Fix code so it really does reflect a minimum available.
+                    o IO-346:  Add ByteArrayOutputStream.toInputStream()
+                    o IO-341:  A constant for holding the BOM character (U+FEFF)
+                    o IO-361:  Add API FileUtils.forceMkdirsParent().
+                    o IO-360:  Add API Charsets.requiredCharsets().
+                    o IO-359:  Add IOUtils.skip and skipFully(ReadableByteChannel, long). Thanks to yukoba.
+                    o IO-358:  Add IOUtils.read and readFully(ReadableByteChannel, ByteBuffer buffer). Thanks to yukoba.
+                    o IO-353:  Add API IOUtils.copy(InputStream, OutputStream, int) Thanks to ggregory.
+                    o IO-349:  Add API with array offset and length argument to FileUtils.writeByteArrayToFile. Thanks to scop.
+                    o IO-348:  Missing information in IllegalArgumentException thrown by org.apache.commons.io.FileUtils#validateListFilesParameters. Thanks to plcstpierre.
+                    o IO-345:  Supply a hook method allowing Tailer actively determining stop condition. Thanks to mkresse.
+                    o IO-437:  Make IOUtils.EOF public and reuse it in various classes.
+
+                    Fixed Bugs:
+                    o IO-446:  adds an endOfFileReached method to the TailerListener Thanks to Jeffrey Barrus.
+                    o IO-484:  FilenameUtils should handle embedded null bytes Thanks to Philippe Arteau.
+                    o IO-481:  Changed/Corrected algorithm for waitFor
+                    o IO-428:  BOMInputStream.skip returns wrong count if stream contains no BOM Thanks to Stefan Gmeiner.
+                    o IO-488:  FileUtils.waitFor(...) swallows thread interrupted status Thanks to Bj�rn Buchner.
+                    o IO-452:  Support for symlinks with missing target. Added support for JDK7 symlink features when present Thanks to David Standish.
+                    o IO-453:  Regression in FileUtils.readFileToString from 2.0.1 Thanks to Steven Christou.
+                    o IO-451:  ant test fails - resources missing from test classpath Thanks to David Standish.
+                    o IO-435:  Document that FileUtils.deleteDirectory, directoryContains and cleanDirectory
+                             may throw an IllegalArgumentException in case the passed directory does not
+                             exist or is not a directory. Thanks to Dominik Stadler.
+                    o IO-424:  Javadoc fixes, mostly to appease 1.8.0 Thanks to Ville Skytt�.
+                    o IO-389:  FileUtils.sizeOfDirectory can throw IllegalArgumentException Thanks to Austin Doupnik.
+                    o IO-390:  FileUtils.sizeOfDirectoryAsBigInteger can overflow.
+                             Ensure that recursive calls all use BigInteger
+                    o IO-385:  FileUtils.doCopyFile can potentially loop forever
+                             Exit loop if no data to copy
+                    o IO-383:  FileUtils.doCopyFile caches the file size; needs to be documented
+                             Added Javadoc; show file lengths in exception message
+                    o IO-380:  FileUtils.copyInputStreamToFile should document it closes the input source Thanks to claudio_ch.
+                    o IO-279:  Tailer erroneously considers file as new.
+                            Fix to use file.lastModified() rather than System.currentTimeMillis()
+                    o IO-356:  CharSequenceInputStream#reset() behaves incorrectly in case when buffer size is not dividable by data size.
+                             Fix code so skip relates to the encoded bytes; reset now re-encodes the data up to the point of the mark
+                    o IO-368:  ClassLoaderObjectInputStream does not handle primitive typed members
+                    o IO-314:  Deprecate all methods that use the default encoding
+                    o IO-338:  When a file is rotated, finish reading previous file prior to starting new one
+                    o IO-354:  Commons IO Tailer does not respect UTF-8 Charset.
+                    o IO-323:  What should happen in FileUtils.sizeOf[Directory] when an overflow takes place?
+                            Added Javadoc.
+                    o IO-372:  FileUtils.moveDirectory can produce misleading error message on failure
+                    o IO-362:  IOUtils.contentEquals* methods returns false if input1 == input2, should return true. Thanks to mmadson, ggregory.
+                    o IO-357:  [Tailer] InterruptedException while the thread is sleeping is silently ignored Thanks to mortenh.
+                    o IO-352:  Spelling fixes. Thanks to scop.
+                    o IO-436:  Improper Javadoc comment for FilenameUtils.indexOfExtension. Thanks to christoph.schneegans.
+
+                    Changes:
+                    o IO-433:  Converted all testcases to JUnit 4
+                    o IO-466:  Added testcase to show this was fixed with IO-423
+                    o IO-479:  Correct exception message in FileUtils.getFile(File, String...) Thanks to Zhouce Chen.
+                    o IO-465:  Update to JUnit 4.12 Thanks to based2.
+                    o IO-462:  IOExceptionWithCause no longer needed
+                    o IO-422:  Deprecate Charsets Charset constants in favor of Java 7's java.nio.charset.StandardCharsets
+                    o IO-239:  Convert IOCase to a Java 1.5+ Enumeration
+                             [N.B. this is binary compatible]
+                    o IO-328:  getPrefixLength returns null if filename has leading slashes
+                            Javadoc: add examples to show correct behavior; add unit tests
+                    o IO-299:  FileUtils.listFilesAndDirs includes original dir in results even when it doesn't match filter
+                            Javadoc: clarify that original dir is included in the results
+                    o IO-375:  FilenameUtils.splitOnTokens(String text) check for '**' could be simplified
+                    o IO-374:  WildcardFileFilter ctors should not use null to mean IOCase.SENSITIVE when delegating to other ctors
+
+                    Compatibility with 2.4:
+                    Binary compatible: Yes.
+                    Source compatible: Yes.
+                    Semantic compatible: Yes.
+
+                    Compatibility with 2.2 and 1.4:
+                    Binary compatible: Yes.
+                    Source compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+                    Semantic compatible: No, see the rare case in https://issues.apache.org/jira/browse/IO-318.
+
+                    Commons IO 2.5 requires JDK 1.6 or later.
+                    Commons IO 2.4 requires JDK 1.6 or later.
+                    Commons IO 2.3 requires JDK 1.6 or later.
+                    Commons IO 2.2 requires JDK 1.5 or later.
+                    Commons IO 1.4 requires JDK 1.3 or later.
+                </source>
+            </p>
+        </section>
+
+    </body>
+</document>
diff --git a/src/site/xdoc/upgradeto2_6.xml b/src/site/xdoc/upgradeto2_6.xml
new file mode 100644
index 0000000..d8c7f8b
--- /dev/null
+++ b/src/site/xdoc/upgradeto2_6.xml
@@ -0,0 +1,156 @@
+<?xml version="1.0"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<document>
+    <properties>
+        <title>Upgrade from 2.5 to 2.6</title>
+        <author email="dev@commons.apache.org">Commons Documentation Team</author>
+    </properties>
+    <body>
+
+        <section name="Upgrade">
+            <p>
+                These are the release notes and advice for upgrading Apache Commons IO from
+                version 2.5 to version 2.6
+                <source>
+                    Apache Commons IO is a package of Java utility classes like java.io.
+                    Classes in this package are considered to be so standard and of such high
+                    reuse as to justify existence in java.io.
+
+                    The Apache Commons IO library contains utility classes, stream implementations,
+                    file filters, file comparators, endian transformation classes, and much more.
+
+                    Apache Commons IO 2.6 requires at least Java 7 to build and run.
+
+
+                    DEPRECATIONS
+                    ============
+
+                    All closeQuietly overloads in org.apache.commons.io.IOUtils have been
+                    deprecated. Use the try-with-resources statement or handle suppressed
+                    exceptions manually.
+
+                    The class org.apache.commons.io.FileSystemUtils has been deprecated.
+                    Use equivalent methods in java.nio.file.FileStore instead, e.g.
+                    Files.getFileStore(Paths.get("/home")).getUsableSpace() or iterate over
+                    FileSystems.getDefault().getFileStores().
+
+
+                    COMPATIBILITY WITH JAVA 9
+                    ==================
+
+                    The MANIFEST.MF now contains an additional entry:
+
+                    Automatic-Module-Name: org.apache.commons.io
+
+                    This should make it possible to use Commons IO 2.6 as a module in the Java 9
+                    module system. For more information see the corresponding issue:
+
+                    https://issues.apache.org/jira/browse/IO-551
+
+                    Building Commons IO 2.6 should work out of the box with the latest Java 9
+                    release. Please report any Java 9 related issues at:
+
+                    https://issues.apache.org/jira/browse/IO
+
+
+                    NEW FEATURES
+                    ============
+
+                    o IO-551: Add Automatic-Module-Name MANIFEST entry for Java 9 compatibility.
+                    o IO-367: Add convenience methods for copyToDirectory. Thanks to James Sawle.
+                    o IO-493: Add infinite circular input stream. Thanks to Piotr Turski.
+                    o IO-507: Add a ByteOrderUtils class.
+                    o IO-518: Add ObservableInputStream.
+                    o IO-519: Add MessageDigestCalculatingInputStream.
+                    o IO-513: Add convenience methods for reading class path resources.
+                    Thanks to Behrang Saeedzadeh.
+
+                    FIXED BUGS
+                    ==========
+
+                    o IO-550: Documentation issue, fix 404 Javadoc issues in the description page.
+                    Thanks to Jimi Adrian.
+                    o IO-442: Javadoc contradictory for FileFilterUtils.ageFileFilter(cutoff) and
+                    the filter it constructs: AgeFileFilter(cutoff).
+                    Thanks to Simon Robinson.
+                    o IO-534: FileUtilTestCase.testForceDeleteDir() should not delete testDirectory
+                    parent.
+                    o IO-528: Fix Tailer.run race condition runaway logging. Thanks to Dave Moten.
+                    o IO-483: getPrefixLength return -1 if Unix file contains colon.
+                    Thanks to Marko Vasic.
+                    o IO-520: FileUtilsTestCase#testContentEqualsIgnoreEOL fails on Windows.
+                    o IO-516: .gitattributes not correctly applied. Thanks to Jason Pyeron.
+                    o IO-515: Allow Specifying Initial Buffer Size of DeferredFileOutputStream.
+                    Thanks to Brett Lounsbury, Gary Gregory.
+                    o IO-512: ThresholdingOutputStream.thresholdReached() results in
+                    FileNotFoundException. Thanks to Ralf Hauser.
+                    o IO-511: After a few unit tests, a few newly created directories not cleaned
+                    completely. Thanks to Ahmet Celik.
+                    o IO-502: Exceptions are suppressed incorrectly when copying files.
+                    Thanks to Christian Schulte.
+                    o IO-503: Update platform requirement to Java 7.
+                    o IO-537: BOMInputStream shouldn't sort array of BOMs in-place.
+                    Thanks to Borys Zibrov.
+
+                    CHANGES
+                    =======
+
+                    o IO-542: FileUtils#readFileToByteArray: optimize reading of files with known
+                    size. Thanks to Ilmars Poikans.
+                    o IO-547: Throw a IllegalArgumentException instead of NullPointerException in
+                    FileSystemUtils.freeSpaceWindows(). Thanks to Nikhil Shinde,
+                    Michael Ernst, Gary Greory.
+                    o IO-506: Deprecate methods FileSystemUtils.freeSpaceKb().
+                    Thanks to Christian Schulte.
+                    o IO-505: Make LineIterator implement Closeable to support try-with-resources
+                    statements. Thanks to Christian Schulte.
+                    o IO-504: Deprecated of all IOUtils.closeQuietly() methods and use
+                    try-with-resources internally. Thanks to Christian Schulte.
+
+                    REMOVED
+                    =======
+
+                    o IO-514: Remove org.apache.commons.io.Java7Support.
+
+                    COMPATIBILITY WITH OLDER VERSIONS
+                    =================================
+
+                    Compatibility with 2.5:
+                    Binary compatible: Yes.
+                    Source compatible: Yes.
+                    Semantic compatible: Yes.
+
+                    Compatibility with 2.6 and 1.4:
+                    Binary compatible: Yes.
+                    Source compatible: No, see the rare case in
+                    https://issues.apache.org/jira/browse/IO-318.
+                    Semantic compatible: No, see the rare case in
+                    https://issues.apache.org/jira/browse/IO-318.
+
+                    Commons IO 2.6 requires Java 7 or later.
+                    Commons IO 2.5 requires Java 6 or later.
+                    Commons IO 2.4 requires Java 6 or later.
+                    Commons IO 2.3 requires Java 6 or later.
+                    Commons IO 2.2 requires Java 5 or later.
+                    Commons IO 1.4 requires Java 1.3 or later.
+                </source>
+            </p>
+        </section>
+
+    </body>
+</document>
diff --git a/src/test/java/org/apache/commons/io/ByteOrderMarkTest.java b/src/test/java/org/apache/commons/io/ByteOrderMarkTest.java
new file mode 100644
index 0000000..fe2430b
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/ByteOrderMarkTest.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.Charset;
+
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Test for {@link ByteOrderMark}.
+ */
+public class ByteOrderMarkTest  {
+
+    private static final ByteOrderMark TEST_BOM_1 = new ByteOrderMark("test1", 1);
+    private static final ByteOrderMark TEST_BOM_2 = new ByteOrderMark("test2", 1, 2);
+    private static final ByteOrderMark TEST_BOM_3 = new ByteOrderMark("test3", 1, 2, 3);
+
+    /** Tests that {@link ByteOrderMark#getCharsetName()} can be loaded as a {@link java.nio.charset.Charset} as advertised. */
+    @Test
+    public void testConstantCharsetNames() {
+        assertNotNull(Charset.forName(ByteOrderMark.UTF_8.getCharsetName()));
+        assertNotNull(Charset.forName(ByteOrderMark.UTF_16BE.getCharsetName()));
+        assertNotNull(Charset.forName(ByteOrderMark.UTF_16LE.getCharsetName()));
+        assertNotNull(Charset.forName(ByteOrderMark.UTF_32BE.getCharsetName()));
+        assertNotNull(Charset.forName(ByteOrderMark.UTF_32LE.getCharsetName()));
+    }
+
+    /** Tests Exceptions */
+    @Test
+    public void testConstructorExceptions() {
+        assertThrows(NullPointerException.class, () -> new ByteOrderMark(null, 1, 2, 3));
+        assertThrows(IllegalArgumentException.class, () -> new ByteOrderMark("", 1, 2, 3));
+        assertThrows(NullPointerException.class, () -> new ByteOrderMark("a", (int[]) null));
+        assertThrows(IllegalArgumentException.class, () -> new ByteOrderMark("b"));
+    }
+
+    /** Tests {@link ByteOrderMark#equals(Object)} */
+    @SuppressWarnings("EqualsWithItself")
+    @Test
+    public void testEquals() {
+        assertEquals(TEST_BOM_1, TEST_BOM_1, "test1 equals");
+        assertEquals(TEST_BOM_2, TEST_BOM_2, "test2 equals");
+        assertEquals(TEST_BOM_3, TEST_BOM_3, "test3 equals");
+
+        assertNotEquals(TEST_BOM_1, new Object(), "Object not equal");
+        assertNotEquals(TEST_BOM_1, new ByteOrderMark("1a", 2), "test1-1 not equal");
+        assertNotEquals(TEST_BOM_1, new ByteOrderMark("1b", 1, 2), "test1-2 not test2");
+        assertNotEquals(TEST_BOM_2, new ByteOrderMark("2", 1, 1), "test2 not equal");
+        assertNotEquals(TEST_BOM_3, new ByteOrderMark("3", 1, 2, 4), "test3 not equal");
+    }
+
+    /** Tests {@link ByteOrderMark#getBytes()} */
+    @Test
+    public void testGetBytes() {
+        assertArrayEquals(TEST_BOM_1.getBytes(), new byte[]{(byte) 1}, "test1 bytes");
+        TEST_BOM_1.getBytes()[0] = 2;
+        assertArrayEquals(TEST_BOM_1.getBytes(), new byte[]{(byte) 1}, "test1 bytes");
+        assertArrayEquals(TEST_BOM_2.getBytes(), new byte[]{(byte) 1, (byte) 2}, "test1 bytes");
+        assertArrayEquals(TEST_BOM_3.getBytes(), new byte[]{(byte) 1, (byte) 2, (byte) 3}, "test1 bytes");
+    }
+
+    /** Tests {@link ByteOrderMark#getCharsetName()} */
+    @Test
+    public void testGetCharsetName() {
+        assertEquals("test1", TEST_BOM_1.getCharsetName(), "test1 name");
+        assertEquals("test2", TEST_BOM_2.getCharsetName(), "test2 name");
+        assertEquals("test3", TEST_BOM_3.getCharsetName(), "test3 name");
+    }
+
+    /** Tests {@link ByteOrderMark#get(int)} */
+    @Test
+    public void testGetInt() {
+        assertEquals(1, TEST_BOM_1.get(0), "test1 get(0)");
+        assertEquals(1, TEST_BOM_2.get(0), "test2 get(0)");
+        assertEquals(2, TEST_BOM_2.get(1), "test2 get(1)");
+        assertEquals(1, TEST_BOM_3.get(0), "test3 get(0)");
+        assertEquals(2, TEST_BOM_3.get(1), "test3 get(1)");
+        assertEquals(3, TEST_BOM_3.get(2), "test3 get(2)");
+    }
+
+    /** Tests {@link ByteOrderMark#hashCode()} */
+    @Test
+    public void testHashCode() {
+        final int bomClassHash = ByteOrderMark.class.hashCode();
+        assertEquals(bomClassHash + 1, TEST_BOM_1.hashCode(), "hash test1 ");
+        assertEquals(bomClassHash + 3, TEST_BOM_2.hashCode(), "hash test2 ");
+        assertEquals(bomClassHash + 6, TEST_BOM_3.hashCode(), "hash test3 ");
+    }
+
+    /** Tests {@link ByteOrderMark#length()} */
+    @Test
+    public void testLength() {
+        assertEquals(1, TEST_BOM_1.length(), "test1 length");
+        assertEquals(2, TEST_BOM_2.length(), "test2 length");
+        assertEquals(3, TEST_BOM_3.length(), "test3 length");
+    }
+
+    /** Tests {@link ByteOrderMark#toString()} */
+    @Test
+    public void testToString() {
+        assertEquals("ByteOrderMark[test1: 0x1]",          TEST_BOM_1.toString(), "test1 ");
+        assertEquals("ByteOrderMark[test2: 0x1,0x2]",      TEST_BOM_2.toString(), "test2 ");
+        assertEquals("ByteOrderMark[test3: 0x1,0x2,0x3]",  TEST_BOM_3.toString(), "test3 ");
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/ByteOrderParserTest.java b/src/test/java/org/apache/commons/io/ByteOrderParserTest.java
new file mode 100644
index 0000000..268c9c3
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/ByteOrderParserTest.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.ByteOrder;
+
+import org.junit.jupiter.api.Test;
+
+public class ByteOrderParserTest {
+
+    private ByteOrder parseByteOrder(final String value) {
+        return ByteOrderParser.parseByteOrder(value);
+    }
+
+    @Test
+    public void testParseBig() {
+        assertEquals(ByteOrder.BIG_ENDIAN, parseByteOrder("BIG_ENDIAN"));
+    }
+
+    @Test
+    public void testParseLittle() {
+        assertEquals(ByteOrder.LITTLE_ENDIAN, parseByteOrder("LITTLE_ENDIAN"));
+    }
+
+    @Test
+    public void testThrowsException() {
+        assertThrows(IllegalArgumentException.class, () -> parseByteOrder("some value"));
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/CharsetsTest.java b/src/test/java/org/apache/commons/io/CharsetsTest.java
new file mode 100644
index 0000000..b68b90b
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/CharsetsTest.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.SortedMap;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link Charsets}.
+ */
+@SuppressWarnings("deprecation") // testing deprecated code
+public class CharsetsTest {
+
+    @Test
+    public void testIso8859_1() {
+        assertEquals("ISO-8859-1", Charsets.ISO_8859_1.name());
+    }
+
+    @Test
+    public void testRequiredCharsets() {
+        final SortedMap<String, Charset> requiredCharsets = Charsets.requiredCharsets();
+        // test for what we expect to be there as of Java 6
+        // Make sure the object at the given key is the right one
+        assertEquals(requiredCharsets.get("US-ASCII").name(), "US-ASCII");
+        assertEquals(requiredCharsets.get("ISO-8859-1").name(), "ISO-8859-1");
+        assertEquals(requiredCharsets.get("UTF-8").name(), "UTF-8");
+        assertEquals(requiredCharsets.get("UTF-16").name(), "UTF-16");
+        assertEquals(requiredCharsets.get("UTF-16BE").name(), "UTF-16BE");
+        assertEquals(requiredCharsets.get("UTF-16LE").name(), "UTF-16LE");
+    }
+
+    @Test
+    public void testToCharset_String() {
+        assertEquals(Charset.defaultCharset(), Charsets.toCharset((String) null));
+        assertEquals(Charset.defaultCharset(), Charsets.toCharset((Charset) null));
+        assertEquals(Charset.defaultCharset(), Charsets.toCharset(Charset.defaultCharset()));
+        assertEquals(StandardCharsets.UTF_8, Charsets.toCharset(StandardCharsets.UTF_8));
+    }
+
+    @Test
+    public void testToCharset_String_Charset() {
+        assertEquals(null, Charsets.toCharset((String) null, null));
+        assertEquals(Charset.defaultCharset(), Charsets.toCharset((String) null, Charset.defaultCharset()));
+        assertEquals(Charset.defaultCharset(), Charsets.toCharset((Charset) null, Charset.defaultCharset()));
+        assertEquals(null, Charsets.toCharset((Charset) null, null));
+        assertEquals(Charset.defaultCharset(), Charsets.toCharset(Charset.defaultCharset(), Charset.defaultCharset()));
+        assertEquals(StandardCharsets.UTF_8, Charsets.toCharset(StandardCharsets.UTF_8, Charset.defaultCharset()));
+        assertEquals(StandardCharsets.UTF_8, Charsets.toCharset(StandardCharsets.UTF_8, null));
+    }
+
+    @Test
+    public void testUsAscii() {
+        assertEquals("US-ASCII", Charsets.US_ASCII.name());
+    }
+
+    @Test
+    public void testUtf16() {
+        assertEquals("UTF-16", Charsets.UTF_16.name());
+    }
+
+    @Test
+    public void testUtf16Be() {
+        assertEquals("UTF-16BE", Charsets.UTF_16BE.name());
+    }
+
+    @Test
+    public void testUtf16Le() {
+        assertEquals("UTF-16LE", Charsets.UTF_16LE.name());
+    }
+
+    @Test
+    public void testUtf8() {
+        assertEquals("UTF-8", Charsets.UTF_8.name());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/CopyUtilsTest.java b/src/test/java/org/apache/commons/io/CopyUtilsTest.java
new file mode 100644
index 0000000..ba9f2d1
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/CopyUtilsTest.java
@@ -0,0 +1,255 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.Reader;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.commons.io.input.StringInputStream;
+import org.apache.commons.io.output.ByteArrayOutputStream;
+import org.apache.commons.io.test.TestUtils;
+import org.apache.commons.io.test.ThrowOnCloseInputStream;
+import org.apache.commons.io.test.ThrowOnFlushAndCloseOutputStream;
+import org.junit.jupiter.api.Test;
+
+@SuppressWarnings("deprecation") // these are test cases for the deprecated CopyUtils
+
+/**
+ * JUnit tests for CopyUtils.
+ *
+ * @see CopyUtils
+ */
+public class CopyUtilsTest {
+
+    /*
+     * NOTE this is not particularly beautiful code. A better way to check for
+     * flush and close status would be to implement "trojan horse" wrapper
+     * implementations of the various stream classes, which set a flag when
+     * relevant methods are called. (JT)
+     */
+
+    private static final int FILE_SIZE = 1024 * 4 + 1;
+
+    private final byte[] inData = TestUtils.generateTestData(FILE_SIZE);
+
+    @Test
+    public void copy_byteArrayToOutputStream() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        CopyUtils.copy(inData, out);
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void copy_byteArrayToWriter() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+        final Writer writer = new java.io.OutputStreamWriter(out, StandardCharsets.US_ASCII);
+
+        CopyUtils.copy(inData, writer);
+        writer.flush();
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    @Test
+    public void copy_inputStreamToWriter() throws Exception {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+        final Writer writer = new java.io.OutputStreamWriter(out, StandardCharsets.US_ASCII);
+
+        CopyUtils.copy(in, writer);
+        writer.flush();
+
+        assertEquals(0, in.available(), "Not all bytes were read");
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void copy_inputStreamToWriterWithEncoding() throws Exception {
+        final String inDataStr = "data";
+        final String charsetName = StandardCharsets.UTF_8.name();
+        final StringWriter writer = new StringWriter();
+        CopyUtils.copy(new StringInputStream(inDataStr, charsetName), writer, charsetName);
+        assertEquals(inDataStr, writer.toString());
+    }
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    @Test
+    public void copy_readerToWriter() throws Exception {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+        final Reader reader = new java.io.InputStreamReader(in, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+        final Writer writer = new java.io.OutputStreamWriter(out, StandardCharsets.US_ASCII);
+
+        final int count = CopyUtils.copy(reader, writer);
+        writer.flush();
+        assertEquals(inData.length, count, "The number of characters returned by copy is wrong");
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void copy_stringToOutputStream() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        CopyUtils.copy(str, out);
+        //Note: this method *does* flush. It is equivalent to:
+        //  OutputStreamWriter _out = new OutputStreamWriter(fout);
+        //  IOUtils.copy( str, _out, 4096 ); // copy( Reader, Writer, int );
+        //  _out.flush();
+        //  out = fout;
+        // note: we don't flush here; this IOUtils method does it for us
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void copy_stringToOutputStreamString() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        CopyUtils.copy(str, out, StandardCharsets.US_ASCII.name());
+        //Note: this method *does* flush. It is equivalent to:
+        //  OutputStreamWriter _out = new OutputStreamWriter(fout);
+        //  IOUtils.copy( str, _out, 4096 ); // copy( Reader, Writer, int );
+        //  _out.flush();
+        //  out = fout;
+        // note: we don't flush here; this IOUtils method does it for us
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void copy_stringToWriter() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+        final Writer writer = new java.io.OutputStreamWriter(out, StandardCharsets.US_ASCII);
+
+        CopyUtils.copy(str, writer);
+        writer.flush();
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testCopy_byteArrayToWriterWithEncoding() throws Exception {
+        final String inDataStr = "data";
+        final String charsetName = StandardCharsets.UTF_8.name();
+        final StringWriter writer = new StringWriter();
+        CopyUtils.copy(inDataStr.getBytes(charsetName), writer, charsetName);
+        assertEquals(inDataStr, writer.toString());
+    }
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    @Test
+    public void testCopy_inputStreamToOutputStream() throws Exception {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        final int count = CopyUtils.copy(in, out);
+
+        assertEquals(0, in.available(), "Not all bytes were read");
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+        assertEquals(inData.length, count);
+    }
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    @Test
+    public void testCopy_readerToOutputStream() throws Exception {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+        final Reader reader = new java.io.InputStreamReader(in, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        CopyUtils.copy(reader, out);
+        //Note: this method *does* flush. It is equivalent to:
+        //  OutputStreamWriter _out = new OutputStreamWriter(fout);
+        //  IOUtils.copy( fin, _out, 4096 ); // copy( Reader, Writer, int );
+        //  _out.flush();
+        //  out = fout;
+
+        // Note: rely on the method to flush
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    @Test
+    public void testCopy_readerToOutputStreamString() throws Exception {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+        final Reader reader = new java.io.InputStreamReader(in, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        CopyUtils.copy(reader, out, StandardCharsets.US_ASCII.name());
+        //Note: this method *does* flush. It is equivalent to:
+        //  OutputStreamWriter _out = new OutputStreamWriter(fout);
+        //  IOUtils.copy( fin, _out, 4096 ); // copy( Reader, Writer, int );
+        //  _out.flush();
+        //  out = fout;
+
+        // Note: rely on the method to flush
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testCtor() {
+        new CopyUtils();
+        // Nothing to assert, the constructor is public and does not blow up.
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/DeleteDirectoryTest.java b/src/test/java/org/apache/commons/io/DeleteDirectoryTest.java
new file mode 100644
index 0000000..aaffbbe
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/DeleteDirectoryTest.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.Set;
+
+import org.apache.commons.io.file.AbstractTempDirTest;
+import org.apache.commons.io.file.PathUtils;
+import org.apache.commons.io.file.StandardDeleteOption;
+import org.apache.commons.io.function.IOConsumer;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+/**
+ * Tests <a href="https://issues.apache.org/jira/browse/IO-751">IO-751</a>.
+ * <p>
+ * Must be run on a POSIX file system, macOS or Linux, disabled on Windows.
+ * </p>
+ */
+@DisabledOnOs(OS.WINDOWS)
+public class DeleteDirectoryTest extends AbstractTempDirTest {
+
+    private void testDeleteDirectory(final IOConsumer<Path> deleter) throws IOException {
+        // Create a test file
+        final String contents = "Hello!";
+        final Path file = tempDirPath.resolve("file.txt");
+        final Charset charset = StandardCharsets.UTF_8;
+        PathUtils.writeString(file, contents, charset);
+        final Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(file);
+        // Sanity check: Owner has write permission on the new file
+        assertTrue(permissions.contains(PosixFilePermission.OWNER_WRITE), permissions::toString);
+
+        // Create a test directory
+        final Path testDir = tempDirPath.resolve("dir");
+        Files.createDirectory(testDir);
+
+        // Inside the test directory, create a symlink to the test file
+        final Path symLink = testDir.resolve("symlink.txt");
+        Files.createSymbolicLink(symLink, file);
+        // Sanity check: The symlink really points to the test file
+        assertEquals(contents, PathUtils.readString(symLink, charset));
+
+        // Delete the test directory using the given implementation
+        deleter.accept(testDir);
+        // Symlink is gone -- passes
+        assertFalse(Files.exists(symLink), symLink::toString);
+        // The test file still exists -- passes
+        assertTrue(Files.exists(file), file::toString);
+        // The permissions of the test file should still be the same
+        assertEquals(permissions, Files.getPosixFilePermissions(file), file::toString);
+    }
+
+    @Test
+    public void testDeleteDirectoryWithFileUtils() throws IOException {
+        testDeleteDirectory(dir -> FileUtils.deleteDirectory(dir.toFile()));
+    }
+
+    @Test
+    public void testDeleteDirectoryWithPathUtils() throws IOException {
+        testDeleteDirectory(PathUtils::deleteDirectory);
+    }
+
+    @Test
+    public void testDeleteDirectoryWithPathUtilsOverrideReadOnly() throws IOException {
+        testDeleteDirectory(dir -> PathUtils.deleteDirectory(dir, StandardDeleteOption.OVERRIDE_READ_ONLY));
+    }
+
+    @Test
+    @DisabledOnOs(OS.LINUX) // TODO
+    public void testDeleteFileCheckParentAccess() throws IOException {
+        // Create a test directory
+        final Path testDir = tempDirPath.resolve("dir");
+        Files.createDirectory(testDir);
+
+        // Create a test file
+        final Path file = testDir.resolve("file.txt");
+        final Charset charset = StandardCharsets.UTF_8;
+        PathUtils.writeString(file, "Hello!", charset);
+
+        // A file is RO in POSIX if the parent is not W and not E.
+        PathUtils.setReadOnly(file, true);
+        final Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(testDir);
+        assertFalse(Files.isWritable(testDir),
+                () -> String.format("Parent directory '%s' of '%s' should NOT be Writable, permissions are %s ", testDir, file, permissions));
+        assertFalse(Files.isExecutable(testDir),
+                () -> String.format("Parent directory '%s' of '%s' should NOT be Executable, permissions are %s ", testDir, file, permissions));
+
+        assertThrows(IOException.class, () -> PathUtils.delete(file));
+        // Nothing happened, we're not even allowed to test attributes, so the file seems deleted, but it is not.
+
+        PathUtils.delete(file, StandardDeleteOption.OVERRIDE_READ_ONLY);
+
+        assertFalse(Files.exists(file));
+
+        assertEquals(permissions, Files.getPosixFilePermissions(testDir), testDir::toString);
+        assertFalse(Files.isWritable(testDir));
+        assertFalse(Files.isExecutable(testDir));
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/DemuxInputStreamTest.java b/src/test/java/org/apache/commons/io/DemuxInputStreamTest.java
new file mode 100644
index 0000000..b117623
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/DemuxInputStreamTest.java
@@ -0,0 +1,197 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Random;
+
+import org.apache.commons.io.input.DemuxInputStream;
+import org.apache.commons.io.input.StringInputStream;
+import org.apache.commons.io.output.ByteArrayOutputStream;
+import org.apache.commons.io.output.DemuxOutputStream;
+import org.apache.commons.io.test.TestUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Basic unit tests for the multiplexing streams.
+ */
+public class DemuxInputStreamTest {
+
+    private static class ReaderThread extends Thread {
+        private final DemuxInputStream demuxInputStream;
+        private final InputStream inputStream;
+        private final StringBuffer stringBuffer = new StringBuffer();
+
+        ReaderThread(final String name, final InputStream input, final DemuxInputStream demux) {
+            super(name);
+            inputStream = input;
+            demuxInputStream = demux;
+        }
+
+        public String getData() {
+            return stringBuffer.toString();
+        }
+
+        @Override
+        public void run() {
+            demuxInputStream.bindStream(inputStream);
+
+            try {
+                int ch = demuxInputStream.read();
+                while (-1 != ch) {
+                    // System.out.println( "Reading: " + (char)ch );
+                    stringBuffer.append((char) ch);
+
+                    final int sleepMillis = Math.abs(c_random.nextInt() % 10);
+                    TestUtils.sleep(sleepMillis);
+                    ch = demuxInputStream.read();
+                }
+            } catch (final Exception e) {
+                e.printStackTrace();
+            }
+        }
+    }
+
+    private static class WriterThread extends Thread {
+        private final byte[] byteArray;
+        private final DemuxOutputStream demuxOutputStream;
+        private final OutputStream outputStream;
+
+        WriterThread(final String name, final String data, final OutputStream output, final DemuxOutputStream demux) {
+            super(name);
+            outputStream = output;
+            demuxOutputStream = demux;
+            byteArray = data.getBytes();
+        }
+
+        @Override
+        public void run() {
+            demuxOutputStream.bindStream(outputStream);
+            for (final byte element : byteArray) {
+                try {
+                    // System.out.println( "Writing: " + (char)byteArray[ i ] );
+                    demuxOutputStream.write(element);
+                    final int sleepMillis = Math.abs(c_random.nextInt() % 10);
+                    TestUtils.sleep(sleepMillis);
+                } catch (final Exception e) {
+                    e.printStackTrace();
+                }
+            }
+        }
+    }
+
+    private static final Random c_random = new Random();
+    private static final String DATA1 = "Data for thread1";
+
+    private static final String DATA2 = "Data for thread2";
+    private static final String DATA3 = "Data for thread3";
+    private static final String DATA4 = "Data for thread4";
+    private static final String T1 = "Thread1";
+
+    private static final String T2 = "Thread2";
+    private static final String T3 = "Thread3";
+    private static final String T4 = "Thread4";
+
+    private final HashMap<String, ByteArrayOutputStream> outputMap = new HashMap<>();
+
+    private final HashMap<String, Thread> threadMap = new HashMap<>();
+
+    private void doJoin() throws InterruptedException {
+        for (final String name : threadMap.keySet()) {
+            final Thread thread = threadMap.get(name);
+            thread.join();
+        }
+    }
+
+    private void doStart() {
+        threadMap.keySet().forEach(name -> threadMap.get(name).start());
+    }
+
+    private String getInput(final String threadName) {
+        final ReaderThread thread = (ReaderThread) threadMap.get(threadName);
+        assertNotNull(thread, "getInput()");
+        return thread.getData();
+    }
+
+    private String getOutput(final String threadName) {
+        final ByteArrayOutputStream output = outputMap.get(threadName);
+        assertNotNull(output, "getOutput()");
+        return output.toString(StandardCharsets.UTF_8);
+    }
+
+    private void startReader(final String name, final String data, final DemuxInputStream demux) {
+        final InputStream input = new StringInputStream(data);
+        final ReaderThread thread = new ReaderThread(name, input, demux);
+        threadMap.put(name, thread);
+    }
+
+    private void startWriter(final String name, final String data, final DemuxOutputStream demux) {
+        final ByteArrayOutputStream output = new ByteArrayOutputStream();
+        outputMap.put(name, output);
+        final WriterThread thread = new WriterThread(name, data, output, demux);
+        threadMap.put(name, thread);
+    }
+
+    @Test
+    public void testInputStream() throws Exception {
+        try (final DemuxInputStream input = new DemuxInputStream()) {
+            startReader(T1, DATA1, input);
+            startReader(T2, DATA2, input);
+            startReader(T3, DATA3, input);
+            startReader(T4, DATA4, input);
+
+            doStart();
+            doJoin();
+
+            assertEquals(DATA1, getInput(T1), "Data1");
+            assertEquals(DATA2, getInput(T2), "Data2");
+            assertEquals(DATA3, getInput(T3), "Data3");
+            assertEquals(DATA4, getInput(T4), "Data4");
+        }
+    }
+
+    @Test
+    public void testOutputStream() throws Exception {
+        try (final DemuxOutputStream output = new DemuxOutputStream()) {
+            startWriter(T1, DATA1, output);
+            startWriter(T2, DATA2, output);
+            startWriter(T3, DATA3, output);
+            startWriter(T4, DATA4, output);
+
+            doStart();
+            doJoin();
+
+            assertEquals(DATA1, getOutput(T1), "Data1");
+            assertEquals(DATA2, getOutput(T2), "Data2");
+            assertEquals(DATA3, getOutput(T3), "Data3");
+            assertEquals(DATA4, getOutput(T4), "Data4");
+        }
+    }
+
+    @Test
+    public void testReadEOF() throws Exception {
+        try (final DemuxInputStream input = new DemuxInputStream()) {
+            assertEquals(IOUtils.EOF, input.read());
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/DirectoryWalkerTest.java b/src/test/java/org/apache/commons/io/DirectoryWalkerTest.java
new file mode 100644
index 0000000..5fc0b1b
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/DirectoryWalkerTest.java
@@ -0,0 +1,564 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.commons.io.filefilter.FileFilterUtils;
+import org.apache.commons.io.filefilter.IOFileFilter;
+import org.apache.commons.io.filefilter.NameFileFilter;
+import org.junit.jupiter.api.Test;
+
+/**
+ * This is used to test DirectoryWalker for correctness.
+ *
+ * @see DirectoryWalker
+ *
+ */
+public class DirectoryWalkerTest {
+
+    /**
+     * Test DirectoryWalker implementation that finds files in a directory hierarchy
+     * applying a file filter.
+     */
+    static class TestCancelWalker extends DirectoryWalker<File> {
+        private final String cancelFileName;
+        private final boolean suppressCancel;
+
+        TestCancelWalker(final String cancelFileName,final boolean suppressCancel) {
+            this.cancelFileName = cancelFileName;
+            this.suppressCancel = suppressCancel;
+        }
+
+        /** find files. */
+        protected List<File> find(final File startDirectory) throws IOException {
+           final List<File> results = new ArrayList<>();
+           walk(startDirectory, results);
+           return results;
+        }
+
+        /** Handles Cancel. */
+        @Override
+        protected void handleCancelled(final File startDirectory, final Collection<File> results,
+                       final CancelException cancel) throws IOException {
+            if (!suppressCancel) {
+                super.handleCancelled(startDirectory, results, cancel);
+            }
+        }
+
+        /** Handles a directory end by adding the File to the result set. */
+        @Override
+        protected void handleDirectoryEnd(final File directory, final int depth, final Collection<File> results) throws IOException {
+            results.add(directory);
+            if (cancelFileName.equals(directory.getName())) {
+                throw new CancelException(directory, depth);
+            }
+        }
+
+        /** Handles a file by adding the File to the result set. */
+        @Override
+        protected void handleFile(final File file, final int depth, final Collection<File> results) throws IOException {
+            results.add(file);
+            if (cancelFileName.equals(file.getName())) {
+                throw new CancelException(file, depth);
+            }
+        }
+    }
+    /**
+     * Test DirectoryWalker implementation that always returns false
+     * from handleDirectoryStart()
+     */
+    private static class TestFalseFileFinder extends TestFileFinder {
+
+        protected TestFalseFileFinder(final FileFilter filter, final int depthLimit) {
+            super(filter, depthLimit);
+        }
+
+        /** Always returns false. */
+        @Override
+        protected boolean handleDirectory(final File directory, final int depth, final Collection<File> results) {
+            return false;
+        }
+    }
+    /**
+     * Test DirectoryWalker implementation that finds files in a directory hierarchy
+     * applying a file filter.
+     */
+    private static class TestFileFinder extends DirectoryWalker<File> {
+
+        protected TestFileFinder(final FileFilter filter, final int depthLimit) {
+            super(filter, depthLimit);
+        }
+
+        protected TestFileFinder(final IOFileFilter dirFilter, final IOFileFilter fileFilter, final int depthLimit) {
+            super(dirFilter, fileFilter, depthLimit);
+        }
+
+        /** find files. */
+        protected List<File> find(final File startDirectory) {
+           final List<File> results = new ArrayList<>();
+           try {
+               walk(startDirectory, results);
+           } catch(final IOException ex) {
+               fail(ex.toString());
+           }
+           return results;
+        }
+
+        /** Handles a directory end by adding the File to the result set. */
+        @Override
+        protected void handleDirectoryEnd(final File directory, final int depth, final Collection<File> results) {
+            results.add(directory);
+        }
+
+        /** Handles a file by adding the File to the result set. */
+        @Override
+        protected void handleFile(final File file, final int depth, final Collection<File> results) {
+            results.add(file);
+        }
+    }
+    /**
+     * Test DirectoryWalker implementation that finds files in a directory hierarchy
+     * applying a file filter.
+     */
+    private static class TestFileFinderString extends DirectoryWalker<String> {
+
+        protected TestFileFinderString(final FileFilter filter, final int depthLimit) {
+            super(filter, depthLimit);
+        }
+
+        /** find files. */
+        protected List<String> find(final File startDirectory) {
+           final List<String> results = new ArrayList<>();
+           try {
+               walk(startDirectory, results);
+           } catch(final IOException ex) {
+               fail(ex.toString());
+           }
+           return results;
+        }
+
+        /** Handles a file by adding the File to the result set. */
+        @Override
+        protected void handleFile(final File file, final int depth, final Collection<String> results) {
+            results.add(file.toString());
+        }
+    }
+    /**
+     * Test DirectoryWalker implementation that finds files in a directory hierarchy
+     * applying a file filter.
+     */
+    static class TestMultiThreadCancelWalker extends DirectoryWalker<File> {
+        private final String cancelFileName;
+        private final boolean suppressCancel;
+        private boolean cancelled;
+        public List<File> results;
+
+        TestMultiThreadCancelWalker(final String cancelFileName, final boolean suppressCancel) {
+            this.cancelFileName = cancelFileName;
+            this.suppressCancel = suppressCancel;
+        }
+
+        /** find files. */
+        protected List<File> find(final File startDirectory) throws IOException {
+           results = new ArrayList<>();
+           walk(startDirectory, results);
+           return results;
+        }
+
+        /** Handles Cancel. */
+        @Override
+        protected void handleCancelled(final File startDirectory, final Collection<File> results,
+                       final CancelException cancel) throws IOException {
+            if (!suppressCancel) {
+                super.handleCancelled(startDirectory, results, cancel);
+            }
+        }
+
+        /** Handles a directory end by adding the File to the result set. */
+        @Override
+        protected void handleDirectoryEnd(final File directory, final int depth, final Collection<File> results) throws IOException {
+            results.add(directory);
+            assertFalse(cancelled);
+            if (cancelFileName.equals(directory.getName())) {
+                cancelled = true;
+            }
+        }
+
+        /** Handles a file by adding the File to the result set. */
+        @Override
+        protected void handleFile(final File file, final int depth, final Collection<File> results) throws IOException {
+            results.add(file);
+            assertFalse(cancelled);
+            if (cancelFileName.equals(file.getName())) {
+                cancelled = true;
+            }
+        }
+
+        /** Handles Cancelled. */
+        @Override
+        protected boolean handleIsCancelled(final File file, final int depth, final Collection<File> results) throws IOException {
+            return cancelled;
+        }
+    }
+    // Directories
+    private static final File current      = FileUtils.current();
+    private static final File javaDir      = new File("src/main/java");
+    private static final File orgDir       = new File(javaDir, "org");
+
+    private static final File apacheDir    = new File(orgDir, "apache");
+    private static final File commonsDir   = new File(apacheDir, "commons");
+    private static final File ioDir        = new File(commonsDir, "io");
+    private static final File outputDir    = new File(ioDir, "output");
+    private static final File[] dirs       = {orgDir, apacheDir, commonsDir, ioDir, outputDir};
+    // Files
+    private static final File filenameUtils = new File(ioDir, "FilenameUtils.java");
+
+    private static final File ioUtils       = new File(ioDir, "IOUtils.java");
+    private static final File proxyWriter   = new File(outputDir, "ProxyWriter.java");
+    private static final File nullStream    = new File(outputDir, "NullOutputStream.java");
+    private static final File[] ioFiles     = {filenameUtils, ioUtils};
+    private static final File[] outputFiles = {proxyWriter, nullStream};
+
+    // Filters
+    private static final IOFileFilter dirsFilter        = createNameFilter(dirs);
+
+
+    private static final IOFileFilter ioFilesFilter = createNameFilter(ioFiles);
+
+    private static final IOFileFilter outputFilesFilter = createNameFilter(outputFiles);
+
+    private static final IOFileFilter ioDirAndFilesFilter = dirsFilter.or(ioFilesFilter);
+
+    private static final IOFileFilter dirsAndFilesFilter = ioDirAndFilesFilter.or(outputFilesFilter);
+
+    // Filter to exclude SVN files
+    private static final IOFileFilter NOT_SVN = FileFilterUtils.makeSVNAware(null);
+
+    /**
+     * Create a name filter containing the names of the files
+     * in the array.
+     */
+    private static IOFileFilter createNameFilter(final File[] files) {
+        final String[] names = new String[files.length];
+        for (int i = 0; i < files.length; i++) {
+            names[i] = files[i].getName();
+        }
+        return new NameFileFilter(names);
+    }
+
+    /**
+     * Check the files in the array are in the results list.
+     */
+    private void checkContainsFiles(final String prefix, final File[] files, final Collection<File> results) {
+        for (int i = 0; i < files.length; i++) {
+            assertTrue(results.contains(files[i]), prefix + "["+i+"] " + files[i]);
+        }
+    }
+
+    private void checkContainsString(final String prefix, final File[] files, final Collection<String> results) {
+        for (int i = 0; i < files.length; i++) {
+            assertTrue(results.contains(files[i].toString()), prefix + "["+i+"] " + files[i]);
+        }
+    }
+
+    /**
+     * Extract the directories.
+     */
+    private List<File> directoriesOnly(final Collection<File> results) {
+        final List<File> list = new ArrayList<>(results.size());
+        for (final File file : results) {
+            if (file.isDirectory()) {
+                list.add(file);
+            }
+        }
+        return list;
+    }
+
+    /**
+     * Extract the files.
+     */
+    private List<File> filesOnly(final Collection<File> results) {
+        final List<File> list = new ArrayList<>(results.size());
+        for (final File file : results) {
+            if (file.isFile()) {
+                list.add(file);
+            }
+        }
+        return list;
+    }
+
+    /**
+     * Test Cancel
+     */
+    @Test
+    public void testCancel() {
+        String cancelName = null;
+
+        // Cancel on a file
+        try {
+            cancelName = "DirectoryWalker.java";
+            new TestCancelWalker(cancelName, false).find(javaDir);
+            fail("CancelException not thrown for '" + cancelName + "'");
+        } catch (final DirectoryWalker.CancelException cancel) {
+            assertEquals(cancelName, cancel.getFile().getName(), "File:  " + cancelName);
+            assertEquals(5, cancel.getDepth(), "Depth: " + cancelName);
+        } catch(final IOException ex) {
+            fail("IOException: " + cancelName + " " + ex);
+        }
+
+        // Cancel on a directory
+        try {
+            cancelName = "commons";
+            new TestCancelWalker(cancelName, false).find(javaDir);
+            fail("CancelException not thrown for '" + cancelName + "'");
+        } catch (final DirectoryWalker.CancelException cancel) {
+            assertEquals(cancelName, cancel.getFile().getName(), "File:  " + cancelName);
+            assertEquals(3, cancel.getDepth(), "Depth: " + cancelName);
+        } catch(final IOException ex) {
+            fail("IOException: " + cancelName + " " + ex);
+        }
+
+        // Suppress CancelException (use same file name as preceding test)
+        try {
+            final List<File> results = new TestCancelWalker(cancelName, true).find(javaDir);
+            final File lastFile = results.get(results.size() - 1);
+            assertEquals(cancelName, lastFile.getName(), "Suppress:  " + cancelName);
+        } catch(final IOException ex) {
+            fail("Suppress threw " + ex);
+        }
+
+    }
+
+    /**
+     * Test Filtering
+     */
+    @Test
+    public void testFilter() {
+        final List<File> results = new TestFileFinder(dirsAndFilesFilter, -1).find(javaDir);
+        assertEquals(1 + dirs.length + ioFiles.length + outputFiles.length, results.size(), "Result Size");
+        assertTrue(results.contains(javaDir), "Start Dir");
+        checkContainsFiles("Dir", dirs, results);
+        checkContainsFiles("IO File", ioFiles, results);
+        checkContainsFiles("Output File", outputFiles, results);
+    }
+
+    // ------------ Convenience Test Methods ------------------------------------
+
+    /**
+     * Test Filtering and limit to depth 0
+     */
+    @Test
+    public void testFilterAndLimitA() {
+        final List<File> results = new TestFileFinder(NOT_SVN, 0).find(javaDir);
+        assertEquals(1, results.size(), "[A] Result Size");
+        assertTrue(results.contains(javaDir), "[A] Start Dir");
+    }
+
+    /**
+     * Test Filtering and limit to depth 1
+     */
+    @Test
+    public void testFilterAndLimitB() {
+        final List<File> results = new TestFileFinder(NOT_SVN, 1).find(javaDir);
+        assertEquals(2, results.size(), "[B] Result Size");
+        assertTrue(results.contains(javaDir), "[B] Start Dir");
+        assertTrue(results.contains(orgDir), "[B] Org Dir");
+    }
+
+    /**
+     * Test Filtering and limit to depth 3
+     */
+    @Test
+    public void testFilterAndLimitC() {
+        final List<File> results = new TestFileFinder(NOT_SVN, 3).find(javaDir);
+        assertEquals(4, results.size(), "[C] Result Size");
+        assertTrue(results.contains(javaDir), "[C] Start Dir");
+        assertTrue(results.contains(orgDir), "[C] Org Dir");
+        assertTrue(results.contains(apacheDir), "[C] Apache Dir");
+        assertTrue(results.contains(commonsDir), "[C] Commons Dir");
+    }
+
+    /**
+     * Test Filtering and limit to depth 5
+     */
+    @Test
+    public void testFilterAndLimitD() {
+        final List<File> results = new TestFileFinder(dirsAndFilesFilter, 5).find(javaDir);
+        assertEquals(1 + dirs.length + ioFiles.length, results.size(), "[D] Result Size");
+        assertTrue(results.contains(javaDir), "[D] Start Dir");
+        checkContainsFiles("[D] Dir", dirs, results);
+        checkContainsFiles("[D] File", ioFiles, results);
+    }
+
+    /**
+     * Test separate dir and file filters
+     */
+    @Test
+    public void testFilterDirAndFile1() {
+        final List<File> results = new TestFileFinder(dirsFilter, ioFilesFilter, -1).find(javaDir);
+        assertEquals(1 + dirs.length + ioFiles.length, results.size(), "[DirAndFile1] Result Size");
+        assertTrue(results.contains(javaDir), "[DirAndFile1] Start Dir");
+        checkContainsFiles("[DirAndFile1] Dir", dirs, results);
+        checkContainsFiles("[DirAndFile1] File", ioFiles, results);
+    }
+
+    /**
+     * Test separate dir and file filters
+     */
+    @Test
+    public void testFilterDirAndFile2() {
+        final List<File> results = new TestFileFinder(null, null, -1).find(javaDir);
+        assertTrue(results.size() > 1 + dirs.length + ioFiles.length, "[DirAndFile2] Result Size");
+        assertTrue(results.contains(javaDir), "[DirAndFile2] Start Dir");
+        checkContainsFiles("[DirAndFile2] Dir", dirs, results);
+        checkContainsFiles("[DirAndFile2] File", ioFiles, results);
+    }
+
+    /**
+     * Test separate dir and file filters
+     */
+    @Test
+    public void testFilterDirAndFile3() {
+        final List<File> results = new TestFileFinder(dirsFilter, null, -1).find(javaDir);
+        final List<File> resultDirs = directoriesOnly(results);
+        assertEquals(1 + dirs.length, resultDirs.size(), "[DirAndFile3] Result Size");
+        assertTrue(results.contains(javaDir), "[DirAndFile3] Start Dir");
+        checkContainsFiles("[DirAndFile3] Dir", dirs, resultDirs);
+    }
+
+    /**
+     * Test separate dir and file filters
+     */
+    @Test
+    public void testFilterDirAndFile4() {
+        final List<File> results = new TestFileFinder(null, ioFilesFilter, -1).find(javaDir);
+        final List<File> resultFiles = filesOnly(results);
+        assertEquals(ioFiles.length, resultFiles.size(), "[DirAndFile4] Result Size");
+        assertTrue(results.contains(javaDir), "[DirAndFile4] Start Dir");
+        checkContainsFiles("[DirAndFile4] File", ioFiles, resultFiles);
+    }
+
+    // ------------ Test DirectoryWalker implementation --------------------------
+
+    /**
+     * Test Filtering
+     */
+    @Test
+    public void testFilterString() {
+        final List<String> results = new TestFileFinderString(dirsAndFilesFilter, -1).find(javaDir);
+        assertEquals(results.size(), outputFiles.length + ioFiles.length, "Result Size");
+        checkContainsString("IO File", ioFiles, results);
+        checkContainsString("Output File", outputFiles, results);
+    }
+
+    // ------------ Test DirectoryWalker implementation --------------------------
+
+    /**
+     * test an invalid start directory
+     */
+    @Test
+    public void testHandleStartDirectoryFalse() {
+
+        final List<File> results = new TestFalseFileFinder(null, -1).find(current);
+        assertEquals(0, results.size(), "Result Size");
+
+    }
+
+    // ------------ Test DirectoryWalker implementation --------------------------
+
+    /**
+     * Test Limiting to current directory
+     */
+    @Test
+    public void testLimitToCurrent() {
+        final List<File> results = new TestFileFinder(null, 0).find(current);
+        assertEquals(1, results.size(), "Result Size");
+        assertTrue(results.contains(FileUtils.current()), "Current Dir");
+    }
+
+    /**
+     * test an invalid start directory
+     */
+    @Test
+    public void testMissingStartDirectory() {
+
+        // TODO is this what we want with invalid directory?
+        final File invalidDir = new File("invalid-dir");
+        final List<File> results = new TestFileFinder(null, -1).find(invalidDir);
+        assertEquals(1, results.size(), "Result Size");
+        assertTrue(results.contains(invalidDir), "Current Dir");
+
+        assertThrows(NullPointerException.class, () -> new TestFileFinder(null, -1).find(null));
+    }
+
+    /**
+     * Test Cancel
+     */
+    @Test
+    public void testMultiThreadCancel() {
+        String cancelName = "DirectoryWalker.java";
+        TestMultiThreadCancelWalker walker = new TestMultiThreadCancelWalker(cancelName, false);
+        // Cancel on a file
+        try {
+            walker.find(javaDir);
+            fail("CancelException not thrown for '" + cancelName + "'");
+        } catch (final DirectoryWalker.CancelException cancel) {
+            final File last = walker.results.get(walker.results.size() - 1);
+            assertEquals(cancelName, last.getName());
+            assertEquals(5, cancel.getDepth(), "Depth: " + cancelName);
+        } catch(final IOException ex) {
+            fail("IOException: " + cancelName + " " + ex);
+        }
+
+        // Cancel on a directory
+        try {
+            cancelName = "commons";
+            walker = new TestMultiThreadCancelWalker(cancelName, false);
+            walker.find(javaDir);
+            fail("CancelException not thrown for '" + cancelName + "'");
+        } catch (final DirectoryWalker.CancelException cancel) {
+            assertEquals(cancelName, cancel.getFile().getName(), "File:  " + cancelName);
+            assertEquals(3, cancel.getDepth(), "Depth: " + cancelName);
+        } catch(final IOException ex) {
+            fail("IOException: " + cancelName + " " + ex);
+        }
+
+        // Suppress CancelException (use same file name as preceding test)
+        try {
+            walker = new TestMultiThreadCancelWalker(cancelName, true);
+            final List<File> results = walker.find(javaDir);
+            final File lastFile = results.get(results.size() - 1);
+            assertEquals(cancelName, lastFile.getName(), "Suppress:  " + cancelName);
+        } catch(final IOException ex) {
+            fail("Suppress threw " + ex);
+        }
+
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/DirectoryWalkerTestCaseJava4.java b/src/test/java/org/apache/commons/io/DirectoryWalkerTestCaseJava4.java
new file mode 100644
index 0000000..f55699a
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/DirectoryWalkerTestCaseJava4.java
@@ -0,0 +1,525 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.commons.io.filefilter.FileFilterUtils;
+import org.apache.commons.io.filefilter.IOFileFilter;
+import org.apache.commons.io.filefilter.NameFileFilter;
+import org.apache.commons.io.filefilter.OrFileFilter;
+import org.junit.jupiter.api.Test;
+
+/**
+ * This is used to test DirectoryWalker for correctness when using Java4 (i.e. no generics).
+ *
+ * @see DirectoryWalker
+ */
+@SuppressWarnings({"unchecked", "rawtypes"}) // Java4
+public class DirectoryWalkerTestCaseJava4 {
+
+    /**
+     * Test DirectoryWalker implementation that finds files in a directory hierarchy
+     * applying a file filter.
+     */
+    static class TestCancelWalker extends DirectoryWalker {
+        private final String cancelFileName;
+        private final boolean suppressCancel;
+
+        TestCancelWalker(final String cancelFileName, final boolean suppressCancel) {
+            this.cancelFileName = cancelFileName;
+            this.suppressCancel = suppressCancel;
+        }
+
+        /**
+         * find files.
+         */
+        protected List find(final File startDirectory) throws IOException {
+            final List results = new ArrayList();
+            walk(startDirectory, results);
+            return results;
+        }
+
+        /**
+         * Handles Cancel.
+         */
+        @Override
+        protected void handleCancelled(final File startDirectory, final Collection results,
+                                       final CancelException cancel) throws IOException {
+            if (!suppressCancel) {
+                super.handleCancelled(startDirectory, results, cancel);
+            }
+        }
+
+        /**
+         * Handles a directory end by adding the File to the result set.
+         */
+        @Override
+        protected void handleDirectoryEnd(final File directory, final int depth, final Collection results) throws IOException {
+            results.add(directory);
+            if (cancelFileName.equals(directory.getName())) {
+                throw new CancelException(directory, depth);
+            }
+        }
+
+        /**
+         * Handles a file by adding the File to the result set.
+         */
+        @Override
+        protected void handleFile(final File file, final int depth, final Collection results) throws IOException {
+            results.add(file);
+            if (cancelFileName.equals(file.getName())) {
+                throw new CancelException(file, depth);
+            }
+        }
+    }
+    /**
+     * Test DirectoryWalker implementation that always returns false
+     * from handleDirectoryStart()
+     */
+    private static class TestFalseFileFinder extends TestFileFinder {
+
+        protected TestFalseFileFinder(final FileFilter filter, final int depthLimit) {
+            super(filter, depthLimit);
+        }
+
+        /**
+         * Always returns false.
+         */
+        @Override
+        protected boolean handleDirectory(final File directory, final int depth, final Collection results) {
+            return false;
+        }
+    }
+    /**
+     * Test DirectoryWalker implementation that finds files in a directory hierarchy
+     * applying a file filter.
+     */
+    private static class TestFileFinder extends DirectoryWalker {
+
+        protected TestFileFinder(final FileFilter filter, final int depthLimit) {
+            super(filter, depthLimit);
+        }
+
+        protected TestFileFinder(final IOFileFilter dirFilter, final IOFileFilter fileFilter, final int depthLimit) {
+            super(dirFilter, fileFilter, depthLimit);
+        }
+
+        /**
+         * find files.
+         */
+        protected List<File> find(final File startDirectory) {
+            final List<File> results = new ArrayList<>();
+            try {
+                walk(startDirectory, results);
+            } catch (final IOException ex) {
+                fail(ex.toString());
+            }
+            return results;
+        }
+
+        /**
+         * Handles a directory end by adding the File to the result set.
+         */
+        @Override
+        protected void handleDirectoryEnd(final File directory, final int depth, final Collection results) {
+            results.add(directory);
+        }
+
+        /**
+         * Handles a file by adding the File to the result set.
+         */
+        @Override
+        protected void handleFile(final File file, final int depth, final Collection results) {
+            results.add(file);
+        }
+    }
+    /**
+     * Test DirectoryWalker implementation that finds files in a directory hierarchy
+     * applying a file filter.
+     */
+    static class TestMultiThreadCancelWalker extends DirectoryWalker {
+        private final String cancelFileName;
+        private final boolean suppressCancel;
+        private boolean cancelled;
+        public List results;
+
+        TestMultiThreadCancelWalker(final String cancelFileName, final boolean suppressCancel) {
+            this.cancelFileName = cancelFileName;
+            this.suppressCancel = suppressCancel;
+        }
+
+        /**
+         * find files.
+         */
+        protected List find(final File startDirectory) throws IOException {
+            results = new ArrayList();
+            walk(startDirectory, results);
+            return results;
+        }
+
+        /**
+         * Handles Cancel.
+         */
+        @Override
+        protected void handleCancelled(final File startDirectory, final Collection results,
+                                       final CancelException cancel) throws IOException {
+            if (!suppressCancel) {
+                super.handleCancelled(startDirectory, results, cancel);
+            }
+        }
+
+        /**
+         * Handles a directory end by adding the File to the result set.
+         */
+        @Override
+        protected void handleDirectoryEnd(final File directory, final int depth, final Collection results) throws IOException {
+            results.add(directory);
+            assertFalse(cancelled);
+            if (cancelFileName.equals(directory.getName())) {
+                cancelled = true;
+            }
+        }
+
+        /**
+         * Handles a file by adding the File to the result set.
+         */
+        @Override
+        protected void handleFile(final File file, final int depth, final Collection results) throws IOException {
+            results.add(file);
+            assertFalse(cancelled);
+            if (cancelFileName.equals(file.getName())) {
+                cancelled = true;
+            }
+        }
+
+        /**
+         * Handles Cancelled.
+         */
+        @Override
+        protected boolean handleIsCancelled(final File file, final int depth, final Collection results) throws IOException {
+            return cancelled;
+        }
+    }
+    // Directories
+    private static final File current = FileUtils.current();
+    private static final File javaDir = new File("src/main/java");
+    private static final File orgDir = new File(javaDir, "org");
+    private static final File apacheDir = new File(orgDir, "apache");
+
+    private static final File commonsDir = new File(apacheDir, "commons");
+    private static final File ioDir = new File(commonsDir, "io");
+    private static final File outputDir = new File(ioDir, "output");
+    private static final File[] dirs = {orgDir, apacheDir, commonsDir, ioDir, outputDir};
+    // Files
+    private static final File filenameUtils = new File(ioDir, "FilenameUtils.java");
+    private static final File ioUtils = new File(ioDir, "IOUtils.java");
+
+    private static final File proxyWriter = new File(outputDir, "ProxyWriter.java");
+    private static final File nullStream = new File(outputDir, "NullOutputStream.java");
+    private static final File[] ioFiles = {filenameUtils, ioUtils};
+    private static final File[] outputFiles = {proxyWriter, nullStream};
+    // Filters
+    private static final IOFileFilter dirsFilter = createNameFilter(dirs);
+
+    private static final IOFileFilter ioFilesFilter = createNameFilter(ioFiles);
+
+
+    private static final IOFileFilter outputFilesFilter = createNameFilter(outputFiles);
+
+    private static final IOFileFilter ioDirAndFilesFilter = new OrFileFilter(dirsFilter, ioFilesFilter);
+
+    private static final IOFileFilter dirsAndFilesFilter = new OrFileFilter(ioDirAndFilesFilter, outputFilesFilter);
+
+    // Filter to exclude SVN files
+    private static final IOFileFilter NOT_SVN = FileFilterUtils.makeSVNAware(null);
+
+    /**
+     * Create a name filter containing the names of the files
+     * in the array.
+     */
+    private static IOFileFilter createNameFilter(final File[] files) {
+        final String[] names = new String[files.length];
+        for (int i = 0; i < files.length; i++) {
+            names[i] = files[i].getName();
+        }
+        return new NameFileFilter(names);
+    }
+
+    /**
+     * Check the files in the array are in the results list.
+     */
+    private void checkContainsFiles(final String prefix, final File[] files, final Collection results) {
+        for (int i = 0; i < files.length; i++) {
+            assertTrue(results.contains(files[i]), prefix + "[" + i + "] " + files[i]);
+        }
+    }
+
+    /**
+     * Extract the directories.
+     */
+    private List directoriesOnly(final Collection<File> results) {
+        final List list = new ArrayList(results.size());
+        for (final File file : results) {
+            if (file.isDirectory()) {
+                list.add(file);
+            }
+        }
+        return list;
+    }
+
+    /**
+     * Extract the files.
+     */
+    private List filesOnly(final Collection<File> results) {
+        final List list = new ArrayList(results.size());
+        for (final File file : results) {
+            if (file.isFile()) {
+                list.add(file);
+            }
+        }
+        return list;
+    }
+
+    /**
+     * Test Cancel
+     * @throws IOException
+     */
+    @Test
+    public void testCancel() throws IOException {
+        String cancelName = null;
+
+        // Cancel on a file
+        try {
+            cancelName = "DirectoryWalker.java";
+            new TestCancelWalker(cancelName, false).find(javaDir);
+            fail("CancelException not thrown for '" + cancelName + "'");
+        } catch (final DirectoryWalker.CancelException cancel) {
+            assertEquals(cancelName, cancel.getFile().getName(), "File:  " + cancelName);
+            assertEquals(5, cancel.getDepth(), "Depth: " + cancelName);
+        }
+
+        // Cancel on a directory
+        try {
+            cancelName = "commons";
+            new TestCancelWalker(cancelName, false).find(javaDir);
+            fail("CancelException not thrown for '" + cancelName + "'");
+        } catch (final DirectoryWalker.CancelException cancel) {
+            assertEquals(cancelName, cancel.getFile().getName(), "File:  " + cancelName);
+            assertEquals(3, cancel.getDepth(), "Depth: " + cancelName);
+        }
+
+        // Suppress CancelException (use same file name as preceding test)
+        final List results = new TestCancelWalker(cancelName, true).find(javaDir);
+        final File lastFile = (File) results.get(results.size() - 1);
+        assertEquals(cancelName, lastFile.getName(), "Suppress:  " + cancelName);
+    }
+
+    /**
+     * Test Filtering
+     */
+    @Test
+    public void testFilter() {
+        final List<File> results = new TestFileFinder(dirsAndFilesFilter, -1).find(javaDir);
+        assertEquals(1 + dirs.length + ioFiles.length + outputFiles.length, results.size(), "Result Size");
+        assertTrue(results.contains(javaDir), "Start Dir");
+        checkContainsFiles("Dir", dirs, results);
+        checkContainsFiles("IO File", ioFiles, results);
+        checkContainsFiles("Output File", outputFiles, results);
+    }
+
+    /**
+     * Test Filtering and limit to depth 0
+     */
+    @Test
+    public void testFilterAndLimitA() {
+        final List<File> results = new TestFileFinder(NOT_SVN, 0).find(javaDir);
+        assertEquals(1, results.size(), "[A] Result Size");
+        assertTrue(results.contains(javaDir), "[A] Start Dir");
+    }
+
+    /**
+     * Test Filtering and limit to depth 1
+     */
+    @Test
+    public void testFilterAndLimitB() {
+        final List<File> results = new TestFileFinder(NOT_SVN, 1).find(javaDir);
+        assertEquals(2, results.size(), "[B] Result Size");
+        assertTrue(results.contains(javaDir), "[B] Start Dir");
+        assertTrue(results.contains(orgDir), "[B] Org Dir");
+    }
+
+    // ------------ Convenience Test Methods ------------------------------------
+
+    /**
+     * Test Filtering and limit to depth 3
+     */
+    @Test
+    public void testFilterAndLimitC() {
+        final List<File> results = new TestFileFinder(NOT_SVN, 3).find(javaDir);
+        assertEquals(4, results.size(), "[C] Result Size");
+        assertTrue(results.contains(javaDir), "[C] Start Dir");
+        assertTrue(results.contains(orgDir), "[C] Org Dir");
+        assertTrue(results.contains(apacheDir), "[C] Apache Dir");
+        assertTrue(results.contains(commonsDir), "[C] Commons Dir");
+    }
+
+    /**
+     * Test Filtering and limit to depth 5
+     */
+    @Test
+    public void testFilterAndLimitD() {
+        final List<File> results = new TestFileFinder(dirsAndFilesFilter, 5).find(javaDir);
+        assertEquals(1 + dirs.length + ioFiles.length, results.size(), "[D] Result Size");
+        assertTrue(results.contains(javaDir), "[D] Start Dir");
+        checkContainsFiles("[D] Dir", dirs, results);
+        checkContainsFiles("[D] File", ioFiles, results);
+    }
+
+    /**
+     * Test separate dir and file filters
+     */
+    @Test
+    public void testFilterDirAndFile1() {
+        final List<File> results = new TestFileFinder(dirsFilter, ioFilesFilter, -1).find(javaDir);
+        assertEquals(1 + dirs.length + ioFiles.length, results.size(), "[DirAndFile1] Result Size");
+        assertTrue(results.contains(javaDir), "[DirAndFile1] Start Dir");
+        checkContainsFiles("[DirAndFile1] Dir", dirs, results);
+        checkContainsFiles("[DirAndFile1] File", ioFiles, results);
+    }
+
+    /**
+     * Test separate dir and file filters
+     */
+    @Test
+    public void testFilterDirAndFile2() {
+        final List<File> results = new TestFileFinder(null, null, -1).find(javaDir);
+        assertTrue(results.size() > 1 + dirs.length + ioFiles.length, "[DirAndFile2] Result Size");
+        assertTrue(results.contains(javaDir), "[DirAndFile2] Start Dir");
+        checkContainsFiles("[DirAndFile2] Dir", dirs, results);
+        checkContainsFiles("[DirAndFile2] File", ioFiles, results);
+    }
+
+    /**
+     * Test separate dir and file filters
+     */
+    @Test
+    public void testFilterDirAndFile3() {
+        final List<File> results = new TestFileFinder(dirsFilter, null, -1).find(javaDir);
+        final List resultDirs = directoriesOnly(results);
+        assertEquals(1 + dirs.length, resultDirs.size(), "[DirAndFile3] Result Size");
+        assertTrue(results.contains(javaDir), "[DirAndFile3] Start Dir");
+        checkContainsFiles("[DirAndFile3] Dir", dirs, resultDirs);
+    }
+
+    /**
+     * Test separate dir and file filters
+     */
+    @Test
+    public void testFilterDirAndFile4() {
+        final List<File> results = new TestFileFinder(null, ioFilesFilter, -1).find(javaDir);
+        final List resultFiles = filesOnly(results);
+        assertEquals(ioFiles.length, resultFiles.size(), "[DirAndFile4] Result Size");
+        assertTrue(results.contains(javaDir), "[DirAndFile4] Start Dir");
+        checkContainsFiles("[DirAndFile4] File", ioFiles, resultFiles);
+    }
+
+    /**
+     * test an invalid start directory
+     */
+    @Test
+    public void testHandleStartDirectoryFalse() {
+
+        final List<File> results = new TestFalseFileFinder(null, -1).find(current);
+        assertEquals(0, results.size(), "Result Size");
+
+    }
+
+    /**
+     * Test Limiting to current directory
+     */
+    @Test
+    public void testLimitToCurrent() {
+        final List<File> results = new TestFileFinder(null, 0).find(current);
+        assertEquals(1, results.size(), "Result Size");
+        assertTrue(results.contains(FileUtils.current()), "Current Dir");
+    }
+
+    /**
+     * Test an invalid start directory
+     */
+    @Test
+    public void testMissingStartDirectory() {
+
+        // TODO is this what we want with invalid directory?
+        final File invalidDir = new File("invalid-dir");
+        final List<File> results = new TestFileFinder(null, -1).find(invalidDir);
+        assertEquals(1, results.size(), "Result Size");
+        assertTrue(results.contains(invalidDir), "Current Dir");
+
+        assertThrows(NullPointerException.class, () -> new TestFileFinder(null, -1).find(null));
+    }
+
+    /**
+     * Test Cancel
+     * @throws IOException
+     */
+    @Test
+    public void testMultiThreadCancel() throws IOException {
+        String cancelName = "DirectoryWalker.java";
+        TestMultiThreadCancelWalker walker = new TestMultiThreadCancelWalker(cancelName, false);
+        // Cancel on a file
+        try {
+            walker.find(javaDir);
+            fail("CancelException not thrown for '" + cancelName + "'");
+        } catch (final DirectoryWalker.CancelException cancel) {
+            final File last = (File) walker.results.get(walker.results.size() - 1);
+            assertEquals(cancelName, last.getName());
+            assertEquals(5, cancel.getDepth(), "Depth: " + cancelName);
+        }
+
+        // Cancel on a directory
+        try {
+            cancelName = "commons";
+            walker = new TestMultiThreadCancelWalker(cancelName, false);
+            walker.find(javaDir);
+            fail("CancelException not thrown for '" + cancelName + "'");
+        } catch (final DirectoryWalker.CancelException cancel) {
+            assertEquals(cancelName, cancel.getFile().getName(), "File:  " + cancelName);
+            assertEquals(3, cancel.getDepth(), "Depth: " + cancelName);
+        }
+
+        // Suppress CancelException (use same file name as preceding test)
+        walker = new TestMultiThreadCancelWalker(cancelName, true);
+        final List results = walker.find(javaDir);
+        final File lastFile = (File) results.get(results.size() - 1);
+        assertEquals(cancelName, lastFile.getName(), "Suppress:  " + cancelName);
+
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/EndianUtilsTest.java b/src/test/java/org/apache/commons/io/EndianUtilsTest.java
new file mode 100644
index 0000000..c49f333
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/EndianUtilsTest.java
@@ -0,0 +1,311 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.EOFException;
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ *
+ */
+public class EndianUtilsTest  {
+
+    @Test
+    public void testCtor() {
+        new EndianUtils();
+        // Constructor does not blow up.
+    }
+
+    @Test
+    public void testEOFException() throws IOException {
+        final ByteArrayInputStream input = new ByteArrayInputStream(new byte[] {});
+        assertThrows(EOFException.class, () -> EndianUtils.readSwappedDouble(input));
+    }
+
+    @Test
+    public void testReadSwappedDouble() throws IOException {
+        final byte[] bytes = { 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01 };
+        final double d1 = Double.longBitsToDouble( 0x0102030405060708L );
+        final double d2 = EndianUtils.readSwappedDouble( bytes, 0 );
+        assertEquals( d1, d2, 0.0 );
+
+        final ByteArrayInputStream input = new ByteArrayInputStream(bytes);
+        assertEquals( d1, EndianUtils.readSwappedDouble( input ), 0.0 );
+    }
+
+    @Test
+    public void testReadSwappedFloat() throws IOException {
+        final byte[] bytes = { 0x04, 0x03, 0x02, 0x01 };
+        final float f1 = Float.intBitsToFloat( 0x01020304 );
+        final float f2 = EndianUtils.readSwappedFloat( bytes, 0 );
+        assertEquals( f1, f2, 0.0 );
+
+        final ByteArrayInputStream input = new ByteArrayInputStream(bytes);
+        assertEquals( f1, EndianUtils.readSwappedFloat( input ), 0.0 );
+    }
+
+    @Test
+    public void testReadSwappedInteger() throws IOException {
+        final byte[] bytes = { 0x04, 0x03, 0x02, 0x01 };
+        assertEquals( 0x01020304, EndianUtils.readSwappedInteger( bytes, 0 ) );
+
+        final ByteArrayInputStream input = new ByteArrayInputStream(bytes);
+        assertEquals( 0x01020304, EndianUtils.readSwappedInteger( input ) );
+    }
+
+    @Test
+    public void testReadSwappedLong() throws IOException {
+        final byte[] bytes = { 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01 };
+        assertEquals( 0x0102030405060708L, EndianUtils.readSwappedLong( bytes, 0 ) );
+
+        final ByteArrayInputStream input = new ByteArrayInputStream(bytes);
+        assertEquals( 0x0102030405060708L, EndianUtils.readSwappedLong( input ) );
+    }
+
+    @Test
+    public void testReadSwappedShort() throws IOException {
+        final byte[] bytes = { 0x02, 0x01 };
+        assertEquals( 0x0102, EndianUtils.readSwappedShort( bytes, 0 ) );
+
+        final ByteArrayInputStream input = new ByteArrayInputStream(bytes);
+        assertEquals( 0x0102, EndianUtils.readSwappedShort( input ) );
+    }
+
+    @Test
+    public void testReadSwappedUnsignedInteger() throws IOException {
+        final byte[] bytes = { 0x04, 0x03, 0x02, 0x01 };
+        assertEquals( 0x0000000001020304L, EndianUtils.readSwappedUnsignedInteger( bytes, 0 ) );
+
+        final ByteArrayInputStream input = new ByteArrayInputStream(bytes);
+        assertEquals( 0x0000000001020304L, EndianUtils.readSwappedUnsignedInteger( input ) );
+    }
+
+    @Test
+    public void testReadSwappedUnsignedShort() throws IOException {
+        final byte[] bytes = { 0x02, 0x01 };
+        assertEquals( 0x00000102, EndianUtils.readSwappedUnsignedShort( bytes, 0 ) );
+
+        final ByteArrayInputStream input = new ByteArrayInputStream(bytes);
+        assertEquals( 0x00000102, EndianUtils.readSwappedUnsignedShort( input ) );
+    }
+
+    @Test
+    public void testSwapDouble() {
+        assertEquals( 0.0, EndianUtils.swapDouble( 0.0 ), 0.0 );
+        final double d1 = Double.longBitsToDouble( 0x0102030405060708L );
+        final double d2 = Double.longBitsToDouble( 0x0807060504030201L );
+        assertEquals( d2, EndianUtils.swapDouble( d1 ), 0.0 );
+    }
+
+    @Test
+    public void testSwapFloat() {
+        assertEquals( 0.0f, EndianUtils.swapFloat( 0.0f ), 0.0 );
+        final float f1 = Float.intBitsToFloat( 0x01020304 );
+        final float f2 = Float.intBitsToFloat( 0x04030201 );
+        assertEquals( f2, EndianUtils.swapFloat( f1 ), 0.0 );
+    }
+
+    @Test
+    public void testSwapInteger() {
+        assertEquals( 0, EndianUtils.swapInteger( 0 ) );
+        assertEquals( 0x04030201, EndianUtils.swapInteger( 0x01020304 ) );
+        assertEquals( 0x01000000, EndianUtils.swapInteger( 0x00000001 ) );
+        assertEquals( 0x00000001, EndianUtils.swapInteger( 0x01000000 ) );
+        assertEquals( 0x11111111, EndianUtils.swapInteger( 0x11111111 ) );
+        assertEquals( 0xabcdef10, EndianUtils.swapInteger( 0x10efcdab ) );
+        assertEquals( 0xab, EndianUtils.swapInteger( 0xab000000 ) );
+    }
+
+    @Test
+    public void testSwapLong() {
+        assertEquals( 0, EndianUtils.swapLong( 0 ) );
+        assertEquals( 0x0807060504030201L, EndianUtils.swapLong( 0x0102030405060708L ) );
+        assertEquals( 0xffffffffffffffffL, EndianUtils.swapLong( 0xffffffffffffffffL ) );
+        assertEquals( 0xab, EndianUtils.swapLong( 0xab00000000000000L ) );
+    }
+
+    @Test
+    public void testSwapShort() {
+        assertEquals( (short) 0, EndianUtils.swapShort( (short) 0 ) );
+        assertEquals( (short) 0x0201, EndianUtils.swapShort( (short) 0x0102 ) );
+        assertEquals( (short) 0xffff, EndianUtils.swapShort( (short) 0xffff ) );
+        assertEquals( (short) 0x0102, EndianUtils.swapShort( (short) 0x0201 ) );
+    }
+
+    /**
+     * Tests all swapXxxx methods for symmetry when going from one endian
+     * to another and back again.
+     */
+    @Test
+    public void testSymmetry() {
+        assertEquals( (short) 0x0102, EndianUtils.swapShort( EndianUtils.swapShort( (short) 0x0102 ) ) );
+        assertEquals( 0x01020304, EndianUtils.swapInteger( EndianUtils.swapInteger( 0x01020304 ) ) );
+        assertEquals( 0x0102030405060708L, EndianUtils.swapLong( EndianUtils.swapLong( 0x0102030405060708L ) ) );
+        final float f1 = Float.intBitsToFloat( 0x01020304 );
+        assertEquals( f1, EndianUtils.swapFloat( EndianUtils.swapFloat( f1 ) ), 0.0 );
+        final double d1 = Double.longBitsToDouble( 0x0102030405060708L );
+        assertEquals( d1, EndianUtils.swapDouble( EndianUtils.swapDouble( d1 ) ), 0.0 );
+    }
+
+    // tests #IO-101
+    @Test
+    public void testSymmetryOfLong() {
+
+        final double[] tests = {34.345, -345.5645, 545.12, 10.043, 7.123456789123};
+        for (final double test : tests) {
+
+            // testing the real problem
+            byte[] buffer = new byte[8];
+            final long ln1 = Double.doubleToLongBits( test );
+            EndianUtils.writeSwappedLong(buffer, 0, ln1);
+            final long ln2 = EndianUtils.readSwappedLong(buffer, 0);
+            assertEquals( ln1, ln2 );
+
+            // testing the bug report
+            buffer = new byte[8];
+            EndianUtils.writeSwappedDouble(buffer, 0, test);
+            final double val = EndianUtils.readSwappedDouble(buffer, 0);
+            assertEquals( test, val, 0 );
+        }
+    }
+
+    // tests #IO-117
+    @Test
+    public void testUnsignedOverrun() throws Exception {
+        final byte[] target = { 0, 0, 0, (byte)0x80 };
+        final long expected = 0x80000000L;
+
+        long actual = EndianUtils.readSwappedUnsignedInteger(target, 0);
+        assertEquals(expected, actual, "readSwappedUnsignedInteger(byte[], int) was incorrect");
+
+        final ByteArrayInputStream in = new ByteArrayInputStream(target);
+        actual = EndianUtils.readSwappedUnsignedInteger(in);
+        assertEquals(expected, actual, "readSwappedUnsignedInteger(InputStream) was incorrect");
+    }
+
+    @Test
+    public void testWriteSwappedDouble() throws IOException {
+        byte[] bytes = new byte[8];
+        final double d1 = Double.longBitsToDouble( 0x0102030405060708L );
+        EndianUtils.writeSwappedDouble( bytes, 0, d1 );
+        assertEquals( 0x08, bytes[0] );
+        assertEquals( 0x07, bytes[1] );
+        assertEquals( 0x06, bytes[2] );
+        assertEquals( 0x05, bytes[3] );
+        assertEquals( 0x04, bytes[4] );
+        assertEquals( 0x03, bytes[5] );
+        assertEquals( 0x02, bytes[6] );
+        assertEquals( 0x01, bytes[7] );
+
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream(8);
+        EndianUtils.writeSwappedDouble( baos, d1 );
+        bytes = baos.toByteArray();
+        assertEquals( 0x08, bytes[0] );
+        assertEquals( 0x07, bytes[1] );
+        assertEquals( 0x06, bytes[2] );
+        assertEquals( 0x05, bytes[3] );
+        assertEquals( 0x04, bytes[4] );
+        assertEquals( 0x03, bytes[5] );
+        assertEquals( 0x02, bytes[6] );
+        assertEquals( 0x01, bytes[7] );
+    }
+
+    @Test
+    public void testWriteSwappedFloat() throws IOException {
+        byte[] bytes = new byte[4];
+        final float f1 = Float.intBitsToFloat( 0x01020304 );
+        EndianUtils.writeSwappedFloat( bytes, 0, f1 );
+        assertEquals( 0x04, bytes[0] );
+        assertEquals( 0x03, bytes[1] );
+        assertEquals( 0x02, bytes[2] );
+        assertEquals( 0x01, bytes[3] );
+
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream(4);
+        EndianUtils.writeSwappedFloat( baos, f1 );
+        bytes = baos.toByteArray();
+        assertEquals( 0x04, bytes[0] );
+        assertEquals( 0x03, bytes[1] );
+        assertEquals( 0x02, bytes[2] );
+        assertEquals( 0x01, bytes[3] );
+    }
+
+    @Test
+    public void testWriteSwappedInteger() throws IOException {
+        byte[] bytes = new byte[4];
+        EndianUtils.writeSwappedInteger( bytes, 0, 0x01020304 );
+        assertEquals( 0x04, bytes[0] );
+        assertEquals( 0x03, bytes[1] );
+        assertEquals( 0x02, bytes[2] );
+        assertEquals( 0x01, bytes[3] );
+
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream(4);
+        EndianUtils.writeSwappedInteger( baos, 0x01020304 );
+        bytes = baos.toByteArray();
+        assertEquals( 0x04, bytes[0] );
+        assertEquals( 0x03, bytes[1] );
+        assertEquals( 0x02, bytes[2] );
+        assertEquals( 0x01, bytes[3] );
+    }
+
+    @Test
+    public void testWriteSwappedLong() throws IOException {
+        byte[] bytes = new byte[8];
+        EndianUtils.writeSwappedLong( bytes, 0, 0x0102030405060708L );
+        assertEquals( 0x08, bytes[0] );
+        assertEquals( 0x07, bytes[1] );
+        assertEquals( 0x06, bytes[2] );
+        assertEquals( 0x05, bytes[3] );
+        assertEquals( 0x04, bytes[4] );
+        assertEquals( 0x03, bytes[5] );
+        assertEquals( 0x02, bytes[6] );
+        assertEquals( 0x01, bytes[7] );
+
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream(8);
+        EndianUtils.writeSwappedLong( baos, 0x0102030405060708L );
+        bytes = baos.toByteArray();
+        assertEquals( 0x08, bytes[0] );
+        assertEquals( 0x07, bytes[1] );
+        assertEquals( 0x06, bytes[2] );
+        assertEquals( 0x05, bytes[3] );
+        assertEquals( 0x04, bytes[4] );
+        assertEquals( 0x03, bytes[5] );
+        assertEquals( 0x02, bytes[6] );
+        assertEquals( 0x01, bytes[7] );
+    }
+
+    @Test
+    public void testWriteSwappedShort() throws IOException {
+        byte[] bytes = new byte[2];
+        EndianUtils.writeSwappedShort( bytes, 0, (short) 0x0102 );
+        assertEquals( 0x02, bytes[0] );
+        assertEquals( 0x01, bytes[1] );
+
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream(2);
+        EndianUtils.writeSwappedShort( baos, (short) 0x0102 );
+        bytes = baos.toByteArray();
+        assertEquals( 0x02, bytes[0] );
+        assertEquals( 0x01, bytes[1] );
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/FileCleanerTest.java b/src/test/java/org/apache/commons/io/FileCleanerTest.java
new file mode 100644
index 0000000..e807b32
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileCleanerTest.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+/**
+ * This is used to test {@link FileCleaner} for correctness.
+ *
+ * @see FileCleaner
+ */
+@SuppressWarnings("deprecation") // testing deprecated class
+public class FileCleanerTest extends FileCleaningTrackerTest {
+
+    @Override
+    protected FileCleaningTracker newInstance() {
+        return FileCleaner.getInstance();
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/FileCleaningTrackerTest.java b/src/test/java/org/apache/commons/io/FileCleaningTrackerTest.java
new file mode 100644
index 0000000..a1fb92f
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileCleaningTrackerTest.java
@@ -0,0 +1,322 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.lang.ref.ReferenceQueue;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.commons.io.file.AbstractTempDirTest;
+import org.apache.commons.io.test.TestUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * This is used to test {@link FileCleaningTracker} for correctness.
+ *
+ * @see FileCleaningTracker
+ */
+public class FileCleaningTrackerTest extends AbstractTempDirTest {
+
+    private File testFile;
+
+    private FileCleaningTracker theInstance;
+
+    RandomAccessFile createRandomAccessFile() throws FileNotFoundException {
+        return RandomAccessFileMode.READ_WRITE.create(testFile);
+    }
+
+    protected FileCleaningTracker newInstance() {
+        return new FileCleaningTracker();
+    }
+
+    private void pauseForDeleteToComplete(File file) {
+        int count = 0;
+        while (file.exists() && count++ < 40) {
+            TestUtils.sleepQuietly(500L);
+            file = new File(file.getPath());
+        }
+    }
+
+    @BeforeEach
+    public void setUp() {
+        testFile = new File(tempDirFile, "file-test.txt");
+        theInstance = newInstance();
+    }
+
+    private String showFailures() {
+        if (theInstance.deleteFailures.size() == 1) {
+            return "[Delete Failed: " + theInstance.deleteFailures.get(0) + "]";
+        }
+        return "[Delete Failures: " + theInstance.deleteFailures.size() + "]";
+    }
+
+    @AfterEach
+    public void tearDown() {
+
+        // reset file cleaner class, so as not to break other tests
+
+        /**
+         * The following block of code can possibly be removed when the deprecated {@link FileCleaner} is gone. The
+         * question is, whether we want to support reuse of {@link FileCleaningTracker} instances, which we should, IMO,
+         * not.
+         */
+        {
+            if (theInstance != null) {
+                theInstance.q = new ReferenceQueue<>();
+                theInstance.trackers.clear();
+                theInstance.deleteFailures.clear();
+                theInstance.exitWhenFinished = false;
+                theInstance.reaper = null;
+            }
+        }
+
+        theInstance = null;
+    }
+
+    @Test
+    public void testFileCleanerDirectory() throws Exception {
+        TestUtils.createFile(testFile, 100);
+        assertTrue(testFile.exists());
+        assertTrue(tempDirFile.exists());
+
+        Object obj = new Object();
+        assertEquals(0, theInstance.getTrackCount());
+        theInstance.track(tempDirFile, obj);
+        assertEquals(1, theInstance.getTrackCount());
+
+        obj = null;
+
+        waitUntilTrackCount();
+
+        assertEquals(0, theInstance.getTrackCount());
+        assertTrue(testFile.exists());  // not deleted, as dir not empty
+        assertTrue(testFile.getParentFile().exists());  // not deleted, as dir not empty
+    }
+
+    @Test
+    public void testFileCleanerDirectory_ForceStrategy() throws Exception {
+        if (!testFile.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + testFile
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output =
+                new BufferedOutputStream(Files.newOutputStream(testFile.toPath()))) {
+            TestUtils.generateTestData(output, 100);
+        }
+        assertTrue(testFile.exists());
+        assertTrue(tempDirFile.exists());
+
+        Object obj = new Object();
+        assertEquals(0, theInstance.getTrackCount());
+        theInstance.track(tempDirFile, obj, FileDeleteStrategy.FORCE);
+        assertEquals(1, theInstance.getTrackCount());
+
+        obj = null;
+
+        waitUntilTrackCount();
+        pauseForDeleteToComplete(testFile.getParentFile());
+
+        assertEquals(0, theInstance.getTrackCount());
+        assertFalse(new File(testFile.getPath()).exists(), showFailures());
+        assertFalse(testFile.getParentFile().exists(), showFailures());
+    }
+
+    @Test
+    public void testFileCleanerDirectory_NullStrategy() throws Exception {
+        TestUtils.createFile(testFile, 100);
+        assertTrue(testFile.exists());
+        assertTrue(tempDirFile.exists());
+
+        Object obj = new Object();
+        assertEquals(0, theInstance.getTrackCount());
+        theInstance.track(tempDirFile, obj, null);
+        assertEquals(1, theInstance.getTrackCount());
+
+        obj = null;
+
+        waitUntilTrackCount();
+
+        assertEquals(0, theInstance.getTrackCount());
+        assertTrue(testFile.exists());  // not deleted, as dir not empty
+        assertTrue(testFile.getParentFile().exists());  // not deleted, as dir not empty
+    }
+
+    @Test
+    public void testFileCleanerExitWhenFinished_NoTrackAfter() {
+        assertFalse(theInstance.exitWhenFinished);
+        theInstance.exitWhenFinished();
+        assertTrue(theInstance.exitWhenFinished);
+        assertNull(theInstance.reaper);
+
+        final String path = testFile.getPath();
+        final Object marker = new Object();
+
+        assertThrows(IllegalStateException.class, () -> theInstance.track(path, marker));
+        assertTrue(theInstance.exitWhenFinished);
+        assertNull(theInstance.reaper);
+    }
+
+    @Test
+    public void testFileCleanerExitWhenFinished1() throws Exception {
+        final String path = testFile.getPath();
+
+        assertFalse(testFile.exists(), "1-testFile exists: " + testFile);
+        RandomAccessFile r = createRandomAccessFile();
+        assertTrue(testFile.exists(), "2-testFile exists");
+
+        assertEquals(0, theInstance.getTrackCount(), "3-Track Count");
+        theInstance.track(path, r);
+        assertEquals(1, theInstance.getTrackCount(), "4-Track Count");
+        assertFalse(theInstance.exitWhenFinished, "5-exitWhenFinished");
+        assertTrue(theInstance.reaper.isAlive(), "6-reaper.isAlive");
+
+        assertFalse(theInstance.exitWhenFinished, "7-exitWhenFinished");
+        theInstance.exitWhenFinished();
+        assertTrue(theInstance.exitWhenFinished, "8-exitWhenFinished");
+        assertTrue(theInstance.reaper.isAlive(), "9-reaper.isAlive");
+
+        r.close();
+        testFile = null;
+        r = null;
+
+        waitUntilTrackCount();
+        pauseForDeleteToComplete(new File(path));
+
+        assertEquals(0, theInstance.getTrackCount(), "10-Track Count");
+        assertFalse(new File(path).exists(), "11-testFile exists " + showFailures());
+        assertTrue(theInstance.exitWhenFinished, "12-exitWhenFinished");
+        assertFalse(theInstance.reaper.isAlive(), "13-reaper.isAlive");
+    }
+
+    @Test
+    public void testFileCleanerExitWhenFinished2() throws Exception {
+        final String path = testFile.getPath();
+
+        assertFalse(testFile.exists());
+        RandomAccessFile r = createRandomAccessFile();
+        assertTrue(testFile.exists());
+
+        assertEquals(0, theInstance.getTrackCount());
+        theInstance.track(path, r);
+        assertEquals(1, theInstance.getTrackCount());
+        assertFalse(theInstance.exitWhenFinished);
+        assertTrue(theInstance.reaper.isAlive());
+
+        r.close();
+        testFile = null;
+        r = null;
+
+        waitUntilTrackCount();
+        pauseForDeleteToComplete(new File(path));
+
+        assertEquals(0, theInstance.getTrackCount());
+        assertFalse(new File(path).exists(), showFailures());
+        assertFalse(theInstance.exitWhenFinished);
+        assertTrue(theInstance.reaper.isAlive());
+
+        assertFalse(theInstance.exitWhenFinished);
+        theInstance.exitWhenFinished();
+        for (int i = 0; i < 20 && theInstance.reaper.isAlive(); i++) {
+            TestUtils.sleep(500L);  // allow reaper thread to die
+        }
+        assertTrue(theInstance.exitWhenFinished);
+        assertFalse(theInstance.reaper.isAlive());
+    }
+
+    @Test
+    public void testFileCleanerExitWhenFinishedFirst() throws Exception {
+        assertFalse(theInstance.exitWhenFinished);
+        theInstance.exitWhenFinished();
+        assertTrue(theInstance.exitWhenFinished);
+        assertNull(theInstance.reaper);
+
+        waitUntilTrackCount();
+
+        assertEquals(0, theInstance.getTrackCount());
+        assertTrue(theInstance.exitWhenFinished);
+        assertNull(theInstance.reaper);
+    }
+
+    @Test
+    public void testFileCleanerFile() throws Exception {
+        final String path = testFile.getPath();
+
+        assertFalse(testFile.exists());
+        RandomAccessFile r = createRandomAccessFile();
+        assertTrue(testFile.exists());
+
+        assertEquals(0, theInstance.getTrackCount());
+        theInstance.track(path, r);
+        assertEquals(1, theInstance.getTrackCount());
+
+        r.close();
+        testFile = null;
+        r = null;
+
+        waitUntilTrackCount();
+        pauseForDeleteToComplete(new File(path));
+
+        assertEquals(0, theInstance.getTrackCount());
+        assertFalse(new File(path).exists(), showFailures());
+    }
+    @Test
+    public void testFileCleanerNull() {
+        assertThrows(NullPointerException.class, () -> theInstance.track((File) null, new Object()));
+        assertThrows(NullPointerException.class, () -> theInstance.track((File) null, new Object(), FileDeleteStrategy.NORMAL));
+        assertThrows(NullPointerException.class, () -> theInstance.track((String) null, new Object()));
+        assertThrows(NullPointerException.class, () -> theInstance.track((String) null, new Object(), FileDeleteStrategy.NORMAL));
+    }
+
+    private void waitUntilTrackCount() throws Exception {
+        System.gc();
+        TestUtils.sleep(500);
+        int count = 0;
+        while (theInstance.getTrackCount() != 0 && count++ < 5) {
+            List<String> list = new ArrayList<>();
+            try {
+                long i = 0;
+                while (theInstance.getTrackCount() != 0) {
+                    list.add(
+                        "A Big String A Big String A Big String A Big String A Big String A Big String A Big String A Big String A Big String A Big String "
+                            + i++);
+                }
+            } catch (final Throwable ignored) {
+            }
+            list = null;
+            System.gc();
+            TestUtils.sleep(1000);
+        }
+        if (theInstance.getTrackCount() != 0) {
+            throw new IllegalStateException("Your JVM is not releasing References, try running the test with less memory (-Xmx)");
+        }
+
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/FileDeleteStrategyTest.java b/src/test/java/org/apache/commons/io/FileDeleteStrategyTest.java
new file mode 100644
index 0000000..4a78c16
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileDeleteStrategyTest.java
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+
+import org.apache.commons.io.test.TestUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+/**
+ * Test for FileDeleteStrategy.
+ *
+ * @see FileDeleteStrategy
+ */
+public class FileDeleteStrategyTest {
+
+    @TempDir
+    public File temporaryFolder;
+
+    @Test
+    public void testDeleteForce() throws Exception {
+        final File baseDir = temporaryFolder;
+        final File subDir = new File(baseDir, "test");
+        assertTrue(subDir.mkdir());
+        final File subFile = new File(subDir, "a.txt");
+        if (!subFile.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + subFile
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output =
+                new BufferedOutputStream(Files.newOutputStream(subFile.toPath()))) {
+            TestUtils.generateTestData(output, 16);
+        }
+
+        assertTrue(subDir.exists());
+        assertTrue(subFile.exists());
+        // delete dir
+        FileDeleteStrategy.FORCE.delete(subDir);
+        assertFalse(subDir.exists());
+        assertFalse(subFile.exists());
+        // delete dir
+        FileDeleteStrategy.FORCE.delete(subDir);  // no error
+        assertFalse(subDir.exists());
+    }
+
+    @Test
+    public void testDeleteNormal() throws Exception {
+        final File baseDir = temporaryFolder;
+        final File subDir = new File(baseDir, "test");
+        assertTrue(subDir.mkdir());
+        final File subFile = new File(subDir, "a.txt");
+        if (!subFile.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + subFile
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output =
+                new BufferedOutputStream(Files.newOutputStream(subFile.toPath()))) {
+            TestUtils.generateTestData(output, 16);
+        }
+
+        assertTrue(subDir.exists());
+        assertTrue(subFile.exists());
+        // delete dir
+        assertThrows(IOException.class, () -> FileDeleteStrategy.NORMAL.delete(subDir));
+        assertTrue(subDir.exists());
+        assertTrue(subFile.exists());
+        // delete file
+        FileDeleteStrategy.NORMAL.delete(subFile);
+        assertTrue(subDir.exists());
+        assertFalse(subFile.exists());
+        // delete dir
+        FileDeleteStrategy.NORMAL.delete(subDir);
+        assertFalse(subDir.exists());
+        // delete dir
+        FileDeleteStrategy.NORMAL.delete(subDir);  // no error
+        assertFalse(subDir.exists());
+    }
+
+    @Test
+    public void testDeleteNull() throws Exception {
+        assertThrows(NullPointerException.class, () -> FileDeleteStrategy.NORMAL.delete(null));
+        assertTrue(FileDeleteStrategy.NORMAL.deleteQuietly(null));
+    }
+
+    @Test
+    public void testDeleteQuietlyNormal() throws Exception {
+        final File baseDir = temporaryFolder;
+        final File subDir = new File(baseDir, "test");
+        assertTrue(subDir.mkdir());
+        final File subFile = new File(subDir, "a.txt");
+        if (!subFile.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + subFile
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output =
+                new BufferedOutputStream(Files.newOutputStream(subFile.toPath()))) {
+            TestUtils.generateTestData(output, 16);
+        }
+
+        assertTrue(subDir.exists());
+        assertTrue(subFile.exists());
+        // delete dir
+        assertFalse(FileDeleteStrategy.NORMAL.deleteQuietly(subDir));
+        assertTrue(subDir.exists());
+        assertTrue(subFile.exists());
+        // delete file
+        assertTrue(FileDeleteStrategy.NORMAL.deleteQuietly(subFile));
+        assertTrue(subDir.exists());
+        assertFalse(subFile.exists());
+        // delete dir
+        assertTrue(FileDeleteStrategy.NORMAL.deleteQuietly(subDir));
+        assertFalse(subDir.exists());
+        // delete dir
+        assertTrue(FileDeleteStrategy.NORMAL.deleteQuietly(subDir));  // no error
+        assertFalse(subDir.exists());
+    }
+
+    @Test
+    public void testToString() {
+        assertEquals("FileDeleteStrategy[Normal]", FileDeleteStrategy.NORMAL.toString());
+        assertEquals("FileDeleteStrategy[Force]", FileDeleteStrategy.FORCE.toString());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/FileSystemTest.java b/src/test/java/org/apache/commons/io/FileSystemTest.java
new file mode 100644
index 0000000..a1e2433
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileSystemTest.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+
+import org.apache.commons.lang3.SystemUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+/**
+ * Tests {@link FileSystem}.
+ */
+public class FileSystemTest {
+
+
+    @Test
+    public void testGetCurrent() {
+        if (SystemUtils.IS_OS_WINDOWS) {
+            assertEquals(FileSystem.WINDOWS, FileSystem.getCurrent());
+        }
+        if (SystemUtils.IS_OS_LINUX) {
+            assertEquals(FileSystem.LINUX, FileSystem.getCurrent());
+        }
+        if (SystemUtils.IS_OS_MAC_OSX) {
+            assertEquals(FileSystem.MAC_OSX, FileSystem.getCurrent());
+        }
+    }
+
+    @Test
+    public void testIsLegalName() {
+        for (final FileSystem fs : FileSystem.values()) {
+            assertFalse(fs.isLegalFileName(""), fs.name()); // Empty is always illegal
+            assertFalse(fs.isLegalFileName(null), fs.name()); // null is always illegal
+            assertFalse(fs.isLegalFileName("\0"), fs.name()); // Assume NUL is always illegal
+            assertTrue(fs.isLegalFileName("0"), fs.name()); // Assume simple name always legal
+            for (final String candidate : fs.getReservedFileNames()) {
+                // Reserved file names are not legal
+                assertFalse(fs.isLegalFileName(candidate));
+            }
+        }
+    }
+
+    @Test
+    public void testIsReservedFileName() {
+        for (final FileSystem fs : FileSystem.values()) {
+            for (final String candidate : fs.getReservedFileNames()) {
+                assertTrue(fs.isReservedFileName(candidate));
+            }
+        }
+    }
+
+    @Test
+    @EnabledOnOs(OS.WINDOWS)
+    public void testIsReservedFileNameOnWindows() throws IOException {
+        final FileSystem fs = FileSystem.WINDOWS;
+        for (final String candidate : fs.getReservedFileNames()) {
+            // System.out.printf("Reserved %s exists: %s%n", candidate, Files.exists(Paths.get(candidate)));
+            assertTrue(fs.isReservedFileName(candidate));
+            assertTrue(fs.isReservedFileName(candidate + ".txt"), candidate);
+        }
+
+// This can hang when trying to create files for some reserved names, but it is interesting to keep
+//
+//        for (final String candidate : fs.getReservedFileNames()) {
+//            System.out.printf("Testing %s%n", candidate);
+//            assertTrue(fs.isReservedFileName(candidate));
+//            final Path path = Paths.get(candidate);
+//            final boolean exists = Files.exists(path);
+//            try {
+//                PathUtils.writeString(path, "Hello World!", StandardCharsets.UTF_8);
+//            } catch (IOException ignored) {
+//                // Asking to create a reserved file either:
+//                // - Throws an exception, for example "AUX"
+//                // - Is a NOOP, for example "COM3"
+//            }
+//            assertEquals(exists, Files.exists(path), path.toString());
+//        }
+    }
+
+    @Test
+    public void testReplacementWithNUL() {
+        for (final FileSystem fs : FileSystem.values()) {
+            try {
+                fs.toLegalFileName("Test", '\0'); // Assume NUL is always illegal
+            } catch (final IllegalArgumentException iae) {
+                assertTrue(iae.getMessage().startsWith("The replacement character '\\0'"), iae.getMessage());
+            }
+        }
+    }
+
+    @Test
+    public void testSorted() {
+        for (final FileSystem fs : FileSystem.values()) {
+            final char[] chars = fs.getIllegalFileNameChars();
+            for (int i = 0; i < chars.length - 1; i++) {
+                assertTrue(chars[i] < chars[i + 1], fs.name());
+            }
+        }
+    }
+
+    @Test
+    public void testSupportsDriveLetter() {
+        assertTrue(FileSystem.WINDOWS.supportsDriveLetter());
+        assertFalse(FileSystem.GENERIC.supportsDriveLetter());
+        assertFalse(FileSystem.LINUX.supportsDriveLetter());
+        assertFalse(FileSystem.MAC_OSX.supportsDriveLetter());
+    }
+
+    @Test
+    public void testToLegalFileNameWindows() {
+        final FileSystem fs = FileSystem.WINDOWS;
+        final char replacement = '-';
+        for (char i = 0; i < 32; i++) {
+            assertEquals(replacement, fs.toLegalFileName(String.valueOf(i), replacement).charAt(0));
+        }
+        final char[] illegal = { '<', '>', ':', '"', '/', '\\', '|', '?', '*' };
+        for (char i = 0; i < illegal.length; i++) {
+            assertEquals(replacement, fs.toLegalFileName(String.valueOf(i), replacement).charAt(0));
+        }
+        for (char i = 'a'; i < 'z'; i++) {
+            assertEquals(i, fs.toLegalFileName(String.valueOf(i), replacement).charAt(0));
+        }
+        for (char i = 'A'; i < 'Z'; i++) {
+            assertEquals(i, fs.toLegalFileName(String.valueOf(i), replacement).charAt(0));
+        }
+        for (char i = '0'; i < '9'; i++) {
+            assertEquals(i, fs.toLegalFileName(String.valueOf(i), replacement).charAt(0));
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/FileSystemUtilsTest.java b/src/test/java/org/apache/commons/io/FileSystemUtilsTest.java
new file mode 100644
index 0000000..4d936cd
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileSystemUtilsTest.java
@@ -0,0 +1,498 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.time.Duration;
+import java.util.Locale;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * This is used to test FileSystemUtils.
+ *
+ */
+@SuppressWarnings("deprecation") // testing deprecated class
+public class FileSystemUtilsTest {
+
+    static class MockFileSystemUtils extends FileSystemUtils {
+        private final int exitCode;
+        private final byte[] bytes;
+        private final String cmd;
+
+        public MockFileSystemUtils(final int exitCode, final String lines) {
+            this(exitCode, lines, null);
+        }
+
+        public MockFileSystemUtils(final int exitCode, final String lines, final String cmd) {
+            this.exitCode = exitCode;
+            this.bytes = lines.getBytes();
+            this.cmd = cmd;
+        }
+
+        @Override
+        Process openProcess(final String[] params) {
+            if (cmd != null) {
+                assertEquals(cmd, params[params.length - 1]);
+            }
+            return new Process() {
+                @Override
+                public void destroy() {
+                }
+
+                @Override
+                public int exitValue() {
+                    return exitCode;
+                }
+
+                @Override
+                public InputStream getErrorStream() {
+                    return null;
+                }
+
+                @Override
+                public InputStream getInputStream() {
+                    return new ByteArrayInputStream(bytes);
+                }
+
+                @Override
+                public OutputStream getOutputStream() {
+                    return null;
+                }
+
+                @Override
+                public int waitFor() throws InterruptedException {
+                    return exitCode;
+                }
+            };
+        }
+    }
+
+    private static final Duration NEG_1_TIMEOUT = Duration.ofMillis(-1);
+
+    @Test
+    public void testGetFreeSpace_String() throws Exception {
+        // test coverage, as we can't check value
+        if (File.separatorChar == '/') {
+            // have to figure out Unix block size
+            final String[] cmd;
+            String osName = System.getProperty("os.name");
+            osName = osName.toLowerCase(Locale.ENGLISH);
+
+            if (osName.contains("hp-ux") || osName.contains("aix")) {
+                cmd = new String[]{"df", "-P", "/"};
+            } else if (osName.contains("sunos") || osName.contains("sun os")
+                    || osName.contains("solaris")) {
+                cmd = new String[]{"/usr/xpg4/bin/df", "-P", "/"};
+            } else {
+                cmd = new String[]{"df", "/"};
+            }
+            final Process proc = Runtime.getRuntime().exec(cmd);
+            boolean kilobyteBlock = true;
+            try (BufferedReader r = new BufferedReader(new InputStreamReader(proc.getInputStream()))){
+                final String line = r.readLine();
+                assertNotNull(line, "Unexpected null line");
+                if (line.contains("512")) {
+                    kilobyteBlock = false;
+                }
+            }
+
+            // now perform the test
+            final long free = FileSystemUtils.freeSpace("/");
+            final long kb = FileSystemUtils.freeSpaceKb("/");
+            // Assume disk space does not fluctuate
+            // more than 1% between the above two calls;
+            // this is also small enough to verify freeSpaceKb uses
+            // kibibytes (1024) instead of SI kilobytes (1000)
+            final double acceptableDelta = kb * 0.01d;
+            if (kilobyteBlock) {
+                assertEquals(free, kb, acceptableDelta);
+            } else {
+                assertEquals(free / 2d, kb, acceptableDelta);
+            }
+        } else {
+            final long bytes = FileSystemUtils.freeSpace("");
+            final long kb = FileSystemUtils.freeSpaceKb("");
+            // Assume disk space does not fluctuate more than 1%
+            final double acceptableDelta = kb * 0.01d;
+            assertEquals((double) bytes / 1024, kb, acceptableDelta);
+        }
+    }
+
+    @Test
+    public void testGetFreeSpaceOS_String_InitError() throws Exception {
+        final FileSystemUtils fsu = new FileSystemUtils();
+        assertThrows(IllegalStateException.class, () -> fsu.freeSpaceOS("", -1, false, NEG_1_TIMEOUT));
+        assertThrows(IllegalStateException.class, () -> fsu.freeSpaceOS("", -1, true, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceOS_String_NullPath() throws Exception {
+        final FileSystemUtils fsu = new FileSystemUtils();
+        assertThrows(NullPointerException.class, () -> fsu.freeSpaceOS(null, 1, false, NEG_1_TIMEOUT));
+        assertThrows(NullPointerException.class, () -> fsu.freeSpaceOS(null, 1, true, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceOS_String_Other() throws Exception {
+        final FileSystemUtils fsu = new FileSystemUtils();
+        assertThrows(IllegalStateException.class, () -> fsu.freeSpaceOS("", 0, false, NEG_1_TIMEOUT));
+        assertThrows(NullPointerException.class, () -> fsu.freeSpaceOS(null, 1, true, NEG_1_TIMEOUT));
+        assertThrows(IllegalStateException.class, () -> fsu.freeSpaceOS("", 0, true, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceOS_String_Unix() throws Exception {
+        final FileSystemUtils fsu = new FileSystemUtils() {
+            @Override
+            protected long freeSpaceUnix(final String path, final boolean kb, final boolean posix, final Duration timeout) throws IOException {
+                return kb ? 12345L : 54321;
+            }
+        };
+        assertEquals(54321L, fsu.freeSpaceOS("", 2, false, NEG_1_TIMEOUT));
+        assertEquals(12345L, fsu.freeSpaceOS("", 2, true, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceOS_String_Windows() throws Exception {
+        final FileSystemUtils fsu = new FileSystemUtils() {
+            @Override
+            protected long freeSpaceWindows(final String path, final Duration timeout) throws IOException {
+                return 12345L;
+            }
+        };
+        assertEquals(12345L, fsu.freeSpaceOS("", 1, false, NEG_1_TIMEOUT));
+        assertEquals(12345L / 1024, fsu.freeSpaceOS("", 1, true, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceUnix_String_EmptyPath() throws Exception {
+        final String lines =
+                "Filesystem           1K-blocks      Used Available Use% Mounted on\n" +
+                        "xxx:/home/users/s     14428928  12956424   1472504  90% /home/users/s";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertThrows(IllegalArgumentException.class, () -> fsu.freeSpaceUnix("", false, false, NEG_1_TIMEOUT));
+        assertThrows(IllegalArgumentException.class, () -> fsu.freeSpaceUnix("", true, false, NEG_1_TIMEOUT));
+        assertThrows(IllegalArgumentException.class, () -> fsu.freeSpaceUnix("", true, true, NEG_1_TIMEOUT));
+        assertThrows(IllegalArgumentException.class, () -> fsu.freeSpaceUnix("", false, true, NEG_1_TIMEOUT));
+    }
+
+    @Test
+
+    public void testGetFreeSpaceUnix_String_EmptyResponse() {
+        final String lines = "";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", false, false, NEG_1_TIMEOUT));
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", true, false, NEG_1_TIMEOUT));
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", false, true, NEG_1_TIMEOUT));
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", true, true, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceUnix_String_InvalidResponse1() {
+        final String lines =
+                "Filesystem           1K-blocks      Used Available Use% Mounted on\n" +
+                        "                      14428928  12956424       100";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", false, false, NEG_1_TIMEOUT));
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", true, false, NEG_1_TIMEOUT));
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", false, true, NEG_1_TIMEOUT));
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", true, true, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceUnix_String_InvalidResponse2() {
+        final String lines =
+                "Filesystem           1K-blocks      Used Available Use% Mounted on\n" +
+                        "xxx:/home/users/s     14428928  12956424   nnnnnnn  90% /home/users/s";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", false, false, NEG_1_TIMEOUT));
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", true, false, NEG_1_TIMEOUT));
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", false, true, NEG_1_TIMEOUT));
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", true, true, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceUnix_String_InvalidResponse3() {
+        final String lines =
+                "Filesystem           1K-blocks      Used Available Use% Mounted on\n" +
+                        "xxx:/home/users/s     14428928  12956424        -1  90% /home/users/s";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", false, false, NEG_1_TIMEOUT));
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", true, false, NEG_1_TIMEOUT));
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", false, true, NEG_1_TIMEOUT));
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", true, true, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceUnix_String_InvalidResponse4() {
+        final String lines =
+                "Filesystem           1K-blocks      Used Available Use% Mounted on\n" +
+                        "xxx-yyyyyyy-zzz:/home/users/s";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", false, false, NEG_1_TIMEOUT));
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", true, false, NEG_1_TIMEOUT));
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", false, true, NEG_1_TIMEOUT));
+        assertThrows(IOException.class, () -> fsu.freeSpaceUnix("/home/users/s", true, true, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceUnix_String_LongResponse() throws Exception {
+        final String lines =
+                "Filesystem           1K-blocks      Used Available Use% Mounted on\n" +
+                        "xxx-yyyyyyy-zzz:/home/users/s\n" +
+                        "                      14428928  12956424   1472504  90% /home/users/s";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertEquals(1472504L, fsu.freeSpaceUnix("/home/users/s", false, false, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceUnix_String_LongResponseKb() throws Exception {
+        final String lines =
+                "Filesystem           1K-blocks      Used Available Use% Mounted on\n" +
+                        "xxx-yyyyyyy-zzz:/home/users/s\n" +
+                        "                      14428928  12956424   1472504  90% /home/users/s";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertEquals(1472504L, fsu.freeSpaceUnix("/home/users/s", true, false, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceUnix_String_NormalResponseFreeBSD() throws Exception {
+        // from Apache 'FreeBSD 6.1-RELEASE (SMP-turbo)'
+        final String lines =
+                "Filesystem  1K-blocks      Used    Avail Capacity  Mounted on\n" +
+                        "/dev/xxxxxx    128990    102902    15770    87%    /";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertEquals(15770L, fsu.freeSpaceUnix("/", false, false, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceUnix_String_NormalResponseKbFreeBSD() throws Exception {
+        // from Apache 'FreeBSD 6.1-RELEASE (SMP-turbo)'
+        // df and df -k are identical, but df -kP uses 512 blocks (not relevant as not used)
+        final String lines =
+                "Filesystem  1K-blocks      Used    Avail Capacity  Mounted on\n" +
+                        "/dev/xxxxxx    128990    102902    15770    87%    /";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertEquals(15770L, fsu.freeSpaceUnix("/", true, false, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceUnix_String_NormalResponseKbLinux() throws Exception {
+        // from Sourceforge 'GNU bash, version 2.05b.0(1)-release (i386-redhat-linux-gnu)'
+        // df, df -k and df -kP are all identical
+        final String lines =
+                "Filesystem           1K-blocks      Used Available Use% Mounted on\n" +
+                        "/dev/xxx                497944    308528    189416  62% /";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertEquals(189416L, fsu.freeSpaceUnix("/", true, false, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceUnix_String_NormalResponseKbSolaris() throws Exception {
+        // from IO-91 - ' SunOS et 5.10 Generic_118822-25 sun4u sparc SUNW,Ultra-4'
+        // non-kb response does not contain free space - see IO-91
+        final String lines =
+                "Filesystem            kbytes    used   avail capacity  Mounted on\n" +
+                        "/dev/dsk/x0x0x0x0    1350955  815754  481163    63%";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertEquals(481163L, fsu.freeSpaceUnix("/dev/dsk/x0x0x0x0", true, false, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceUnix_String_NormalResponseLinux() throws Exception {
+        // from Sourceforge 'GNU bash, version 2.05b.0(1)-release (i386-redhat-linux-gnu)'
+        final String lines =
+                "Filesystem           1K-blocks      Used Available Use% Mounted on\n" +
+                        "/dev/xxx                497944    308528    189416  62% /";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertEquals(189416L, fsu.freeSpaceUnix("/", false, false, NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceWindows_String_EmptyMultiLineResponse() {
+        final String lines = "\n\n";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertThrows(IOException.class, () -> fsu.freeSpaceWindows("C:", NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceWindows_String_EmptyPath() throws Exception {
+        final String lines =
+                " Volume in drive C is HDD\n" +
+                        " Volume Serial Number is XXXX-YYYY\n" +
+                        "\n" +
+                        " Directory of C:\\Documents and Settings\\Xxxx\n" +
+                        "\n" +
+                        "19/08/2005  22:43    <DIR>          .\n" +
+                        "19/08/2005  22:43    <DIR>          ..\n" +
+                        "11/08/2005  01:07                81 build.properties\n" +
+                        "17/08/2005  21:44    <DIR>          Desktop\n" +
+                        "               7 File(s)         180260 bytes\n" +
+                        "              10 Dir(s)     41411551232 bytes free";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines, "dir /a /-c ");
+        assertEquals(41411551232L, fsu.freeSpaceWindows("", NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceWindows_String_EmptyResponse() {
+        final String lines = "";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertThrows(IOException.class, () -> fsu.freeSpaceWindows("C:", NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceWindows_String_InvalidTextResponse() {
+        final String lines = "BlueScreenOfDeath";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertThrows(IOException.class, () -> fsu.freeSpaceWindows("C:", NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceWindows_String_NormalResponse() throws Exception {
+        final String lines =
+                " Volume in drive C is HDD\n" +
+                        " Volume Serial Number is XXXX-YYYY\n" +
+                        "\n" +
+                        " Directory of C:\\Documents and Settings\\Xxxx\n" +
+                        "\n" +
+                        "19/08/2005  22:43    <DIR>          .\n" +
+                        "19/08/2005  22:43    <DIR>          ..\n" +
+                        "11/08/2005  01:07                81 build.properties\n" +
+                        "17/08/2005  21:44    <DIR>          Desktop\n" +
+                        "               7 File(s)         180260 bytes\n" +
+                        "              10 Dir(s)     41411551232 bytes free";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines, "dir /a /-c \"C:\"");
+        assertEquals(41411551232L, fsu.freeSpaceWindows("C:", NEG_1_TIMEOUT));
+    }
+    @Test
+    public void testGetFreeSpaceWindows_String_NoSuchDirectoryResponse() {
+        final String lines =
+                " Volume in drive C is HDD\n" +
+                        " Volume Serial Number is XXXX-YYYY\n" +
+                        "\n" +
+                        " Directory of C:\\Documents and Settings\\empty" +
+                        "\n";
+        final FileSystemUtils fsu = new MockFileSystemUtils(1, lines);
+        assertThrows(IOException.class, () -> fsu.freeSpaceWindows("C:", NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceWindows_String_ParseCommaFormatBytes() throws Exception {
+        // this is the format of response when calling dir /c
+        // we have now switched to dir /-c, so we should never get this
+        final String lines =
+                " Volume in drive C is HDD\n" +
+                        " Volume Serial Number is XXXX-YYYY\n" +
+                        "\n" +
+                        " Directory of C:\\Documents and Settings\\Xxxx\n" +
+                        "\n" +
+                        "19/08/2005  22:43    <DIR>          .\n" +
+                        "19/08/2005  22:43    <DIR>          ..\n" +
+                        "11/08/2005  01:07                81 build.properties\n" +
+                        "17/08/2005  21:44    <DIR>          Desktop\n" +
+                        "               7 File(s)        180,260 bytes\n" +
+                        "              10 Dir(s)  41,411,551,232 bytes free";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertEquals(41411551232L, fsu.freeSpaceWindows("", NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceWindows_String_ParseCommaFormatBytes_Big() throws Exception {
+        // test with very large free space
+        final String lines =
+                " Volume in drive C is HDD\n" +
+                        " Volume Serial Number is XXXX-YYYY\n" +
+                        "\n" +
+                        " Directory of C:\\Documents and Settings\\Xxxx\n" +
+                        "\n" +
+                        "19/08/2005  22:43    <DIR>          .\n" +
+                        "19/08/2005  22:43    <DIR>          ..\n" +
+                        "11/08/2005  01:07                81 build.properties\n" +
+                        "17/08/2005  21:44    <DIR>          Desktop\n" +
+                        "               7 File(s)        180,260 bytes\n" +
+                        "              10 Dir(s)  141,411,551,232 bytes free";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertEquals(141411551232L, fsu.freeSpaceWindows("", NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceWindows_String_ParseCommaFormatBytes_Small() throws Exception {
+        // test with very large free space
+        final String lines =
+                " Volume in drive C is HDD\n" +
+                        " Volume Serial Number is XXXX-YYYY\n" +
+                        "\n" +
+                        " Directory of C:\\Documents and Settings\\Xxxx\n" +
+                        "\n" +
+                        "19/08/2005  22:43    <DIR>          .\n" +
+                        "19/08/2005  22:43    <DIR>          ..\n" +
+                        "11/08/2005  01:07                81 build.properties\n" +
+                        "17/08/2005  21:44    <DIR>          Desktop\n" +
+                        "               7 File(s)        180,260 bytes\n" +
+                        "              10 Dir(s)  1,232 bytes free";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines);
+        assertEquals(1232L, fsu.freeSpaceWindows("", NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceWindows_String_quoted() throws Exception {
+        final String lines =
+                " Volume in drive C is HDD\n" +
+                        " Volume Serial Number is XXXX-YYYY\n" +
+                        "\n" +
+                        " Directory of C:\\Documents and Settings\\Xxxx\n" +
+                        "\n" +
+                        "19/08/2005  22:43    <DIR>          .\n" +
+                        "19/08/2005  22:43    <DIR>          ..\n" +
+                        "11/08/2005  01:07                81 build.properties\n" +
+                        "17/08/2005  21:44    <DIR>          Desktop\n" +
+                        "               7 File(s)         180260 bytes\n" +
+                        "              10 Dir(s)     41411551232 bytes free";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines, "dir /a /-c \"C:\\somedir\"");
+        assertEquals(41411551232L, fsu.freeSpaceWindows("\"C:\\somedir\"", NEG_1_TIMEOUT));
+    }
+
+    @Test
+    public void testGetFreeSpaceWindows_String_StripDrive() throws Exception {
+        final String lines =
+                " Volume in drive C is HDD\n" +
+                        " Volume Serial Number is XXXX-YYYY\n" +
+                        "\n" +
+                        " Directory of C:\\Documents and Settings\\Xxxx\n" +
+                        "\n" +
+                        "19/08/2005  22:43    <DIR>          .\n" +
+                        "19/08/2005  22:43    <DIR>          ..\n" +
+                        "11/08/2005  01:07                81 build.properties\n" +
+                        "17/08/2005  21:44    <DIR>          Desktop\n" +
+                        "               7 File(s)         180260 bytes\n" +
+                        "              10 Dir(s)     41411551232 bytes free";
+        final FileSystemUtils fsu = new MockFileSystemUtils(0, lines, "dir /a /-c \"C:\\somedir\"");
+        assertEquals(41411551232L, fsu.freeSpaceWindows("C:\\somedir", NEG_1_TIMEOUT));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/FileUtilsCleanDirectoryTest.java b/src/test/java/org/apache/commons/io/FileUtilsCleanDirectoryTest.java
new file mode 100644
index 0000000..138ebdc
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileUtilsCleanDirectoryTest.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.commons.io.file.AbstractTempDirTest;
+import org.apache.commons.lang3.ArrayUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+/**
+ * Test cases for FileUtils.cleanDirectory() method.
+ *
+ * TODO Redo this test using
+ * {@link Files#createSymbolicLink(java.nio.file.Path, java.nio.file.Path, java.nio.file.attribute.FileAttribute...)}.
+ */
+public class FileUtilsCleanDirectoryTest extends AbstractTempDirTest {
+
+    /** Only runs on Linux. */
+    private boolean chmod(final File file, final int mode, final boolean recurse) throws InterruptedException {
+        final List<String> args = new ArrayList<>();
+        args.add("chmod");
+
+        if (recurse) {
+            args.add("-R");
+        }
+
+        args.add(Integer.toString(mode));
+        args.add(file.getAbsolutePath());
+
+        final Process proc;
+
+        try {
+            proc = Runtime.getRuntime().exec(args.toArray(ArrayUtils.EMPTY_STRING_ARRAY));
+        } catch (final IOException e) {
+            return false;
+        }
+        return proc.waitFor() == 0;
+    }
+
+    @DisabledOnOs(OS.WINDOWS)
+    @Test
+    public void testCleanDirectoryToForceDelete() throws Exception {
+        final File file = new File(tempDirFile, "restricted");
+        FileUtils.touch(file);
+
+        // 300 = owner: WE.
+        // 500 = owner: RE.
+        // 700 = owner: RWE.
+        assumeTrue(chmod(tempDirFile, 700, false));
+
+        // cleanDirectory calls forceDelete
+        FileUtils.cleanDirectory(tempDirFile);
+    }
+
+    @Test
+    public void testCleanEmpty() throws Exception {
+        assertEquals(0, tempDirFile.list().length);
+
+        FileUtils.cleanDirectory(tempDirFile);
+
+        assertEquals(0, tempDirFile.list().length);
+    }
+
+    @Test
+    public void testDeletesNested() throws Exception {
+        final File nested = new File(tempDirFile, "nested");
+
+        assertTrue(nested.mkdirs());
+
+        FileUtils.touch(new File(nested, "file"));
+
+        assertEquals(1, tempDirFile.list().length);
+
+        FileUtils.cleanDirectory(tempDirFile);
+
+        assertEquals(0, tempDirFile.list().length);
+    }
+
+    @Test
+    public void testDeletesRegular() throws Exception {
+        FileUtils.touch(new File(tempDirFile, "regular"));
+        FileUtils.touch(new File(tempDirFile, ".hidden"));
+
+        assertEquals(2, tempDirFile.list().length);
+
+        FileUtils.cleanDirectory(tempDirFile);
+
+        assertEquals(0, tempDirFile.list().length);
+    }
+
+    @DisabledOnOs(OS.WINDOWS)
+    @Test
+    public void testThrowsOnNullList() throws Exception {
+        // test won't work if we can't restrict permissions on the
+        // directory, so skip it.
+        assumeTrue(chmod(tempDirFile, 0, false));
+
+        try {
+            // cleanDirectory calls forceDelete
+            FileUtils.cleanDirectory(tempDirFile);
+            fail("expected IOException");
+        } catch (final IOException e) {
+            assertEquals("Unknown I/O error listing contents of directory: " + tempDirFile.getAbsolutePath(), e.getMessage());
+        } finally {
+            chmod(tempDirFile, 755, false);
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/FileUtilsCleanSymlinksTest.java b/src/test/java/org/apache/commons/io/FileUtilsCleanSymlinksTest.java
new file mode 100644
index 0000000..8e7194c
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileUtilsCleanSymlinksTest.java
@@ -0,0 +1,263 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.commons.lang3.ArrayUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Test cases for FileUtils.cleanDirectory() method that involve symlinks.
+ * &amp; FileUtils.isSymlink(File file)
+ */
+public class FileUtilsCleanSymlinksTest {
+
+    @TempDir
+    public File top;
+
+    private boolean setupSymlink(final File res, final File link) throws Exception {
+        // create symlink
+        final List<String> args = new ArrayList<>();
+        args.add("ln");
+        args.add("-s");
+
+        args.add(res.getAbsolutePath());
+        args.add(link.getAbsolutePath());
+
+        final Process proc;
+
+        proc = Runtime.getRuntime().exec(args.toArray(ArrayUtils.EMPTY_STRING_ARRAY));
+        return proc.waitFor() == 0;
+    }
+
+
+    @Test
+    public void testCleanDirWithASymlinkDir() throws Exception {
+        if (System.getProperty("os.name").startsWith("Win")) {
+            // Can't use "ln" for symlinks on the command line in Windows.
+            return;
+        }
+
+        final File realOuter = new File(top, "realouter");
+        assertTrue(realOuter.mkdirs());
+
+        final File realInner = new File(realOuter, "realinner");
+        assertTrue(realInner.mkdirs());
+
+        FileUtils.touch(new File(realInner, "file1"));
+        assertEquals(1, realInner.list().length);
+
+        final File randomDirectory = new File(top, "randomDir");
+        assertTrue(randomDirectory.mkdirs());
+
+        FileUtils.touch(new File(randomDirectory, "randomfile"));
+        assertEquals(1, randomDirectory.list().length);
+
+        final File symlinkDirectory = new File(realOuter, "fakeinner");
+        assertTrue(setupSymlink(randomDirectory, symlinkDirectory));
+
+        assertEquals(1, symlinkDirectory.list().length);
+
+        // assert contents of the real directory were removed including the symlink
+        FileUtils.cleanDirectory(realOuter);
+        assertEquals(0, realOuter.list().length);
+
+        // ensure that the contents of the symlink were NOT removed.
+        assertEquals(1, randomDirectory.list().length, "Contents of sym link should not have been removed");
+    }
+
+    @Test
+    public void testCleanDirWithParentSymlinks() throws Exception {
+        if (System.getProperty("os.name").startsWith("Win")) {
+            // Can't use "ln" for symlinks on the command line in Windows.
+            return;
+        }
+
+        final File realParent = new File(top, "realparent");
+        assertTrue(realParent.mkdirs());
+
+        final File realInner = new File(realParent, "realinner");
+        assertTrue(realInner.mkdirs());
+
+        FileUtils.touch(new File(realInner, "file1"));
+        assertEquals(1, realInner.list().length);
+
+        final File randomDirectory = new File(top, "randomDir");
+        assertTrue(randomDirectory.mkdirs());
+
+        FileUtils.touch(new File(randomDirectory, "randomfile"));
+        assertEquals(1, randomDirectory.list().length);
+
+        final File symlinkDirectory = new File(realParent, "fakeinner");
+        assertTrue(setupSymlink(randomDirectory, symlinkDirectory));
+
+        assertEquals(1, symlinkDirectory.list().length);
+
+        final File symlinkParentDirectory = new File(top, "fakeouter");
+        assertTrue(setupSymlink(realParent, symlinkParentDirectory));
+
+        // assert contents of the real directory were removed including the symlink
+        FileUtils.cleanDirectory(symlinkParentDirectory);// should clean the contents of this but not recurse into other links
+        assertEquals(0, symlinkParentDirectory.list().length);
+        assertEquals(0, realParent.list().length);
+
+        // ensure that the contents of the symlink were NOT removed.
+        assertEquals(1, randomDirectory.list().length, "Contents of sym link should not have been removed");
+    }
+
+    @Test
+    public void testCleanDirWithSymlinkFile() throws Exception {
+        if (System.getProperty("os.name").startsWith("Win")) {
+            // Can't use "ln" for symlinks on the command line in Windows.
+            return;
+        }
+
+        final File realOuter = new File(top, "realouter");
+        assertTrue(realOuter.mkdirs());
+
+        final File realInner = new File(realOuter, "realinner");
+        assertTrue(realInner.mkdirs());
+
+        final File realFile = new File(realInner, "file1");
+        FileUtils.touch(realFile);
+        assertEquals(1, realInner.list().length);
+
+        final File randomFile = new File(top, "randomfile");
+        FileUtils.touch(randomFile);
+
+        final File symlinkFile = new File(realInner, "fakeinner");
+        assertTrue(setupSymlink(randomFile, symlinkFile));
+
+        assertEquals(2, realInner.list().length);
+
+        // assert contents of the real directory were removed including the symlink
+        FileUtils.cleanDirectory(realOuter);
+        assertEquals(0, realOuter.list().length);
+
+        // ensure that the contents of the symlink were NOT removed.
+        assertTrue(randomFile.exists());
+        assertFalse(symlinkFile.exists());
+    }
+
+
+    @Test
+    public void testCorrectlyIdentifySymlinkWithParentSymLink() throws Exception {
+        if (System.getProperty("os.name").startsWith("Win")) {
+            // Can't use "ln" for symlinks on the command line in Windows.
+            return;
+        }
+
+        final File realParent = new File(top, "realparent");
+        assertTrue(realParent.mkdirs());
+
+        final File symlinkParentDirectory = new File(top, "fakeparent");
+        assertTrue(setupSymlink(realParent, symlinkParentDirectory));
+
+        final File realChild = new File(symlinkParentDirectory, "realChild");
+        assertTrue(realChild.mkdirs());
+
+        final File symlinkChild = new File(symlinkParentDirectory, "fakeChild");
+        assertTrue(setupSymlink(realChild, symlinkChild));
+
+        assertTrue(FileUtils.isSymlink(symlinkChild));
+        assertFalse(FileUtils.isSymlink(realChild));
+    }
+
+    @Test
+    public void testIdentifiesBrokenSymlinkFile() throws Exception {
+        if (System.getProperty("os.name").startsWith("Win")) {
+            // Can't use "ln" for symlinks on the command line in Windows.
+            return;
+        }
+
+        final File noexistFile = new File(top, "noexist");
+        final File symlinkFile = new File(top, "fakeinner");
+        final File badSymlinkInPathFile = new File(symlinkFile, "fakeinner");
+        final File noexistParentFile = new File("noexist", "file");
+
+        assertTrue(setupSymlink(noexistFile, symlinkFile));
+
+        assertTrue(FileUtils.isSymlink(symlinkFile));
+        assertFalse(FileUtils.isSymlink(noexistFile));
+        assertFalse(FileUtils.isSymlink(noexistParentFile));
+        assertFalse(FileUtils.isSymlink(badSymlinkInPathFile));
+    }
+
+    @Test
+    public void testIdentifiesSymlinkDir() throws Exception {
+        if (System.getProperty("os.name").startsWith("Win")) {
+            // Can't use "ln" for symlinks on the command line in Windows.
+            return;
+        }
+
+        final File randomDirectory = new File(top, "randomDir");
+        assertTrue(randomDirectory.mkdirs());
+
+        final File symlinkDirectory = new File(top, "fakeDir");
+        assertTrue(setupSymlink(randomDirectory, symlinkDirectory));
+
+        assertTrue(FileUtils.isSymlink(symlinkDirectory));
+        assertFalse(FileUtils.isSymlink(randomDirectory));
+    }
+
+    @Test
+    public void testIdentifiesSymlinkFile() throws Exception {
+        if (System.getProperty("os.name").startsWith("Win")) {
+            // Can't use "ln" for symlinks on the command line in Windows.
+            return;
+        }
+
+        final File randomFile = new File(top, "randomfile");
+        FileUtils.touch(randomFile);
+
+        final File symlinkFile = new File(top, "fakeinner");
+        assertTrue(setupSymlink(randomFile, symlinkFile));
+
+        assertTrue(FileUtils.isSymlink(symlinkFile));
+        assertFalse(FileUtils.isSymlink(randomFile));
+    }
+
+    @Test
+    public void testStillClearsIfGivenDirectoryIsASymlink() throws Exception {
+        if (System.getProperty("os.name").startsWith("Win")) {
+            // Can't use "ln" for symlinks on the command line in Windows.
+            return;
+        }
+
+        final File randomDirectory = new File(top, "randomDir");
+        assertTrue(randomDirectory.mkdirs());
+
+        FileUtils.touch(new File(randomDirectory, "randomfile"));
+        assertEquals(1, randomDirectory.list().length);
+
+        final File symlinkDirectory = new File(top, "fakeDir");
+        assertTrue(setupSymlink(randomDirectory, symlinkDirectory));
+
+        FileUtils.cleanDirectory(symlinkDirectory);
+        assertEquals(0, symlinkDirectory.list().length);
+        assertEquals(0, randomDirectory.list().length);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/FileUtilsCopyDirectoryToDirectoryTest.java b/src/test/java/org/apache/commons/io/FileUtilsCopyDirectoryToDirectoryTest.java
new file mode 100644
index 0000000..6bc2223
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileUtilsCopyDirectoryToDirectoryTest.java
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+
+import org.apache.commons.io.file.PathUtils;
+import org.apache.commons.io.file.TempFile;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * This class ensure the correctness of {@link FileUtils#copyDirectoryToDirectory(File, File)}. TODO: currently does not
+ * cover happy cases
+ *
+ * @see FileUtils#copyDirectoryToDirectory(File, File)
+ */
+public class FileUtilsCopyDirectoryToDirectoryTest {
+
+    private static void assertExceptionTypeAndMessage(final File srcDir, final File destDir,
+        final Class<? extends Exception> expectedExceptionType, final String expectedMessage) {
+        try {
+            FileUtils.copyDirectoryToDirectory(srcDir, destDir);
+        } catch (final Exception e) {
+            final String msg = e.getMessage();
+            assertEquals(expectedExceptionType, e.getClass());
+            assertEquals(expectedMessage, msg);
+            return;
+        }
+        fail();
+    }
+
+    /** Temporary folder managed by JUnit. */
+    @TempDir
+    public File temporaryFolder;
+
+    private void assertAclEntryList(final Path sourcePath, final Path destPath) throws IOException {
+        assertEquals(PathUtils.getAclEntryList(sourcePath), PathUtils.getAclEntryList(destPath));
+    }
+
+    @Test
+    public void copyDirectoryToDirectoryThrowsIllegalArgumentExceptionWithCorrectMessageWhenDstDirIsNotDirectory()
+        throws IOException {
+        final File srcDir = new File(temporaryFolder, "sourceDirectory");
+        srcDir.mkdir();
+        final File destDir = new File(temporaryFolder, "notadirectory");
+        destDir.createNewFile();
+        final String expectedMessage = String.format("Parameter 'destinationDir' is not a directory: '%s'",
+            destDir);
+        assertExceptionTypeAndMessage(srcDir, destDir, IllegalArgumentException.class, expectedMessage);
+    }
+
+    @Test
+    public void copyDirectoryToDirectoryThrowsIllegalExceptionWithCorrectMessageWhenSrcDirIsNotDirectory()
+        throws IOException {
+        try (TempFile srcDir = TempFile.create("notadirectory", null)) {
+            final File destDir = new File(temporaryFolder, "destinationDirectory");
+            destDir.mkdirs();
+            final String expectedMessage = String.format("Parameter 'sourceDir' is not a directory: '%s'", srcDir);
+            assertExceptionTypeAndMessage(srcDir.toFile(), destDir, IllegalArgumentException.class, expectedMessage);
+        }
+    }
+
+    @Test
+    public void copyDirectoryToDirectoryThrowsNullPointerExceptionWithCorrectMessageWhenDstDirIsNull() {
+        final File srcDir = new File(temporaryFolder, "sourceDirectory");
+        srcDir.mkdir();
+        final File destDir = null;
+        assertExceptionTypeAndMessage(srcDir, destDir, NullPointerException.class, "destinationDir");
+    }
+
+    @Test
+    public void copyDirectoryToDirectoryThrowsNullPointerExceptionWithCorrectMessageWhenSrcDirIsNull() {
+        final File srcDir = null;
+        final File destinationDirectory = new File(temporaryFolder, "destinationDirectory");
+        destinationDirectory.mkdir();
+        assertExceptionTypeAndMessage(srcDir, destinationDirectory, NullPointerException.class, "sourceDir");
+    }
+
+    @Test
+    public void copyFileAndCheckAcl() throws IOException {
+        try (TempFile sourcePath = TempFile.create("TempOutput", ".bin")) {
+            final Path destPath = Paths.get(temporaryFolder.getAbsolutePath(), "SomeFile.bin");
+            // Test copy attributes without replace FIRST.
+            FileUtils.copyFile(sourcePath.toFile(), destPath.toFile(), true, StandardCopyOption.COPY_ATTRIBUTES);
+            assertAclEntryList(sourcePath.get(), destPath);
+            //
+            FileUtils.copyFile(sourcePath.toFile(), destPath.toFile());
+            assertAclEntryList(sourcePath.get(), destPath);
+            //
+            FileUtils.copyFile(sourcePath.toFile(), destPath.toFile(), true, StandardCopyOption.REPLACE_EXISTING);
+            assertAclEntryList(sourcePath.get(), destPath);
+            //
+            FileUtils.copyFile(sourcePath.toFile(), destPath.toFile(), true, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES);
+            assertAclEntryList(sourcePath.get(), destPath);
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/FileUtilsCopyToFileTest.java b/src/test/java/org/apache/commons/io/FileUtilsCopyToFileTest.java
new file mode 100644
index 0000000..bf0a0e3
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileUtilsCopyToFileTest.java
@@ -0,0 +1,100 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License. */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.apache.commons.io.test.TestUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+
+/**
+ * This is used to test FileUtils for correctness.
+ */
+public class FileUtilsCopyToFileTest {
+
+    private class CheckingInputStream extends ByteArrayInputStream {
+        private boolean closed;
+
+        public CheckingInputStream(final byte[] data) {
+            super(data);
+            closed = false;
+        }
+
+        @Override
+        public void close() throws IOException {
+            super.close();
+            closed = true;
+        }
+
+        public boolean isClosed() {
+            return closed;
+        }
+    }
+
+    @TempDir
+    public File temporaryFolder;
+
+    private File testFile;
+
+    private byte[] testData;
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        testFile = new File(temporaryFolder, "file1-test.txt");
+        if(!testFile.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + testFile +
+                " as the parent directory does not exist");
+        }
+
+        testData = TestUtils.generateTestData(1024);
+    }
+
+    /**
+     * Tests that {@code copyInputStreamToFile(InputStream, File)} closes the input stream.
+     *
+     * @throws IOException
+     * @see FileUtils#copyInputStreamToFile(InputStream, File)
+     * @see FileUtils#copyToFile(InputStream, File)
+     */
+    @Test
+    public void testCopyInputStreamToFile() throws IOException {
+        try(CheckingInputStream inputStream = new CheckingInputStream(testData)) {
+            FileUtils.copyInputStreamToFile(inputStream, testFile);
+            assertTrue(inputStream.isClosed(), "inputStream should be closed");
+        }
+    }
+
+    /**
+     * Tests that {@code copyToFile(InputStream, File)} does not close the input stream.
+     *
+     * @throws IOException
+     * @see FileUtils#copyToFile(InputStream, File)
+     * @see FileUtils#copyInputStreamToFile(InputStream, File)
+     */
+    @Test
+    public void testCopyToFile() throws IOException {
+        try(CheckingInputStream inputStream = new CheckingInputStream(testData)) {
+            FileUtils.copyToFile(inputStream, testFile);
+            assertFalse(inputStream.isClosed(), "inputStream should NOT be closed");
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/FileUtilsDeleteDirectoryBaseTest.java b/src/test/java/org/apache/commons/io/FileUtilsDeleteDirectoryBaseTest.java
new file mode 100644
index 0000000..94c1ec3
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileUtilsDeleteDirectoryBaseTest.java
@@ -0,0 +1,258 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.nio.file.Files;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Test cases for FileUtils.deleteDirectory() method.
+ *
+ */
+public abstract class FileUtilsDeleteDirectoryBaseTest {
+    @TempDir
+    public File top;
+
+    protected abstract boolean setupSymlink(final File res, final File link) throws Exception;
+
+    @Test
+    public void testDeleteDirectoryNullArgument() {
+        assertThrows(NullPointerException.class, () -> FileUtils.deleteDirectory(null));
+    }
+
+    @Test
+    public void testDeleteDirWithASymlinkDir() throws Exception {
+
+        final File realOuter = new File(top, "realouter");
+        assertTrue(realOuter.mkdirs());
+
+        final File realInner = new File(realOuter, "realinner");
+        assertTrue(realInner.mkdirs());
+
+        FileUtils.touch(new File(realInner, "file1"));
+        assertEquals(1, realInner.list().length);
+
+        final File randomDirectory = new File(top, "randomDir");
+        assertTrue(randomDirectory.mkdirs());
+
+        FileUtils.touch(new File(randomDirectory, "randomfile"));
+        assertEquals(1, randomDirectory.list().length);
+
+        final File symlinkDirectory = new File(realOuter, "fakeinner");
+        assertTrue(setupSymlink(randomDirectory, symlinkDirectory));
+
+        assertEquals(1, symlinkDirectory.list().length);
+
+        // assert contents of the real directory were removed including the symlink
+        FileUtils.deleteDirectory(realOuter);
+        assertEquals(1, top.list().length);
+
+        // ensure that the contents of the symlink were NOT removed.
+        assertEquals(1, randomDirectory.list().length, "Contents of sym link should not have been removed");
+    }
+
+    @Test
+    public void testDeleteDirWithASymlinkDir2() throws Exception {
+
+        final File realOuter = new File(top, "realouter");
+        assertTrue(realOuter.mkdirs());
+
+        final File realInner = new File(realOuter, "realinner");
+        assertTrue(realInner.mkdirs());
+
+        FileUtils.touch(new File(realInner, "file1"));
+        assertEquals(1, realInner.list().length);
+
+        final File randomDirectory = new File(top, "randomDir");
+        assertTrue(randomDirectory.mkdirs());
+
+        FileUtils.touch(new File(randomDirectory, "randomfile"));
+        assertEquals(1, randomDirectory.list().length);
+
+        final File symlinkDirectory = new File(realOuter, "fakeinner");
+        Files.createSymbolicLink(symlinkDirectory.toPath(), randomDirectory.toPath());
+
+        assertEquals(1, symlinkDirectory.list().length);
+
+        // assert contents of the real directory were removed including the symlink
+        FileUtils.deleteDirectory(realOuter);
+        assertEquals(1, top.list().length);
+
+        // ensure that the contents of the symlink were NOT removed.
+        assertEquals(1, randomDirectory.list().length, "Contents of sym link should not have been removed");
+    }
+
+    @Test
+    public void testDeleteDirWithSymlinkFile() throws Exception {
+        final File realOuter = new File(top, "realouter");
+        assertTrue(realOuter.mkdirs());
+
+        final File realInner = new File(realOuter, "realinner");
+        assertTrue(realInner.mkdirs());
+
+        final File realFile = new File(realInner, "file1");
+        FileUtils.touch(realFile);
+
+        assertEquals(1, realInner.list().length);
+
+        final File randomFile = new File(top, "randomfile");
+        FileUtils.touch(randomFile);
+
+        final File symlinkFile = new File(realInner, "fakeinner");
+        assertTrue(setupSymlink(randomFile, symlinkFile));
+
+        assertEquals(2, realInner.list().length);
+        assertEquals(2, top.list().length);
+
+        // assert the real directory were removed including the symlink
+        FileUtils.deleteDirectory(realOuter);
+        assertEquals(1, top.list().length);
+
+        // ensure that the contents of the symlink were NOT removed.
+        assertTrue(randomFile.exists());
+        assertFalse(symlinkFile.exists());
+    }
+
+    @Test
+    public void testDeleteInvalidLinks() throws Exception {
+        final File aFile = new File(top, "realParentDirA");
+        assertTrue(aFile.mkdir());
+        final File bFile = new File(aFile, "realChildDirB");
+        assertTrue(bFile.mkdir());
+
+        final File cFile = new File(top, "realParentDirC");
+        assertTrue(cFile.mkdir());
+        final File dFile = new File(cFile, "realChildDirD");
+        assertTrue(dFile.mkdir());
+
+        final File linkToC = new File(bFile, "linkToC");
+        Files.createSymbolicLink(linkToC.toPath(), cFile.toPath());
+
+        final File linkToB = new File(dFile, "linkToB");
+        Files.createSymbolicLink(linkToB.toPath(), bFile.toPath());
+
+        FileUtils.deleteDirectory(aFile);
+        FileUtils.deleteDirectory(cFile);
+        assertEquals(0, top.list().length);
+    }
+
+    @Test
+    public void testDeleteParentSymlink() throws Exception {
+        final File realParent = new File(top, "realparent");
+        assertTrue(realParent.mkdirs());
+
+        final File realInner = new File(realParent, "realinner");
+        assertTrue(realInner.mkdirs());
+
+        FileUtils.touch(new File(realInner, "file1"));
+        assertEquals(1, realInner.list().length);
+
+        final File randomDirectory = new File(top, "randomDir");
+        assertTrue(randomDirectory.mkdirs());
+
+        FileUtils.touch(new File(randomDirectory, "randomfile"));
+        assertEquals(1, randomDirectory.list().length);
+
+        final File symlinkDirectory = new File(realParent, "fakeinner");
+        assertTrue(setupSymlink(randomDirectory, symlinkDirectory));
+
+        assertEquals(1, symlinkDirectory.list().length);
+
+        final File symlinkParentDirectory = new File(top, "fakeouter");
+        assertTrue(setupSymlink(realParent, symlinkParentDirectory));
+
+        // assert only the symlink is deleted, but not followed
+        FileUtils.deleteDirectory(symlinkParentDirectory);
+        assertEquals(2, top.list().length);
+
+        // ensure that the contents of the symlink were NOT removed.
+        assertEquals(1, randomDirectory.list().length, "Contents of sym link should not have been removed");
+    }
+
+    @Test
+    public void testDeleteParentSymlink2() throws Exception {
+        final File realParent = new File(top, "realparent");
+        assertTrue(realParent.mkdirs());
+
+        final File realInner = new File(realParent, "realinner");
+        assertTrue(realInner.mkdirs());
+
+        FileUtils.touch(new File(realInner, "file1"));
+        assertEquals(1, realInner.list().length);
+
+        final File randomDirectory = new File(top, "randomDir");
+        assertTrue(randomDirectory.mkdirs());
+
+        FileUtils.touch(new File(randomDirectory, "randomfile"));
+        assertEquals(1, randomDirectory.list().length);
+
+        final File symlinkDirectory = new File(realParent, "fakeinner");
+        Files.createSymbolicLink(symlinkDirectory.toPath(), randomDirectory.toPath());
+
+        assertEquals(1, symlinkDirectory.list().length);
+
+        final File symlinkParentDirectory = new File(top, "fakeouter");
+        Files.createSymbolicLink(symlinkParentDirectory.toPath(), realParent.toPath());
+
+        // assert only the symlink is deleted, but not followed
+        FileUtils.deleteDirectory(symlinkParentDirectory);
+        assertEquals(2, top.list().length);
+
+        // ensure that the contents of the symlink were NOT removed.
+        assertEquals(1, randomDirectory.list().length, "Contents of sym link should not have been removed");
+    }
+
+    @Test
+    public void testDeletesNested() throws Exception {
+        final File nested = new File(top, "nested");
+        assertTrue(nested.mkdirs());
+
+        assertEquals(1, top.list().length);
+
+        FileUtils.touch(new File(nested, "regular"));
+        FileUtils.touch(new File(nested, ".hidden"));
+
+        assertEquals(2, nested.list().length);
+
+        FileUtils.deleteDirectory(nested);
+
+        assertEquals(0, top.list().length);
+    }
+
+    @Test
+    public void testDeletesRegular() throws Exception {
+        final File nested = new File(top, "nested");
+        assertTrue(nested.mkdirs());
+
+        assertEquals(1, top.list().length);
+
+        assertEquals(0, nested.list().length);
+
+        FileUtils.deleteDirectory(nested);
+
+        assertEquals(0, top.list().length);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/FileUtilsDeleteDirectoryLinuxTest.java b/src/test/java/org/apache/commons/io/FileUtilsDeleteDirectoryLinuxTest.java
new file mode 100644
index 0000000..d078f15
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileUtilsDeleteDirectoryLinuxTest.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.commons.lang3.ArrayUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+@DisabledOnOs({OS.WINDOWS, OS.MAC})
+public class FileUtilsDeleteDirectoryLinuxTest extends FileUtilsDeleteDirectoryBaseTest {
+
+    /** Only runs on Linux. */
+    private boolean chmod(final File file, final int mode, final boolean recurse) throws InterruptedException {
+        final List<String> args = new ArrayList<>();
+        args.add("chmod");
+
+        if (recurse) {
+            args.add("-R");
+        }
+
+        args.add(Integer.toString(mode));
+        args.add(file.getAbsolutePath());
+
+        final Process proc;
+
+        try {
+            proc = Runtime.getRuntime().exec(args.toArray(ArrayUtils.EMPTY_STRING_ARRAY));
+        } catch (final IOException e) {
+            return false;
+        }
+        return proc.waitFor() == 0;
+    }
+
+    @Override
+    protected boolean setupSymlink(final File res, final File link) throws Exception {
+        // create symlink
+        final List<String> args = new ArrayList<>();
+        args.add("ln");
+        args.add("-s");
+
+        args.add(res.getAbsolutePath());
+        args.add(link.getAbsolutePath());
+
+        final Process proc;
+
+        proc = Runtime.getRuntime().exec(args.toArray(ArrayUtils.EMPTY_STRING_ARRAY));
+        return proc.waitFor() == 0;
+    }
+
+    @Test
+    public void testThrowsOnCannotDeleteFile() throws Exception {
+        final File nested = new File(top, "nested");
+        assertTrue(nested.mkdirs());
+
+        final File file = new File(nested, "restricted");
+        FileUtils.touch(file);
+
+        assumeTrue(chmod(nested, 500, false));
+
+        try {
+            // deleteDirectory calls forceDelete
+            FileUtils.deleteDirectory(nested);
+            fail("expected IOException");
+        } catch (final IOException e) {
+            final IOExceptionList list = (IOExceptionList) e;
+            assertTrue(list.getCause(0).getMessage().endsWith("Cannot delete file: " + file.getAbsolutePath()));
+        } finally {
+            chmod(nested, 755, false);
+            FileUtils.deleteDirectory(nested);
+        }
+        assertEquals(0, top.list().length);
+    }
+
+    @Test
+    public void testThrowsOnNullList() throws Exception {
+        final File nested = new File(top, "nested");
+        assertTrue(nested.mkdirs());
+
+        // test won't work if we can't restrict permissions on the
+        // directory, so skip it.
+        assumeTrue(chmod(nested, 0, false));
+
+        try {
+            // cleanDirectory calls forceDelete
+            FileUtils.deleteDirectory(nested);
+            fail("expected IOException");
+        } catch (final IOException e) {
+            assertEquals("Unknown I/O error listing contents of directory: " + nested.getAbsolutePath(), e.getMessage());
+        } finally {
+            chmod(nested, 755, false);
+            FileUtils.deleteDirectory(nested);
+        }
+        assertEquals(0, top.list().length);
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/FileUtilsDeleteDirectoryWindowsTest.java b/src/test/java/org/apache/commons/io/FileUtilsDeleteDirectoryWindowsTest.java
new file mode 100644
index 0000000..521ec6e
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileUtilsDeleteDirectoryWindowsTest.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.File;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.commons.lang3.ArrayUtils;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+/**
+ * Requires Windows admin karma or you get "You do not have sufficient privilege to perform this operation."
+ */
+@EnabledOnOs(OS.WINDOWS)
+public class FileUtilsDeleteDirectoryWindowsTest extends FileUtilsDeleteDirectoryBaseTest {
+
+    @Override
+    protected boolean setupSymlink(final File res, final File link) throws Exception {
+        // create symlink
+        final List<String> args = new ArrayList<>();
+        args.add("cmd");
+        args.add("/C");
+        // Requires Windows admin karma or you get "You do not have sufficient privilege to perform this operation."
+        args.add("mklink");
+
+        if (res.isDirectory()) {
+            args.add("/D");
+        }
+
+        args.add(link.getAbsolutePath());
+        args.add(res.getAbsolutePath());
+
+        final Process proc = Runtime.getRuntime().exec(args.toArray(ArrayUtils.EMPTY_STRING_ARRAY));
+        final InputStream errorStream = proc.getErrorStream();
+        final int rc = proc.waitFor();
+        System.err.print(IOUtils.toString(errorStream, Charset.defaultCharset()));
+        System.err.flush();
+        return rc == 0;
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/FileUtilsDirectoryContainsTest.java b/src/test/java/org/apache/commons/io/FileUtilsDirectoryContainsTest.java
new file mode 100644
index 0000000..bba9f09
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileUtilsDirectoryContainsTest.java
@@ -0,0 +1,173 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * This class ensure the correctness of {@link FileUtils#directoryContains(File,File)}.
+ *
+ * @see FileUtils#directoryContains(File, File)
+ * @since 2.2
+ */
+public class FileUtilsDirectoryContainsTest {
+
+    private File directory1;
+    private File directory2;
+    private File directory3;
+    private File file1;
+    private File file1ByRelativeDirectory2;
+    private File file2;
+    private File file2ByRelativeDirectory1;
+    private File file3;
+
+    @TempDir
+    public File top;
+
+    @SuppressWarnings("ResultOfMethodCallIgnored")
+    @BeforeEach
+    public void setUp() throws Exception {
+        directory1 = new File(top, "directory1");
+        directory2 = new File(top, "directory2");
+        directory3 = new File(directory2, "directory3");
+
+        directory1.mkdir();
+        directory2.mkdir();
+        directory3.mkdir();
+
+        file1 = new File(directory1, "file1");
+        file2 = new File(directory2, "file2");
+        file3 = new File(top, "file3");
+
+        // Tests case with relative path
+        file1ByRelativeDirectory2 = new File(top, "directory2/../directory1/file1");
+        file2ByRelativeDirectory1 = new File(top, "directory1/../directory2/file2");
+
+        FileUtils.touch(file1);
+        FileUtils.touch(file2);
+        FileUtils.touch(file3);
+    }
+
+    @Test
+    public void testCanonicalPath() throws IOException {
+        assertTrue(FileUtils.directoryContains(directory1, file1ByRelativeDirectory2));
+        assertTrue(FileUtils.directoryContains(directory2, file2ByRelativeDirectory1));
+
+        assertFalse(FileUtils.directoryContains(directory1, file2ByRelativeDirectory1));
+        assertFalse(FileUtils.directoryContains(directory2, file1ByRelativeDirectory2));
+    }
+
+    @Test
+    public void testDirectoryContainsDirectory() throws IOException {
+        assertTrue(FileUtils.directoryContains(top, directory1));
+        assertTrue(FileUtils.directoryContains(top, directory2));
+        assertTrue(FileUtils.directoryContains(top, directory3));
+        assertTrue(FileUtils.directoryContains(directory2, directory3));
+    }
+
+    @Test
+    public void testDirectoryContainsFile() throws IOException {
+        assertTrue(FileUtils.directoryContains(directory1, file1));
+        assertTrue(FileUtils.directoryContains(directory2, file2));
+    }
+
+    @Test
+    public void testDirectoryDoesNotContainFile() throws IOException {
+        assertFalse(FileUtils.directoryContains(directory1, file2));
+        assertFalse(FileUtils.directoryContains(directory2, file1));
+
+        assertFalse(FileUtils.directoryContains(directory1, file3));
+        assertFalse(FileUtils.directoryContains(directory2, file3));
+    }
+
+    @Test
+    public void testDirectoryDoesNotContainsDirectory() throws IOException {
+        assertFalse(FileUtils.directoryContains(directory1, top));
+        assertFalse(FileUtils.directoryContains(directory2, top));
+        assertFalse(FileUtils.directoryContains(directory3, top));
+        assertFalse(FileUtils.directoryContains(directory3, directory2));
+    }
+
+    @Test
+    public void testDirectoryDoesNotExist() throws IOException {
+        final File dir = new File("DOESNOTEXIST");
+        assertFalse(dir.exists());
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.directoryContains(dir, file1));
+    }
+
+    @Test
+    public void testFileDoesNotExist() throws IOException {
+        assertFalse(FileUtils.directoryContains(top, null));
+        final File file = new File("DOESNOTEXIST");
+        assertFalse(file.exists());
+        assertFalse(FileUtils.directoryContains(top, file));
+    }
+
+    /**
+     * Test to demonstrate a file which does not exist returns false
+     * @throws IOException If an I/O error occurs
+     */
+    @Test
+    public void testFileDoesNotExistBug() throws IOException {
+        final File file = new File(top, "DOESNOTEXIST");
+        assertTrue(top.exists(), "Check directory exists");
+        assertFalse(file.exists(), "Check file does not exist");
+        assertFalse(FileUtils.directoryContains(top, file), "Directory does not contain unrealized file");
+    }
+
+    @Test
+    public void testFileHavingSamePrefixBug() throws IOException {
+        final File foo = new File(top, "foo");
+        final File foobar = new File(top, "foobar");
+        final File fooTxt = new File(top, "foo.txt");
+        foo.mkdir();
+        foobar.mkdir();
+        FileUtils.touch(fooTxt);
+
+        assertFalse(FileUtils.directoryContains(foo, foobar));
+        assertFalse(FileUtils.directoryContains(foo, fooTxt));
+    }
+
+    @Test
+    public void testIO466() throws IOException {
+            final File fooFile = new File(directory1.getParent(), "directory1.txt");
+            assertFalse(FileUtils.directoryContains(directory1, fooFile));
+    }
+
+    @Test
+    public void testSameFile() throws IOException {
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.directoryContains(file1, file1));
+    }
+
+    @Test
+    public void testUnrealizedContainment() throws IOException {
+        final File dir = new File("DOESNOTEXIST");
+        final File file = new File(dir, "DOESNOTEXIST2");
+        assertFalse(dir.exists());
+        assertFalse(file.exists());
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.directoryContains(dir, file));
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/FileUtilsFileNewerTest.java b/src/test/java/org/apache/commons/io/FileUtilsFileNewerTest.java
new file mode 100644
index 0000000..51eef36
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileUtilsFileNewerTest.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.attribute.FileTime;
+import java.util.Date;
+
+import org.apache.commons.io.file.attribute.FileTimes;
+import org.apache.commons.io.test.TestUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * This is used to test FileUtils for correctness.
+ */
+public class FileUtilsFileNewerTest {
+
+    // Test data
+    private static final int FILE1_SIZE = 1;
+
+    private static final int FILE2_SIZE = 1024 * 4 + 1;
+    @TempDir
+    public File temporaryFolder;
+
+    private File testFile1;
+    private File testFile2;
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        testFile1 = new File(temporaryFolder, "file1-test.txt");
+        testFile2 = new File(temporaryFolder, "file2-test.txt");
+        if (!testFile1.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + testFile1 + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output1 = new BufferedOutputStream(Files.newOutputStream(testFile1.toPath()))) {
+            TestUtils.generateTestData(output1, FILE1_SIZE);
+        }
+        if (!testFile2.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + testFile2 + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(testFile2.toPath()))) {
+            TestUtils.generateTestData(output, FILE2_SIZE);
+        }
+    }
+
+    /**
+     * Tests the {@code isFileNewer(File, *)} methods which a "normal" file.
+     *
+     * @throws IOException
+     *
+     * @see FileUtils#isFileNewer(File, long)
+     * @see FileUtils#isFileNewer(File, Date)
+     * @see FileUtils#isFileNewer(File, File)
+     */
+    @Test
+    public void testIsFileNewer() throws IOException {
+        if (!testFile1.exists()) {
+            throw new IllegalStateException("The testFile1 should exist");
+        }
+
+        final FileTime fileLastModified = Files.getLastModifiedTime(testFile1.toPath());
+        final long TWO_SECOND = 2;
+
+        testIsFileNewer("two second earlier is not newer", testFile1, FileTimes.plusSeconds(fileLastModified, TWO_SECOND), false);
+        testIsFileNewer("same time is not newer", testFile1, fileLastModified, false);
+        testIsFileNewer("two second later is newer", testFile1, FileTimes.minusSeconds(fileLastModified, TWO_SECOND), true);
+    }
+
+    /**
+     * Tests the {@code isFileNewer(File, *)} methods which the specified conditions.
+     *
+     * Creates :
+     * <ul>
+     * <li>a {@code Date} which represents the time reference</li>
+     * <li>a temporary file with the same last modification date as the time reference</li>
+     * </ul>
+     * Then compares (with the needed {@code isFileNewer} method) the last modification date of the specified file with the
+     * specified time reference, the created {@code Date} and the temporary file.
+     * <p>
+     * The test is successful if the three comparisons return the specified wanted result.
+     *
+     * @param description describes the tested situation
+     * @param file the file of which the last modification date is compared
+     * @param fileTime the time reference measured in milliseconds since the epoch
+     * @param wantedResult the expected result
+     * @throws IOException if an I/O error occurs.
+     */
+    protected void testIsFileNewer(final String description, final File file, final FileTime fileTime, final boolean wantedResult) throws IOException {
+        assertEquals(wantedResult, FileUtils.isFileNewer(file, fileTime), () -> description + " - FileTime");
+        assertEquals(wantedResult, FileUtils.isFileNewer(file, fileTime.toInstant()), () -> description + " - Instant");
+
+        final File temporaryFile = testFile2;
+        Files.setLastModifiedTime(temporaryFile.toPath(), fileTime);
+        assertEquals(fileTime, Files.getLastModifiedTime(temporaryFile.toPath()), "The temporary file hasn't the right last modification date");
+        assertEquals(wantedResult, FileUtils.isFileNewer(file, temporaryFile), () -> description + " - file");
+    }
+
+    /**
+     * Tests the {@code isFileNewer(File, *)} methods which a not existing file.
+     *
+     * @throws IOException if an I/O error occurs.
+     *
+     * @see FileUtils#isFileNewer(File, long)
+     * @see FileUtils#isFileNewer(File, Date)
+     * @see FileUtils#isFileNewer(File, File)
+     */
+    @Test
+    public void testIsFileNewerImaginaryFile() throws IOException {
+        final File imaginaryFile = new File(temporaryFolder, "imaginaryFile");
+        if (imaginaryFile.exists()) {
+            throw new IllegalStateException("The imaginary File exists");
+        }
+
+        testIsFileNewer("imaginary file can be newer", imaginaryFile, FileUtils.lastModifiedFileTime(testFile2), false);
+    }
+
+    /**
+     * Tests the {@code isFileNewer(File, Date)} method without specifying a {@code Date}.
+     * <p>
+     * The test is successful if the method throws an {@code IllegalArgumentException}.
+     * </p>
+     */
+    @Test
+    public void testIsFileNewerNoDate() {
+        assertThrows(NullPointerException.class, () -> FileUtils.isFileNewer(testFile1, (Date) null), "date");
+    }
+
+    /**
+     * Tests the {@code isFileNewer(File, long)} method without specifying a {@code File}.
+     * <p>
+     * The test is successful if the method throws an {@code IllegalArgumentException}.
+     * </p>
+     */
+    @Test
+    public void testIsFileNewerNoFile() {
+        assertThrows(NullPointerException.class, () -> FileUtils.isFileNewer(null, 0), "file");
+    }
+
+    /**
+     * Tests the {@code isFileNewer(File, File)} method without specifying a reference {@code File}.
+     * <p>
+     * The test is successful if the method throws an {@code IllegalArgumentException}.
+     * </p>
+     */
+    @Test
+    public void testIsFileNewerNoFileReference() {
+        assertThrows(NullPointerException.class, () -> FileUtils.isFileNewer(testFile1, (File) null), "reference");
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/FileUtilsListFilesTest.java b/src/test/java/org/apache/commons/io/FileUtilsListFilesTest.java
new file mode 100644
index 0000000..9603d73
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileUtilsListFilesTest.java
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.stream.Collectors;
+
+import org.apache.commons.io.filefilter.FileFilterUtils;
+import org.apache.commons.io.filefilter.IOFileFilter;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Test cases for FileUtils.listFiles() methods.
+ */
+public class FileUtilsListFilesTest {
+
+    @TempDir
+    public File temporaryFolder;
+
+    private Collection<String> filesToFilenames(final Collection<File> files) {
+        return files.stream().map(File::getName).collect(Collectors.toList());
+    }
+
+    private Collection<String> filesToFilenames(final Iterator<File> files) {
+        final Collection<String> fileNames = new ArrayList<>();
+        files.forEachRemaining(f -> fileNames.add(f.getName()));
+        return fileNames;
+    }
+
+    @SuppressWarnings("ResultOfMethodCallIgnored")
+    @BeforeEach
+    public void setUp() throws Exception {
+        File dir = temporaryFolder;
+        File file = new File(dir, "dummy-build.xml");
+        FileUtils.touch(file);
+        file = new File(dir, "README");
+        FileUtils.touch(file);
+
+        dir = new File(dir, "subdir1");
+        dir.mkdirs();
+        file = new File(dir, "dummy-build.xml");
+        FileUtils.touch(file);
+        file = new File(dir, "dummy-readme.txt");
+        FileUtils.touch(file);
+
+        dir = new File(dir, "subsubdir1");
+        dir.mkdirs();
+        file = new File(dir, "dummy-file.txt");
+        FileUtils.touch(file);
+        file = new File(dir, "dummy-index.html");
+        FileUtils.touch(file);
+
+        dir = dir.getParentFile();
+        dir = new File(dir, "CVS");
+        dir.mkdirs();
+        file = new File(dir, "Entries");
+        FileUtils.touch(file);
+        file = new File(dir, "Repository");
+        FileUtils.touch(file);
+    }
+
+    @Test
+    public void testIterateFilesByExtension() {
+        final String[] extensions = { "xml", "txt" };
+
+        Iterator<File> files = FileUtils.iterateFiles(temporaryFolder, extensions, false);
+        Collection<String> filenames = filesToFilenames(files);
+        assertEquals(1, filenames.size());
+        assertTrue(filenames.contains("dummy-build.xml"));
+        assertFalse(filenames.contains("README"));
+        assertFalse(filenames.contains("dummy-file.txt"));
+
+        files = FileUtils.iterateFiles(temporaryFolder, extensions, true);
+        filenames = filesToFilenames(files);
+        assertEquals(4, filenames.size());
+        assertTrue(filenames.contains("dummy-file.txt"));
+        assertFalse(filenames.contains("dummy-index.html"));
+
+        files = FileUtils.iterateFiles(temporaryFolder, null, false);
+        filenames = filesToFilenames(files);
+        assertEquals(2, filenames.size());
+        assertTrue(filenames.contains("dummy-build.xml"));
+        assertTrue(filenames.contains("README"));
+        assertFalse(filenames.contains("dummy-file.txt"));
+    }
+
+    @Test
+    public void testListFiles() {
+        Collection<File> files;
+        Collection<String> filenames;
+        IOFileFilter fileFilter;
+        IOFileFilter dirFilter;
+
+        // First, find non-recursively
+        fileFilter = FileFilterUtils.trueFileFilter();
+        files = FileUtils.listFiles(temporaryFolder, fileFilter, null);
+        filenames = filesToFilenames(files);
+        assertTrue(filenames.contains("dummy-build.xml"), "'dummy-build.xml' is missing");
+        assertFalse(filenames.contains("dummy-index.html"), "'dummy-index.html' shouldn't be found");
+        assertFalse(filenames.contains("Entries"), "'Entries' shouldn't be found");
+
+        // Second, find recursively
+        fileFilter = FileFilterUtils.trueFileFilter();
+        dirFilter = FileFilterUtils.notFileFilter(FileFilterUtils.nameFileFilter("CVS"));
+        files = FileUtils.listFiles(temporaryFolder, fileFilter, dirFilter);
+        filenames = filesToFilenames(files);
+        assertTrue(filenames.contains("dummy-build.xml"), "'dummy-build.xml' is missing");
+        assertTrue(filenames.contains("dummy-index.html"), "'dummy-index.html' is missing");
+        assertFalse(filenames.contains("Entries"), "'Entries' shouldn't be found");
+
+        // Do the same as above but now with the filter coming from FileFilterUtils
+        fileFilter = FileFilterUtils.trueFileFilter();
+        dirFilter = FileFilterUtils.makeCVSAware(null);
+        files = FileUtils.listFiles(temporaryFolder, fileFilter, dirFilter);
+        filenames = filesToFilenames(files);
+        assertTrue(filenames.contains("dummy-build.xml"), "'dummy-build.xml' is missing");
+        assertTrue(filenames.contains("dummy-index.html"), "'dummy-index.html' is missing");
+        assertFalse(filenames.contains("Entries"), "'Entries' shouldn't be found");
+
+        // Again with the CVS filter but now with a non-null parameter
+        fileFilter = FileFilterUtils.trueFileFilter();
+        dirFilter = FileFilterUtils.prefixFileFilter("sub");
+        dirFilter = FileFilterUtils.makeCVSAware(dirFilter);
+        files = FileUtils.listFiles(temporaryFolder, fileFilter, dirFilter);
+        filenames = filesToFilenames(files);
+        assertTrue(filenames.contains("dummy-build.xml"), "'dummy-build.xml' is missing");
+        assertTrue(filenames.contains("dummy-index.html"), "'dummy-index.html' is missing");
+        assertFalse(filenames.contains("Entries"), "'Entries' shouldn't be found");
+
+        assertThrows(NullPointerException.class, () -> FileUtils.listFiles(temporaryFolder, null, null));
+    }
+
+    @Test
+    public void testListFilesByExtension() {
+        final String[] extensions = {"xml", "txt"};
+
+        Collection<File> files = FileUtils.listFiles(temporaryFolder, extensions, false);
+        assertEquals(1, files.size());
+        Collection<String> filenames = filesToFilenames(files);
+        assertTrue(filenames.contains("dummy-build.xml"));
+        assertFalse(filenames.contains("README"));
+        assertFalse(filenames.contains("dummy-file.txt"));
+
+        files = FileUtils.listFiles(temporaryFolder, extensions, true);
+        filenames = filesToFilenames(files);
+        assertEquals(4, filenames.size());
+        assertTrue(filenames.contains("dummy-file.txt"));
+        assertFalse(filenames.contains("dummy-index.html"));
+
+        files = FileUtils.listFiles(temporaryFolder, null, false);
+        assertEquals(2, files.size());
+        filenames = filesToFilenames(files);
+        assertTrue(filenames.contains("dummy-build.xml"));
+        assertTrue(filenames.contains("README"));
+        assertFalse(filenames.contains("dummy-file.txt"));
+    }
+
+
+}
diff --git a/src/test/java/org/apache/commons/io/FileUtilsTest.java b/src/test/java/org/apache/commons/io/FileUtilsTest.java
new file mode 100644
index 0000000..6aa926e
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileUtilsTest.java
@@ -0,0 +1,3119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.BufferedOutputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.math.BigInteger;
+import java.net.URL;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.FileTime;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.zip.CRC32;
+import java.util.zip.Checksum;
+
+import org.apache.commons.io.file.AbstractTempDirTest;
+import org.apache.commons.io.file.PathUtils;
+import org.apache.commons.io.file.PathUtilsIsEmptyTest;
+import org.apache.commons.io.file.TempDirectory;
+import org.apache.commons.io.file.TempFile;
+import org.apache.commons.io.filefilter.IOFileFilter;
+import org.apache.commons.io.filefilter.NameFileFilter;
+import org.apache.commons.io.filefilter.WildcardFileFilter;
+import org.apache.commons.io.test.TestUtils;
+import org.apache.commons.lang3.SystemUtils;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * This is used to test FileUtils for correctness.
+ *
+ * @see FileUtils
+ */
+@SuppressWarnings({"deprecation", "ResultOfMethodCallIgnored"}) // unit tests include tests of many deprecated methods
+public class FileUtilsTest extends AbstractTempDirTest {
+
+    /**
+     * DirectoryWalker implementation that recursively lists all files and directories.
+     */
+    static class ListDirectoryWalker extends DirectoryWalker<File> {
+
+        ListDirectoryWalker() {
+        }
+
+        @Override
+        protected void handleDirectoryStart(final File directory, final int depth, final Collection<File> results) throws IOException {
+            // Add all directories except the starting directory
+            if (depth > 0) {
+                results.add(directory);
+            }
+        }
+
+        @Override
+        protected void handleFile(final File file, final int depth, final Collection<File> results) throws IOException {
+            results.add(file);
+        }
+
+        List<File> list(final File startDirectory) throws IOException {
+            final ArrayList<File> files = new ArrayList<>();
+            walk(startDirectory, files);
+            return files;
+        }
+    }
+
+    // Test helper class to pretend a file is shorter than it is
+    private static class ShorterFile extends File {
+        private static final long serialVersionUID = 1L;
+
+        public ShorterFile(final String pathname) {
+            super(pathname);
+        }
+
+        @Override
+        public long length() {
+            return super.length() - 1;
+        }
+    }
+
+    private static final String UTF_8 = StandardCharsets.UTF_8.name();
+
+    /** Test data. */
+    private static final long DATE3 = 1000000002000L;
+
+    /** Test data. */
+    private static final long DATE2 = 1000000001000L;
+
+    /** Test data. */
+    private static final long DATE1 = 1000000000000L;
+
+    /**
+     * Size of test directory.
+     */
+    private static final int TEST_DIRECTORY_SIZE = 0;
+
+    /**
+     * Size of test directory.
+     */
+    private static final BigInteger TEST_DIRECTORY_SIZE_BI = BigInteger.ZERO;
+
+    /**
+     * Size (greater of zero) of test file.
+     */
+    private static final BigInteger TEST_DIRECTORY_SIZE_GT_ZERO_BI = BigInteger.valueOf(100);
+
+    /**
+     * List files recursively
+     */
+    private static final ListDirectoryWalker LIST_WALKER = new ListDirectoryWalker();
+
+    /**
+     * Delay in milliseconds to make sure test for "last modified date" are accurate
+     */
+    //private static final int LAST_MODIFIED_DELAY = 600;
+
+    private File testFile1;
+    private File testFile2;
+
+    private long testFile1Size;
+
+    private long testFile2Size;
+
+    private void assertContentMatchesAfterCopyURLToFileFor(final String resourceName, final File destination) throws IOException {
+        FileUtils.copyURLToFile(getClass().getResource(resourceName), destination);
+
+        try (InputStream fis = Files.newInputStream(destination.toPath());
+             InputStream expected = getClass().getResourceAsStream(resourceName)) {
+            assertTrue(IOUtils.contentEquals(expected, fis), "Content is not equal.");
+        }
+    }
+
+    private void backDateFile10Minutes(final File testFile) throws IOException {
+        final long mins10 = 1000 * 60 * 10;
+        final long lastModified1 = getLastModifiedMillis(testFile);
+        assertTrue(setLastModifiedMillis(testFile, lastModified1 - mins10));
+        // ensure it was changed
+        assertNotEquals(getLastModifiedMillis(testFile), lastModified1, "Should have changed source date");
+    }
+
+    private void consumeRemaining(final Iterator<File> iterator) {
+        if (iterator != null) {
+            iterator.forEachRemaining(e -> {});
+        }
+    }
+
+    private Path createCircularOsSymLink(final String linkName, final String targetName) throws IOException {
+        return Files.createSymbolicLink(Paths.get(linkName), Paths.get(targetName));
+    }
+
+    /**
+     * May throw java.nio.file.FileSystemException: C:\Users\...\FileUtilsTestCase\cycle: A required privilege is not held
+     * by the client. On Windows, you are fine if you run a terminal with admin karma.
+     */
+    private void createCircularSymLink(final File file) throws IOException {
+        assertTrue(file.exists());
+        final String linkName = file + "/cycle";
+        final String targetName = file + "/..";
+        assertTrue(file.exists());
+        final Path linkPath = Paths.get(linkName);
+        assertFalse(Files.exists(linkPath));
+        final Path targetPath = Paths.get(targetName);
+        assertTrue(Files.exists(targetPath));
+        try {
+            // May throw java.nio.file.FileSystemException: C:\Users\...\FileUtilsTestCase\cycle: A required privilege is not held by the client.
+            // On Windows, you are fine if you run a terminal with admin karma.
+            Files.createSymbolicLink(linkPath, targetPath);
+        } catch (final UnsupportedOperationException e) {
+            e.printStackTrace();
+            createCircularOsSymLink(linkName, targetName);
+        }
+        // Sanity check:
+        assertTrue(Files.isSymbolicLink(linkPath), () -> "Expected a sym link here: " + linkName);
+    }
+
+    private void createFilesForTestCopyDirectory(final File grandParentDir, final File parentDir, final File childDir) throws IOException {
+        final File childDir2 = new File(parentDir, "child2");
+        final File grandChildDir = new File(childDir, "grandChild");
+        final File grandChild2Dir = new File(childDir2, "grandChild2");
+        final File file1 = new File(grandParentDir, "file1.txt");
+        final File file2 = new File(parentDir, "file2.txt");
+        final File file3 = new File(childDir, "file3.txt");
+        final File file4 = new File(childDir2, "file4.txt");
+        final File file5 = new File(grandChildDir, "file5.txt");
+        final File file6 = new File(grandChild2Dir, "file6.txt");
+        FileUtils.deleteDirectory(grandParentDir);
+        grandChildDir.mkdirs();
+        grandChild2Dir.mkdirs();
+        FileUtils.writeStringToFile(file1, "File 1 in grandparent", "UTF8");
+        FileUtils.writeStringToFile(file2, "File 2 in parent", "UTF8");
+        FileUtils.writeStringToFile(file3, "File 3 in child", "UTF8");
+        FileUtils.writeStringToFile(file4, "File 4 in child2", "UTF8");
+        FileUtils.writeStringToFile(file5, "File 5 in grandChild", "UTF8");
+        FileUtils.writeStringToFile(file6, "File 6 in grandChild2", "UTF8");
+    }
+
+    private Path createTempSymlinkedRelativeDir() throws IOException {
+        final Path targetDir = tempDirPath.resolve("subdir");
+        final Path symlinkDir = tempDirPath.resolve("symlinked-dir");
+        Files.createDirectory(targetDir);
+        Files.createSymbolicLink(symlinkDir, targetDir);
+        return symlinkDir;
+    }
+
+    private Set<String> getFilePathSet(final List<File> files) {
+        return files.stream().map(f -> {
+            try {
+                return f.getCanonicalPath();
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+        }).collect(Collectors.toSet());
+    }
+
+    private long getLastModifiedMillis(final File file) throws IOException {
+        return FileUtils.lastModified(file);
+    }
+
+    private String getName() {
+        return this.getClass().getSimpleName();
+    }
+
+    private void iterateFilesAndDirs(final File dir, final IOFileFilter fileFilter,
+        final IOFileFilter dirFilter, final Collection<File> expectedFilesAndDirs) {
+        final Iterator<File> iterator;
+        int filesCount = 0;
+        iterator = FileUtils.iterateFilesAndDirs(dir, fileFilter, dirFilter);
+        try {
+            final List<File> actualFiles = new ArrayList<>();
+            while (iterator.hasNext()) {
+                filesCount++;
+                final File file = iterator.next();
+                actualFiles.add(file);
+                assertTrue(expectedFilesAndDirs.contains(file),
+                    () -> "Unexpected directory/file " + file + ", expected one of " + expectedFilesAndDirs);
+            }
+            assertEquals(expectedFilesAndDirs.size(), filesCount, actualFiles::toString);
+        } finally {
+            // MUST consume until the end in order to close the underlying stream.
+            consumeRemaining(iterator);
+        }
+    }
+
+    void openOutputStream_noParent(final boolean createFile) throws Exception {
+        final File file = new File("test.txt");
+        assertNull(file.getParentFile());
+        try {
+            if (createFile) {
+                TestUtils.createLineBasedFile(file, new String[]{"Hello"});
+            }
+            try (FileOutputStream out = FileUtils.openOutputStream(file)) {
+                out.write(0);
+            }
+            assertTrue(file.exists());
+        } finally {
+            if (!file.delete()) {
+                file.deleteOnExit();
+            }
+        }
+    }
+
+    private boolean setLastModifiedMillis(final File testFile, final long millis) {
+        return testFile.setLastModified(millis);
+//        try {
+//            Files.setLastModifiedTime(testFile.toPath(), FileTime.fromMillis(millis));
+//        } catch (IOException e) {
+//            return false;
+//        }
+//        return true;
+    }
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        testFile1 = new File(tempDirFile, "file1-test.txt");
+        testFile2 = new File(tempDirFile, "file1a-test.txt");
+
+        testFile1Size = testFile1.length();
+        testFile2Size = testFile2.length();
+        if (!testFile1.getParentFile().exists()) {
+            fail("Cannot create file " + testFile1
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output3 =
+                new BufferedOutputStream(Files.newOutputStream(testFile1.toPath()))) {
+            TestUtils.generateTestData(output3, testFile1Size);
+        }
+        if (!testFile2.getParentFile().exists()) {
+            fail("Cannot create file " + testFile2
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output2 =
+                new BufferedOutputStream(Files.newOutputStream(testFile2.toPath()))) {
+            TestUtils.generateTestData(output2, testFile2Size);
+        }
+        FileUtils.deleteDirectory(tempDirFile);
+        tempDirFile.mkdirs();
+        if (!testFile1.getParentFile().exists()) {
+            fail("Cannot create file " + testFile1
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output1 =
+                new BufferedOutputStream(Files.newOutputStream(testFile1.toPath()))) {
+            TestUtils.generateTestData(output1, testFile1Size);
+        }
+        if (!testFile2.getParentFile().exists()) {
+            fail("Cannot create file " + testFile2
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output =
+                new BufferedOutputStream(Files.newOutputStream(testFile2.toPath()))) {
+            TestUtils.generateTestData(output, testFile2Size);
+        }
+    }
+
+    @Test
+    public void test_openInputStream_exists() throws Exception {
+        final File file = new File(tempDirFile, "test.txt");
+        TestUtils.createLineBasedFile(file, new String[]{"Hello"});
+        try (FileInputStream in = FileUtils.openInputStream(file)) {
+            assertEquals('H', in.read());
+        }
+    }
+
+    @Test
+    public void test_openInputStream_existsButIsDirectory() {
+        final File directory = new File(tempDirFile, "subdir");
+        directory.mkdirs();
+        assertThrows(IOException.class, () -> FileUtils.openInputStream(directory));
+    }
+
+    @Test
+    public void test_openInputStream_notExists() {
+        final File directory = new File(tempDirFile, "test.txt");
+        assertThrows(IOException.class, () -> FileUtils.openInputStream(directory));
+    }
+
+    @Test
+    public void test_openOutputStream_exists() throws Exception {
+        final File file = new File(tempDirFile, "test.txt");
+        TestUtils.createLineBasedFile(file, new String[]{"Hello"});
+        try (FileOutputStream out = FileUtils.openOutputStream(file)) {
+            out.write(0);
+        }
+        assertTrue(file.exists());
+    }
+
+    @Test
+    public void test_openOutputStream_existsButIsDirectory() {
+        final File directory = new File(tempDirFile, "subdir");
+        directory.mkdirs();
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.openOutputStream(directory));
+    }
+
+    @Test
+    public void test_openOutputStream_intoExistingSymlinkedDir() throws Exception {
+        final Path symlinkedDir = createTempSymlinkedRelativeDir();
+
+        final File file = symlinkedDir.resolve("test.txt").toFile();
+        try (FileOutputStream out = FileUtils.openOutputStream(file)) {
+            out.write(0);
+        }
+        assertTrue(file.exists());
+    }
+
+    @Test
+    public void test_openOutputStream_noParentCreateFile() throws Exception {
+        openOutputStream_noParent(true);
+    }
+
+    @Test
+    public void test_openOutputStream_noParentNoFile() throws Exception {
+        openOutputStream_noParent(false);
+    }
+
+    @Test
+    public void test_openOutputStream_notExists() throws Exception {
+        final File file = new File(tempDirFile, "a/test.txt");
+        try (FileOutputStream out = FileUtils.openOutputStream(file)) {
+            out.write(0);
+        }
+        assertTrue(file.exists());
+    }
+
+    @Test
+    public void test_openOutputStream_notExistsCannotCreate() {
+        // according to Wikipedia, most filing systems have a 256 limit on filename
+        final String longStr =
+                "abcdevwxyzabcdevwxyzabcdevwxyzabcdevwxyzabcdevwxyz" +
+                        "abcdevwxyzabcdevwxyzabcdevwxyzabcdevwxyzabcdevwxyz" +
+                        "abcdevwxyzabcdevwxyzabcdevwxyzabcdevwxyzabcdevwxyz" +
+                        "abcdevwxyzabcdevwxyzabcdevwxyzabcdevwxyzabcdevwxyz" +
+                        "abcdevwxyzabcdevwxyzabcdevwxyzabcdevwxyzabcdevwxyz" +
+                        "abcdevwxyzabcdevwxyzabcdevwxyzabcdevwxyzabcdevwxyz";  // 300 chars
+        final File file = new File(tempDirFile, "a/" + longStr + "/test.txt");
+        assertThrows(IOException.class, () -> FileUtils.openOutputStream(file));
+    }
+
+    // byteCountToDisplaySize
+    @Test
+    public void testByteCountToDisplaySizeBigInteger() {
+        final BigInteger b1023 = BigInteger.valueOf(1023);
+        final BigInteger b1025 = BigInteger.valueOf(1025);
+        final BigInteger KB1 = BigInteger.valueOf(1024);
+        final BigInteger MB1 = KB1.multiply(KB1);
+        final BigInteger GB1 = MB1.multiply(KB1);
+        final BigInteger GB2 = GB1.add(GB1);
+        final BigInteger TB1 = GB1.multiply(KB1);
+        final BigInteger PB1 = TB1.multiply(KB1);
+        final BigInteger EB1 = PB1.multiply(KB1);
+        assertEquals("0 bytes", FileUtils.byteCountToDisplaySize(BigInteger.ZERO));
+        assertEquals("1 bytes", FileUtils.byteCountToDisplaySize(BigInteger.ONE));
+        assertEquals("1023 bytes", FileUtils.byteCountToDisplaySize(b1023));
+        assertEquals("1 KB", FileUtils.byteCountToDisplaySize(KB1));
+        assertEquals("1 KB", FileUtils.byteCountToDisplaySize(b1025));
+        assertEquals("1023 KB", FileUtils.byteCountToDisplaySize(MB1.subtract(BigInteger.ONE)));
+        assertEquals("1 MB", FileUtils.byteCountToDisplaySize(MB1));
+        assertEquals("1 MB", FileUtils.byteCountToDisplaySize(MB1.add(BigInteger.ONE)));
+        assertEquals("1023 MB", FileUtils.byteCountToDisplaySize(GB1.subtract(BigInteger.ONE)));
+        assertEquals("1 GB", FileUtils.byteCountToDisplaySize(GB1));
+        assertEquals("1 GB", FileUtils.byteCountToDisplaySize(GB1.add(BigInteger.ONE)));
+        assertEquals("2 GB", FileUtils.byteCountToDisplaySize(GB2));
+        assertEquals("1 GB", FileUtils.byteCountToDisplaySize(GB2.subtract(BigInteger.ONE)));
+        assertEquals("1 TB", FileUtils.byteCountToDisplaySize(TB1));
+        assertEquals("1 PB", FileUtils.byteCountToDisplaySize(PB1));
+        assertEquals("1 EB", FileUtils.byteCountToDisplaySize(EB1));
+        assertEquals("7 EB", FileUtils.byteCountToDisplaySize(Long.MAX_VALUE));
+        // Other MAX_VALUEs
+        assertEquals("63 KB", FileUtils.byteCountToDisplaySize(BigInteger.valueOf(Character.MAX_VALUE)));
+        assertEquals("31 KB", FileUtils.byteCountToDisplaySize(BigInteger.valueOf(Short.MAX_VALUE)));
+        assertEquals("1 GB", FileUtils.byteCountToDisplaySize(BigInteger.valueOf(Integer.MAX_VALUE)));
+    }
+
+    @SuppressWarnings("NumericOverflow")
+    @Test
+    public void testByteCountToDisplaySizeLong() {
+        assertEquals("0 bytes", FileUtils.byteCountToDisplaySize(0));
+        assertEquals("1 bytes", FileUtils.byteCountToDisplaySize(1));
+        assertEquals("1023 bytes", FileUtils.byteCountToDisplaySize(1023));
+        assertEquals("1 KB", FileUtils.byteCountToDisplaySize(1024));
+        assertEquals("1 KB", FileUtils.byteCountToDisplaySize(1025));
+        assertEquals("1023 KB", FileUtils.byteCountToDisplaySize(1024 * 1023));
+        assertEquals("1 MB", FileUtils.byteCountToDisplaySize(1024 * 1024));
+        assertEquals("1 MB", FileUtils.byteCountToDisplaySize(1024 * 1025));
+        assertEquals("1023 MB", FileUtils.byteCountToDisplaySize(1024 * 1024 * 1023));
+        assertEquals("1 GB", FileUtils.byteCountToDisplaySize(1024 * 1024 * 1024));
+        assertEquals("1 GB", FileUtils.byteCountToDisplaySize(1024 * 1024 * 1025));
+        assertEquals("2 GB", FileUtils.byteCountToDisplaySize(1024L * 1024 * 1024 * 2));
+        assertEquals("1 GB", FileUtils.byteCountToDisplaySize(1024 * 1024 * 1024 * 2 - 1));
+        assertEquals("1 TB", FileUtils.byteCountToDisplaySize(1024L * 1024 * 1024 * 1024));
+        assertEquals("1 PB", FileUtils.byteCountToDisplaySize(1024L * 1024 * 1024 * 1024 * 1024));
+        assertEquals("1 EB", FileUtils.byteCountToDisplaySize(1024L * 1024 * 1024 * 1024 * 1024 * 1024));
+        assertEquals("7 EB", FileUtils.byteCountToDisplaySize(Long.MAX_VALUE));
+        // Other MAX_VALUEs
+        assertEquals("63 KB", FileUtils.byteCountToDisplaySize(Character.MAX_VALUE));
+        assertEquals("31 KB", FileUtils.byteCountToDisplaySize(Short.MAX_VALUE));
+        assertEquals("1 GB", FileUtils.byteCountToDisplaySize(Integer.MAX_VALUE));
+    }
+
+    @Test
+    public void testByteCountToDisplaySizeNumber() {
+        assertEquals("0 bytes", FileUtils.byteCountToDisplaySize(Integer.valueOf(0)));
+        assertEquals("1 bytes", FileUtils.byteCountToDisplaySize(Integer.valueOf(1)));
+        assertEquals("1023 bytes", FileUtils.byteCountToDisplaySize(Integer.valueOf(1023)));
+        assertEquals("1 KB", FileUtils.byteCountToDisplaySize(Integer.valueOf(1024)));
+        assertEquals("1 KB", FileUtils.byteCountToDisplaySize(Integer.valueOf(1025)));
+        assertEquals("1023 KB", FileUtils.byteCountToDisplaySize(Long.valueOf(1024 * 1023)));
+        assertEquals("1 MB", FileUtils.byteCountToDisplaySize(Long.valueOf(1024 * 1024)));
+        assertEquals("1 MB", FileUtils.byteCountToDisplaySize(Long.valueOf(1024 * 1025)));
+        assertEquals("1023 MB", FileUtils.byteCountToDisplaySize(Long.valueOf(1024 * 1024 * 1023)));
+        assertEquals("1 GB", FileUtils.byteCountToDisplaySize(Long.valueOf(1024 * 1024 * 1024)));
+        assertEquals("1 GB", FileUtils.byteCountToDisplaySize(Long.valueOf(1024 * 1024 * 1025)));
+        assertEquals("2 GB", FileUtils.byteCountToDisplaySize(Long.valueOf(1024L * 1024 * 1024 * 2)));
+        assertEquals("1 GB", FileUtils.byteCountToDisplaySize(Long.valueOf(1024 * 1024 * 1024 * 2 - 1)));
+        assertEquals("1 TB", FileUtils.byteCountToDisplaySize(Long.valueOf(1024L * 1024 * 1024 * 1024)));
+        assertEquals("1 PB", FileUtils.byteCountToDisplaySize(Long.valueOf(1024L * 1024 * 1024 * 1024 * 1024)));
+        assertEquals("1 EB", FileUtils.byteCountToDisplaySize(Long.valueOf(1024L * 1024 * 1024 * 1024 * 1024 * 1024)));
+        assertEquals("7 EB", FileUtils.byteCountToDisplaySize(Long.valueOf(Long.MAX_VALUE)));
+        // Other MAX_VALUEs
+        assertEquals("63 KB", FileUtils.byteCountToDisplaySize(Integer.valueOf(Character.MAX_VALUE)));
+        assertEquals("31 KB", FileUtils.byteCountToDisplaySize(Short.valueOf(Short.MAX_VALUE)));
+        assertEquals("1 GB", FileUtils.byteCountToDisplaySize(Integer.valueOf(Integer.MAX_VALUE)));
+    }
+
+    @Test
+    public void testChecksum() throws Exception {
+        // create a test file
+        final String text = "Imagination is more important than knowledge - Einstein";
+        final File file = new File(tempDirFile, "checksum-test.txt");
+        FileUtils.writeStringToFile(file, text, "US-ASCII");
+
+        // compute the expected checksum
+        final Checksum expectedChecksum = new CRC32();
+        expectedChecksum.update(text.getBytes(StandardCharsets.US_ASCII), 0, text.length());
+        final long expectedValue = expectedChecksum.getValue();
+
+        // compute the checksum of the file
+        final Checksum testChecksum = new CRC32();
+        final Checksum resultChecksum = FileUtils.checksum(file, testChecksum);
+        final long resultValue = resultChecksum.getValue();
+
+        assertSame(testChecksum, resultChecksum);
+        assertEquals(expectedValue, resultValue);
+    }
+
+    @Test
+    public void testChecksumCRC32() throws Exception {
+        // create a test file
+        final String text = "Imagination is more important than knowledge - Einstein";
+        final File file = new File(tempDirFile, "checksum-test.txt");
+        FileUtils.writeStringToFile(file, text, "US-ASCII");
+
+        // compute the expected checksum
+        final Checksum expectedChecksum = new CRC32();
+        expectedChecksum.update(text.getBytes(StandardCharsets.US_ASCII), 0, text.length());
+        final long expectedValue = expectedChecksum.getValue();
+
+        // compute the checksum of the file
+        final long resultValue = FileUtils.checksumCRC32(file);
+
+        assertEquals(expectedValue, resultValue);
+    }
+
+    @Test
+    public void testChecksumDouble() throws Exception {
+        // create a test file
+        final String text1 = "Imagination is more important than knowledge - Einstein";
+        final File file1 = new File(tempDirFile, "checksum-test.txt");
+        FileUtils.writeStringToFile(file1, text1, "US-ASCII");
+
+        // create a second test file
+        final String text2 = "To be or not to be - Shakespeare";
+        final File file2 = new File(tempDirFile, "checksum-test2.txt");
+        FileUtils.writeStringToFile(file2, text2, "US-ASCII");
+
+        // compute the expected checksum
+        final Checksum expectedChecksum = new CRC32();
+        expectedChecksum.update(text1.getBytes(StandardCharsets.US_ASCII), 0, text1.length());
+        expectedChecksum.update(text2.getBytes(StandardCharsets.US_ASCII), 0, text2.length());
+        final long expectedValue = expectedChecksum.getValue();
+
+        // compute the checksum of the file
+        final Checksum testChecksum = new CRC32();
+        FileUtils.checksum(file1, testChecksum);
+        FileUtils.checksum(file2, testChecksum);
+        final long resultValue = testChecksum.getValue();
+
+        assertEquals(expectedValue, resultValue);
+    }
+
+    @Test
+    public void testChecksumOnDirectory() {
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.checksum(FileUtils.current(), new CRC32()));
+    }
+
+    @Test
+    public void testChecksumOnNullChecksum() throws Exception {
+        // create a test file
+        final String text = "Imagination is more important than knowledge - Einstein";
+        final File file = new File(tempDirFile, "checksum-test.txt");
+        FileUtils.writeStringToFile(file, text, "US-ASCII");
+        assertThrows(NullPointerException.class, () -> FileUtils.checksum(file, null));
+    }
+
+    @Test
+    public void testChecksumOnNullFile() {
+        assertThrows(NullPointerException.class, () -> FileUtils.checksum(null, new CRC32()));
+    }
+
+    // Compare sizes of a directory tree using long and BigInteger methods
+    @Test
+    public void testCompareSizeOf() {
+        final File start = new File("src/test/java");
+        final long sizeLong1 = FileUtils.sizeOf(start);
+        final BigInteger sizeBig = FileUtils.sizeOfAsBigInteger(start);
+        final long sizeLong2 = FileUtils.sizeOf(start);
+        assertEquals(sizeLong1, sizeLong2, "Size should not change");
+        assertEquals(sizeLong1, sizeBig.longValue(), "longSize should equal BigSize");
+    }
+
+    @Test
+    public void testContentEquals() throws Exception {
+        // Non-existent files
+        final File file = new File(tempDirFile, getName());
+        final File file2 = new File(tempDirFile, getName() + "2");
+        assertTrue(FileUtils.contentEquals(null, null));
+        assertFalse(FileUtils.contentEquals(null, file));
+        assertFalse(FileUtils.contentEquals(file, null));
+        // both don't  exist
+        assertTrue(FileUtils.contentEquals(file, file));
+        assertTrue(FileUtils.contentEquals(file, file2));
+        assertTrue(FileUtils.contentEquals(file2, file2));
+        assertTrue(FileUtils.contentEquals(file2, file));
+
+        // Directories
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.contentEquals(tempDirFile, tempDirFile));
+
+        // Different files
+        final File objFile1 =
+                new File(tempDirFile, getName() + ".object");
+        FileUtils.copyURLToFile(
+                getClass().getResource("/java/lang/Object.class"),
+                objFile1);
+
+        final File objFile1b =
+                new File(tempDirFile, getName() + ".object2");
+        FileUtils.copyURLToFile(
+                getClass().getResource("/java/lang/Object.class"),
+                objFile1b);
+
+        final File objFile2 =
+                new File(tempDirFile, getName() + ".collection");
+        FileUtils.copyURLToFile(
+                getClass().getResource("/java/util/Collection.class"),
+                objFile2);
+
+        assertFalse(FileUtils.contentEquals(objFile1, objFile2));
+        assertFalse(FileUtils.contentEquals(objFile1b, objFile2));
+        assertTrue(FileUtils.contentEquals(objFile1, objFile1b));
+
+        assertTrue(FileUtils.contentEquals(objFile1, objFile1));
+        assertTrue(FileUtils.contentEquals(objFile1b, objFile1b));
+        assertTrue(FileUtils.contentEquals(objFile2, objFile2));
+
+        // Equal files
+        file.createNewFile();
+        file2.createNewFile();
+        assertTrue(FileUtils.contentEquals(file, file));
+        assertTrue(FileUtils.contentEquals(file, file2));
+    }
+
+    @Test
+    public void testContentEqualsIgnoreEOL() throws Exception {
+        // Non-existent files
+        final File file1 = new File(tempDirFile, getName());
+        final File file2 = new File(tempDirFile, getName() + "2");
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(null, null, null));
+        assertFalse(FileUtils.contentEqualsIgnoreEOL(null, file1, null));
+        assertFalse(FileUtils.contentEqualsIgnoreEOL(file1, null, null));
+        // both don't  exist
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(file1, file1, null));
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(file1, file2, null));
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(file2, file2, null));
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(file2, file1, null));
+
+        // Directories
+        assertThrows(IllegalArgumentException.class,
+            () -> FileUtils.contentEqualsIgnoreEOL(tempDirFile, tempDirFile, null));
+
+        // Different files
+        final File tfile1 = new File(tempDirFile, getName() + ".txt1");
+        FileUtils.write(tfile1, "123\r");
+
+        final File tfile2 = new File(tempDirFile, getName() + ".txt2");
+        FileUtils.write(tfile2, "123\n");
+
+        final File tfile3 = new File(tempDirFile, getName() + ".collection");
+        FileUtils.write(tfile3, "123\r\n2");
+
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(tfile1, tfile1, null));
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(tfile2, tfile2, null));
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(tfile3, tfile3, null));
+
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(tfile1, tfile2, null));
+        assertFalse(FileUtils.contentEqualsIgnoreEOL(tfile1, tfile3, null));
+        assertFalse(FileUtils.contentEqualsIgnoreEOL(tfile2, tfile3, null));
+
+        final URL urlCR = getClass().getResource("FileUtilsTestDataCR.dat");
+        assertNotNull(urlCR);
+        final File cr = new File(urlCR.toURI());
+        assertTrue(cr.exists());
+
+        final URL urlCRLF = getClass().getResource("FileUtilsTestDataCRLF.dat");
+        assertNotNull(urlCRLF);
+        final File crlf = new File(urlCRLF.toURI());
+        assertTrue(crlf.exists());
+
+        final URL urlLF = getClass().getResource("FileUtilsTestDataLF.dat");
+        assertNotNull(urlLF);
+        final File lf = new File(urlLF.toURI());
+        assertTrue(lf.exists());
+
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(cr, cr, null));
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(crlf, crlf, null));
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(lf, lf, null));
+
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(cr, crlf, null));
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(cr, lf, null));
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(crlf, lf, null));
+
+        // Check the files behave OK when EOL is not ignored
+        assertTrue(FileUtils.contentEquals(cr, cr));
+        assertTrue(FileUtils.contentEquals(crlf, crlf));
+        assertTrue(FileUtils.contentEquals(lf, lf));
+
+        assertFalse(FileUtils.contentEquals(cr, crlf));
+        assertFalse(FileUtils.contentEquals(cr, lf));
+        assertFalse(FileUtils.contentEquals(crlf, lf));
+
+        // Equal files
+        file1.createNewFile();
+        file2.createNewFile();
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(file1, file1, null));
+        assertTrue(FileUtils.contentEqualsIgnoreEOL(file1, file2, null));
+    }
+
+    @Test
+    public void testCopyDirectoryExceptions() {
+        //
+        // NullPointerException
+        assertThrows(NullPointerException.class, () -> FileUtils.copyDirectory(null, null));
+        assertThrows(NullPointerException.class, () -> FileUtils.copyDirectory(null, testFile1));
+        assertThrows(NullPointerException.class, () -> FileUtils.copyDirectory(testFile1, null));
+        assertThrows(NullPointerException.class, () -> FileUtils.copyDirectory(null, new File("a")));
+        //
+        // IllegalArgumentException
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.copyDirectory(testFile1, new File("a")));
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.copyDirectory(testFile1, new File("a")));
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.copyDirectory(tempDirFile, tempDirFile));
+        //
+        // IOException
+        assertThrows(IOException.class, () -> FileUtils.copyDirectory(new File("doesnt-exist"), new File("a")));
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.copyDirectory(tempDirFile, testFile1));
+    }
+
+    @Test
+    public void testCopyDirectoryFiltered() throws IOException {
+        final File grandParentDir = new File(tempDirFile, "grandparent");
+        final File parentDir = new File(grandParentDir, "parent");
+        final File childDir = new File(parentDir, "child");
+        createFilesForTestCopyDirectory(grandParentDir, parentDir, childDir);
+
+        final NameFileFilter filter = new NameFileFilter("parent", "child", "file3.txt");
+        final File destDir = new File(tempDirFile, "copydest");
+
+        FileUtils.copyDirectory(grandParentDir, destDir, filter);
+        final List<File> files = LIST_WALKER.list(destDir);
+        assertEquals(3, files.size());
+        assertEquals("parent", files.get(0).getName());
+        assertEquals("child", files.get(1).getName());
+        assertEquals("file3.txt", files.get(2).getName());
+    }
+
+//   @Test public void testToURLs2() throws Exception {
+//        File[] files = new File[] {
+//            new File(temporaryFolder, "file1.txt"),
+//            null,
+//        };
+//        URL[] urls = FileUtils.toURLs(files);
+//
+//        assertEquals(files.length, urls.length);
+//        assertTrue(urls[0].toExternalForm().startsWith("file:"));
+//        assertTrue(urls[0].toExternalForm().indexOf("file1.txt") > 0);
+//        assertEquals(null, urls[1]);
+//    }
+//
+//   @Test public void testToURLs3() throws Exception {
+//        File[] files = null;
+//        URL[] urls = FileUtils.toURLs(files);
+//
+//        assertEquals(0, urls.length);
+//    }
+
+    @Test
+    public void testCopyDirectoryPreserveDates() throws Exception {
+        final File source = new File(tempDirFile, "source");
+        final File sourceDirectory = new File(source, "directory");
+        final File sourceFile = new File(sourceDirectory, "hello.txt");
+
+        // Prepare source data
+        source.mkdirs();
+        sourceDirectory.mkdir();
+        FileUtils.writeStringToFile(sourceFile, "HELLO WORLD", "UTF8");
+        // Set dates in reverse order to avoid overwriting previous values
+        // Also, use full seconds (arguments are in ms) close to today
+        // but still highly unlikely to occur in the real world
+        assertTrue(setLastModifiedMillis(sourceFile, DATE3));
+        assertTrue(setLastModifiedMillis(sourceDirectory, DATE2));
+        assertTrue(setLastModifiedMillis(source, DATE1));
+
+        final File target = new File(tempDirFile, "target");
+        final File targetDirectory = new File(target, "directory");
+        final File targetFile = new File(targetDirectory, "hello.txt");
+
+        // Test with preserveFileDate disabled
+        // On Windows, the last modified time is copied by default.
+        FileUtils.copyDirectory(source, target, false);
+        assertNotEquals(DATE1, getLastModifiedMillis(target));
+        assertNotEquals(DATE2, getLastModifiedMillis(targetDirectory));
+        if (!SystemUtils.IS_OS_WINDOWS) {
+            assertNotEquals(DATE3, getLastModifiedMillis(targetFile));
+        }
+        FileUtils.deleteDirectory(target);
+
+        // Test with preserveFileDate enabled
+        FileUtils.copyDirectory(source, target, true);
+        assertEquals(DATE1, getLastModifiedMillis(target));
+        assertEquals(DATE2, getLastModifiedMillis(targetDirectory));
+        assertEquals(DATE3, getLastModifiedMillis(targetFile));
+        FileUtils.deleteDirectory(target);
+
+        // also if the target directory already exists (IO-190)
+        target.mkdirs();
+        FileUtils.copyDirectory(source, target, true);
+        assertEquals(DATE1, getLastModifiedMillis(target));
+        assertEquals(DATE2, getLastModifiedMillis(targetDirectory));
+        assertEquals(DATE3, getLastModifiedMillis(targetFile));
+        FileUtils.deleteDirectory(target);
+
+        // also if the target subdirectory already exists (IO-190)
+        targetDirectory.mkdirs();
+        FileUtils.copyDirectory(source, target, true);
+        assertEquals(DATE1, getLastModifiedMillis(target));
+        assertEquals(DATE2, getLastModifiedMillis(targetDirectory));
+        assertEquals(DATE3, getLastModifiedMillis(targetFile));
+        FileUtils.deleteDirectory(target);
+    }
+
+    /** Tests IO-141 */
+    @Test
+    public void testCopyDirectoryToChild() throws IOException {
+        final File grandParentDir = new File(tempDirFile, "grandparent");
+        final File parentDir = new File(grandParentDir, "parent");
+        final File childDir = new File(parentDir, "child");
+        createFilesForTestCopyDirectory(grandParentDir, parentDir, childDir);
+
+        final long expectedCount = LIST_WALKER.list(grandParentDir).size() + LIST_WALKER.list(parentDir).size();
+        final long expectedSize = FileUtils.sizeOfDirectory(grandParentDir) + FileUtils.sizeOfDirectory(parentDir);
+        FileUtils.copyDirectory(parentDir, childDir);
+        assertEquals(expectedCount, LIST_WALKER.list(grandParentDir).size());
+        assertEquals(expectedSize, FileUtils.sizeOfDirectory(grandParentDir));
+        assertTrue(expectedCount > 0, "Count > 0");
+        assertTrue(expectedSize > 0, "Size > 0");
+    }
+
+    @Test
+    public void testCopyDirectoryToDirectory_NonExistingDest() throws Exception {
+        if (!testFile1.getParentFile().exists()) {
+            fail("Cannot create file " + testFile1
+                    + " as the parent directory does not exist");
+        }
+        try (final OutputStream output1 = new BufferedOutputStream(Files.newOutputStream(testFile1.toPath()))) {
+            TestUtils.generateTestData(output1, 1234);
+        }
+        if (!testFile2.getParentFile().exists()) {
+            fail("Cannot create file " + testFile2
+                    + " as the parent directory does not exist");
+        }
+        try (final OutputStream output = new BufferedOutputStream(Files.newOutputStream(testFile2.toPath()))) {
+            TestUtils.generateTestData(output, 4321);
+        }
+        final File srcDir = tempDirFile;
+        final File subDir = new File(srcDir, "sub");
+        subDir.mkdir();
+        final File subFile = new File(subDir, "A.txt");
+        FileUtils.writeStringToFile(subFile, "HELLO WORLD", "UTF8");
+        final File destDir = new File(FileUtils.getTempDirectoryPath(), "tmp-FileUtilsTestCase");
+        FileUtils.deleteDirectory(destDir);
+        final File actualDestDir = new File(destDir, srcDir.getName());
+
+        FileUtils.copyDirectoryToDirectory(srcDir, destDir);
+
+        assertTrue(destDir.exists(), "Check exists");
+        assertTrue(actualDestDir.exists(), "Check exists");
+        final long srcSize = FileUtils.sizeOfDirectory(srcDir);
+        assertTrue(srcSize > 0, "Size > 0");
+        assertEquals(srcSize, FileUtils.sizeOfDirectory(actualDestDir), "Check size");
+        assertTrue(new File(actualDestDir, "sub/A.txt").exists());
+        FileUtils.deleteDirectory(destDir);
+    }
+
+    @Test
+    public void testCopyDirectoryToExistingDest() throws Exception {
+        if (!testFile1.getParentFile().exists()) {
+            fail("Cannot create file " + testFile1
+                    + " as the parent directory does not exist");
+        }
+        try (final OutputStream output1 = new BufferedOutputStream(Files.newOutputStream(testFile1.toPath()))) {
+            TestUtils.generateTestData(output1, 1234);
+        }
+        if (!testFile2.getParentFile().exists()) {
+            fail("Cannot create file " + testFile2
+                    + " as the parent directory does not exist");
+        }
+        try (final OutputStream output = new BufferedOutputStream(Files.newOutputStream(testFile2.toPath()))) {
+            TestUtils.generateTestData(output, 4321);
+        }
+        final File srcDir = tempDirFile;
+        final File subDir = new File(srcDir, "sub");
+        subDir.mkdir();
+        final File subFile = new File(subDir, "A.txt");
+        FileUtils.writeStringToFile(subFile, "HELLO WORLD", "UTF8");
+        final File destDir = new File(System.getProperty("java.io.tmpdir"), "tmp-FileUtilsTestCase");
+        FileUtils.deleteDirectory(destDir);
+        destDir.mkdirs();
+
+        FileUtils.copyDirectory(srcDir, destDir);
+
+        final long srcSize = FileUtils.sizeOfDirectory(srcDir);
+        assertTrue(srcSize > 0, "Size > 0");
+        assertEquals(srcSize, FileUtils.sizeOfDirectory(destDir));
+        assertTrue(new File(destDir, "sub/A.txt").exists());
+    }
+
+    /** Test IO-141 */
+    @Test
+    public void testCopyDirectoryToGrandChild() throws IOException {
+        final File grandParentDir = new File(tempDirFile, "grandparent");
+        final File parentDir = new File(grandParentDir, "parent");
+        final File childDir = new File(parentDir, "child");
+        createFilesForTestCopyDirectory(grandParentDir, parentDir, childDir);
+
+        final long expectedCount = LIST_WALKER.list(grandParentDir).size() * 2;
+        final long expectedSize = FileUtils.sizeOfDirectory(grandParentDir) * 2;
+        FileUtils.copyDirectory(grandParentDir, childDir);
+        assertEquals(expectedCount, LIST_WALKER.list(grandParentDir).size());
+        assertEquals(expectedSize, FileUtils.sizeOfDirectory(grandParentDir));
+        assertTrue(expectedSize > 0, "Size > 0");
+    }
+
+    /** Tests IO-217 FileUtils.copyDirectoryToDirectory makes infinite loops */
+    @Test
+    public void testCopyDirectoryToItself() throws Exception {
+        final File dir = new File(tempDirFile, "itself");
+        dir.mkdirs();
+        FileUtils.copyDirectoryToDirectory(dir, dir);
+        assertEquals(1, LIST_WALKER.list(dir).size());
+    }
+
+    @Test
+    public void testCopyDirectoryToNonExistingDest() throws Exception {
+        if (!testFile1.getParentFile().exists()) {
+            fail("Cannot create file " + testFile1
+                    + " as the parent directory does not exist");
+        }
+        try (final OutputStream output1 = new BufferedOutputStream(Files.newOutputStream(testFile1.toPath()))) {
+            TestUtils.generateTestData(output1, 1234);
+        }
+        if (!testFile2.getParentFile().exists()) {
+            fail("Cannot create file " + testFile2
+                    + " as the parent directory does not exist");
+        }
+        try (final OutputStream output = new BufferedOutputStream(Files.newOutputStream(testFile2.toPath()));) {
+            TestUtils.generateTestData(output, 4321);
+        }
+        final File srcDir = tempDirFile;
+        final File subDir = new File(srcDir, "sub");
+        subDir.mkdir();
+        final File subFile = new File(subDir, "A.txt");
+        FileUtils.writeStringToFile(subFile, "HELLO WORLD", "UTF8");
+        final File destDir = new File(FileUtils.getTempDirectoryPath(), "tmp-FileUtilsTestCase");
+        FileUtils.deleteDirectory(destDir);
+
+        FileUtils.copyDirectory(srcDir, destDir);
+
+        assertTrue(destDir.exists(), "Check exists");
+        final long sizeOfSrcDirectory = FileUtils.sizeOfDirectory(srcDir);
+        assertTrue(sizeOfSrcDirectory > 0, "Size > 0");
+        assertEquals(sizeOfSrcDirectory, FileUtils.sizeOfDirectory(destDir), "Check size");
+        assertTrue(new File(destDir, "sub/A.txt").exists());
+        FileUtils.deleteDirectory(destDir);
+    }
+
+    /**
+     * Test for https://github.com/apache/commons-io/pull/371. The dir name 'par' is a substring of
+     * the dir name 'parent' which is the parent of the 'parent/child' dir.
+     */
+    @Test
+    public void testCopyDirectoryWithPotentialFalsePartialMatch() throws IOException {
+        final File grandParentDir = new File(tempDirFile, "grandparent");
+        final File parentDir = new File(grandParentDir, "parent");
+        final File parDir = new File(grandParentDir, "par");
+        final File childDir = new File(parentDir, "child");
+        createFilesForTestCopyDirectory(grandParentDir, parDir, childDir);
+
+        final List<File> initFiles = LIST_WALKER.list(grandParentDir);
+        final List<File> parFiles = LIST_WALKER.list(parDir);
+        final long expectedCount = initFiles.size() + parFiles.size();
+        final long expectedSize = FileUtils.sizeOfDirectory(grandParentDir) + FileUtils.sizeOfDirectory(parDir);
+        FileUtils.copyDirectory(parDir, childDir);
+        final List<File> latestFiles = LIST_WALKER.list(grandParentDir);
+        assertEquals(expectedCount, latestFiles.size());
+        assertEquals(expectedSize, FileUtils.sizeOfDirectory(grandParentDir));
+        assertTrue(expectedCount > 0, "Count > 0");
+        assertTrue(expectedSize > 0, "Size > 0");
+        final Set<String> initFilePaths = getFilePathSet(initFiles);
+        final Set<String> newFilePaths = getFilePathSet(latestFiles);
+        newFilePaths.removeAll(initFilePaths);
+        assertEquals(parFiles.size(), newFilePaths.size());
+    }
+
+    @Test
+    public void testCopyFile1() throws Exception {
+        final File destination = new File(tempDirFile, "copy1.txt");
+
+        backDateFile10Minutes(testFile1); // set test file back 10 minutes
+
+        FileUtils.copyFile(testFile1, destination);
+        assertTrue(destination.exists(), "Check Exist");
+        assertEquals(testFile1Size, destination.length(), "Check Full copy");
+        assertEquals(getLastModifiedMillis(testFile1), getLastModifiedMillis(destination), "Check last modified date preserved");
+    }
+
+    @Test
+    public void testCopyFile1ToDir() throws Exception {
+        final File directory = new File(tempDirFile, "subdir");
+        if (!directory.exists()) {
+            directory.mkdirs();
+        }
+        final File destination = new File(directory, testFile1.getName());
+
+        backDateFile10Minutes(testFile1);
+
+        FileUtils.copyFileToDirectory(testFile1, directory);
+        assertTrue(destination.exists(), "Check Exist");
+        assertEquals(testFile1Size, destination.length(), "Check Full copy");
+        assertEquals(FileUtils.lastModified(testFile1), FileUtils.lastModified(destination), "Check last modified date preserved");
+
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.copyFileToDirectory(destination, directory),
+            "Should not be able to copy a file into the same directory as itself");
+    }
+
+    @Test
+    public void testCopyFile2() throws Exception {
+        final File destination = new File(tempDirFile, "copy2.txt");
+
+        backDateFile10Minutes(testFile1); // set test file back 10 minutes
+
+        FileUtils.copyFile(testFile1, destination);
+        assertTrue(destination.exists(), "Check Exist");
+        assertEquals(testFile2Size, destination.length(), "Check Full copy");
+        assertEquals(getLastModifiedMillis(testFile1) , getLastModifiedMillis(destination), "Check last modified date preserved");
+    }
+
+    @Test
+    public void testCopyFile2ToDir() throws Exception {
+        final File directory = new File(tempDirFile, "subdir");
+        if (!directory.exists()) {
+            directory.mkdirs();
+        }
+        final File destination = new File(directory, testFile1.getName());
+
+        backDateFile10Minutes(testFile1);
+
+        FileUtils.copyFileToDirectory(testFile1, directory);
+        assertTrue(destination.exists(), "Check Exist");
+        assertEquals(testFile2Size, destination.length(), "Check Full copy");
+        assertEquals(FileUtils.lastModified(testFile1), FileUtils.lastModified(destination), "Check last modified date preserved");
+    }
+
+    @Test
+    public void testCopyFile2WithoutFileDatePreservation() throws Exception {
+        final File destFile = new File(tempDirFile, "copy2.txt");
+
+        backDateFile10Minutes(testFile1); // set test file back 10 minutes
+
+        // destination file time should not be less than this (allowing for granularity)
+        final long nowMillis = System.currentTimeMillis() - 1000L;
+        // On Windows, the last modified time is copied by default.
+        FileUtils.copyFile(testFile1, destFile, false);
+        assertTrue(destFile.exists(), "Check Exist");
+        assertEquals(testFile1Size, destFile.length(), "Check Full copy");
+        final long destLastModMillis = getLastModifiedMillis(destFile);
+        final long unexpectedMillis = getLastModifiedMillis(testFile1);
+        if (!SystemUtils.IS_OS_WINDOWS) {
+            final long deltaMillis = destLastModMillis - unexpectedMillis;
+            assertNotEquals(unexpectedMillis, destLastModMillis,
+                "Check last modified date not same as input, delta " + deltaMillis);
+            assertTrue(destLastModMillis > nowMillis,
+                destLastModMillis + " > " + nowMillis + " (delta " + deltaMillis + ")");
+        }
+    }
+
+    @Test
+    @Disabled
+    public void testCopyFileLarge() throws Exception {
+
+        final File largeFile = new File(tempDirFile, "large.txt");
+        final File destination = new File(tempDirFile, "copylarge.txt");
+
+        System.out.println("START:   " + new java.util.Date());
+        if (!largeFile.getParentFile().exists()) {
+            fail("Cannot create file " + largeFile
+                    + " as the parent directory does not exist");
+        }
+        try (final OutputStream output = new BufferedOutputStream(Files.newOutputStream(largeFile.toPath()))) {
+            TestUtils.generateTestData(output, FileUtils.ONE_GB);
+        }
+        System.out.println("CREATED: " + new java.util.Date());
+        FileUtils.copyFile(largeFile, destination);
+        System.out.println("COPIED:  " + new java.util.Date());
+
+        assertTrue(destination.exists(), "Check Exist");
+        assertEquals(largeFile.length(), destination.length(), "Check Full copy");
+    }
+
+    @Test
+    public void testCopyFileToOutputStream() throws Exception {
+        final ByteArrayOutputStream destination = new ByteArrayOutputStream();
+        FileUtils.copyFile(testFile1, destination);
+        assertEquals(testFile1Size, destination.size(), "Check Full copy size");
+        final byte[] expected = FileUtils.readFileToByteArray(testFile1);
+        assertArrayEquals(expected, destination.toByteArray(), "Check Full copy");
+    }
+
+    @Test
+    public void testCopyToDirectoryWithDirectory() throws IOException {
+        final File destDirectory = new File(tempDirFile, "destination");
+        if (!destDirectory.exists()) {
+            destDirectory.mkdirs();
+        }
+
+        // Create a test directory
+        final File inputDirectory = new File(tempDirFile, "input");
+        if (!inputDirectory.exists()) {
+            inputDirectory.mkdirs();
+        }
+        final File outputDirDestination = new File(destDirectory, inputDirectory.getName());
+        FileUtils.copyToDirectory(testFile1, inputDirectory);
+        final File destFile1 = new File(outputDirDestination, testFile1.getName());
+        FileUtils.copyToDirectory(testFile2, inputDirectory);
+        final File destFile2 = new File(outputDirDestination, testFile2.getName());
+
+        FileUtils.copyToDirectory(inputDirectory, destDirectory);
+
+        // Check the directory was created
+        assertTrue(outputDirDestination.exists(), "Check Exists");
+        assertTrue(outputDirDestination.isDirectory(), "Check Directory");
+
+        // Check each file
+        assertTrue(destFile1.exists(), "Check Exists");
+        assertEquals(testFile1Size, destFile1.length(), "Check Full Copy");
+        assertTrue(destFile2.exists(), "Check Exists");
+        assertEquals(testFile2Size, destFile2.length(), "Check Full Copy");
+    }
+
+    @Test
+    public void testCopyToDirectoryWithFile() throws IOException {
+        final File directory = new File(tempDirFile, "subdir");
+        if (!directory.exists()) {
+            directory.mkdirs();
+        }
+        final File destination = new File(directory, testFile1.getName());
+
+        FileUtils.copyToDirectory(testFile1, directory);
+        assertTrue(destination.exists(), "Check Exists");
+        assertEquals(testFile1Size, destination.length(), "Check Full Copy");
+    }
+
+    @Test
+    public void testCopyToDirectoryWithFileSourceDoesNotExist() {
+        assertThrows(IOException.class,
+                () -> FileUtils.copyToDirectory(new File(tempDirFile, "doesNotExists"), tempDirFile));
+    }
+
+    @Test
+    public void testCopyToDirectoryWithFileSourceIsNull() {
+        assertThrows(NullPointerException.class, () -> FileUtils.copyToDirectory((File) null, tempDirFile));
+    }
+
+    @Test
+    public void testCopyToDirectoryWithIterable() throws IOException {
+        final File directory = new File(tempDirFile, "subdir");
+        if (!directory.exists()) {
+            directory.mkdirs();
+        }
+
+        final List<File> input = new ArrayList<>();
+        input.add(testFile1);
+        input.add(testFile2);
+
+        final File destFile1 = new File(directory, testFile1.getName());
+        final File destFile2 = new File(directory, testFile2.getName());
+
+        FileUtils.copyToDirectory(input, directory);
+        // Check each file
+        assertTrue(destFile1.exists(), "Check Exists");
+        assertEquals(testFile1Size, destFile1.length(), "Check Full Copy");
+        assertTrue(destFile2.exists(), "Check Exists");
+        assertEquals(testFile2Size, destFile2.length(), "Check Full Copy");
+    }
+
+    @Test
+    public void testCopyToDirectoryWithIterableSourceDoesNotExist() {
+        assertThrows(IOException.class,
+                () -> FileUtils.copyToDirectory(Collections.singleton(new File(tempDirFile, "doesNotExists")),
+                        tempDirFile));
+    }
+
+    @Test
+    public void testCopyToDirectoryWithIterableSourceIsNull() {
+        assertThrows(NullPointerException.class, () -> FileUtils.copyToDirectory((List<File>) null, tempDirFile));
+    }
+
+    @Test
+    public void testCopyToSelf() throws Exception {
+        final File destination = new File(tempDirFile, "copy3.txt");
+        //Prepare a test file
+        FileUtils.copyFile(testFile1, destination);
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.copyFile(destination, destination));
+    }
+
+    @Test
+    public void testCopyURLToFile() throws Exception {
+        // Creates file
+        final File file = new File(tempDirFile, getName());
+        assertContentMatchesAfterCopyURLToFileFor("/java/lang/Object.class", file);
+        //TODO Maybe test copy to itself like for copyFile()
+    }
+
+    @Test
+    public void testCopyURLToFileCreatesParentDirs() throws Exception {
+        final File file = managedTempDirPath.resolve("subdir").resolve(getName()).toFile();
+        assertContentMatchesAfterCopyURLToFileFor("/java/lang/Object.class", file);
+    }
+
+    @Test
+    public void testCopyURLToFileReplacesExisting() throws Exception {
+        final File file = new File(tempDirFile, getName());
+        assertContentMatchesAfterCopyURLToFileFor("/java/lang/Object.class", file);
+        assertContentMatchesAfterCopyURLToFileFor("/java/lang/String.class", file);
+    }
+
+    @Test
+    public void testCopyURLToFileWithTimeout() throws Exception {
+        // Creates file
+        final File file = new File(tempDirFile, "testCopyURLToFileWithTimeout");
+
+        // Loads resource
+        final String resourceName = "/java/lang/Object.class";
+        FileUtils.copyURLToFile(getClass().getResource(resourceName), file, 500, 500);
+
+        // Tests that resource was copied correctly
+        try (InputStream fis = Files.newInputStream(file.toPath());
+             InputStream resStream = getClass().getResourceAsStream(resourceName);) {
+            assertTrue(IOUtils.contentEquals(resStream, fis), "Content is not equal.");
+        }
+        //TODO Maybe test copy to itself like for copyFile()
+    }
+
+    /**
+     * Tests a directory with one file of size 0.
+     */
+    @Test
+    public void testCountFolders1FileSize0() {
+        assertEquals(0, FileUtils.sizeOfDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-0").toFile()));
+    }
+
+    /**
+     * Tests a directory with one file of size 1.
+     */
+    @Test
+    public void testCountFolders1FileSize1() {
+        assertEquals(1, FileUtils.sizeOfDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1").toFile()));
+    }
+
+    /**
+     * Tests a directory with two subdirectories, each containing one file of size 1.
+     */
+    @Test
+    public void testCountFolders2FileSize2() {
+        assertEquals(2, FileUtils.sizeOfDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-2-file-size-2").toFile()));
+    }
+
+    /**
+     * Tests a directory with two subdirectories, each containing one file of size 1.
+     */
+    @Test
+    public void testCountFolders2FileSize4() {
+        assertEquals(8, FileUtils.sizeOfDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-2-file-size-4").toFile()));
+    }
+
+    @Test
+    public void testDecodeUrl() {
+        assertEquals("", FileUtils.decodeUrl(""));
+        assertEquals("foo", FileUtils.decodeUrl("foo"));
+        assertEquals("+", FileUtils.decodeUrl("+"));
+        assertEquals("% ", FileUtils.decodeUrl("%25%20"));
+        assertEquals("%20", FileUtils.decodeUrl("%2520"));
+        assertEquals("jar:file:/C:/dir/sub dir/1.0/foo-1.0.jar!/org/Bar.class", FileUtils
+                .decodeUrl("jar:file:/C:/dir/sub%20dir/1.0/foo-1.0.jar!/org/Bar.class"));
+    }
+
+    @Test
+    public void testDecodeUrlEncodingUtf8() {
+        assertEquals("\u00E4\u00F6\u00FC\u00DF", FileUtils.decodeUrl("%C3%A4%C3%B6%C3%BC%C3%9F"));
+    }
+
+    @Test
+    public void testDecodeUrlLenient() {
+        assertEquals(" ", FileUtils.decodeUrl(" "));
+        assertEquals("\u00E4\u00F6\u00FC\u00DF", FileUtils.decodeUrl("\u00E4\u00F6\u00FC\u00DF"));
+        assertEquals("%", FileUtils.decodeUrl("%"));
+        assertEquals("% ", FileUtils.decodeUrl("%%20"));
+        assertEquals("%2", FileUtils.decodeUrl("%2"));
+        assertEquals("%2G", FileUtils.decodeUrl("%2G"));
+    }
+
+    @Test
+    public void testDecodeUrlNullSafe() {
+        assertNull(FileUtils.decodeUrl(null));
+    }
+
+    @Test
+    public void testDelete() throws Exception {
+        assertEquals(testFile1, FileUtils.delete(testFile1));
+        assertThrows(IOException.class, () -> FileUtils.delete(new File("does not exist.nope")));
+    }
+
+    @Test
+    public void testDeleteDirectoryWithNonDirectory() {
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.deleteDirectory(testFile1));
+    }
+
+    @Test
+    public void testDeleteQuietlyDir() throws IOException {
+        final File testDirectory = new File(tempDirFile, "testDeleteQuietlyDir");
+        final File testFile = new File(testDirectory, "testDeleteQuietlyFile");
+        testDirectory.mkdirs();
+        if (!testFile.getParentFile().exists()) {
+            fail("Cannot create file " + testFile
+                    + " as the parent directory does not exist");
+        }
+        try (final OutputStream output = new BufferedOutputStream(Files.newOutputStream(testFile.toPath()))) {
+            TestUtils.generateTestData(output, 0);
+        }
+
+        assertTrue(testDirectory.exists());
+        assertTrue(testFile.exists());
+        FileUtils.deleteQuietly(testDirectory);
+        assertFalse(testDirectory.exists(), "Check No Exist");
+        assertFalse(testFile.exists(), "Check No Exist");
+    }
+
+    @Test
+    public void testDeleteQuietlyFile() throws IOException {
+        final File testFile = new File(tempDirFile, "testDeleteQuietlyFile");
+        if (!testFile.getParentFile().exists()) {
+            fail("Cannot create file " + testFile
+                    + " as the parent directory does not exist");
+        }
+        try (final OutputStream output = new BufferedOutputStream(Files.newOutputStream(testFile.toPath()))) {
+            TestUtils.generateTestData(output, 0);
+        }
+
+        assertTrue(testFile.exists());
+        FileUtils.deleteQuietly(testFile);
+        assertFalse(testFile.exists(), "Check No Exist");
+    }
+
+    @Test
+    public void testDeleteQuietlyForNull() {
+        FileUtils.deleteQuietly(null);
+    }
+
+    @Test
+    public void testDeleteQuietlyNonExistent() {
+        final File testFile = new File("testDeleteQuietlyNonExistent");
+        assertFalse(testFile.exists());
+        FileUtils.deleteQuietly(testFile);
+    }
+
+    /**
+     * Tests the FileUtils implementation.
+     */
+    @Test
+    public void testFileUtils() throws Exception {
+        // Loads file from classpath
+        final File file1 = new File(tempDirFile, "test.txt");
+        final String filename = file1.getAbsolutePath();
+
+        //Create test file on-the-fly (used to be in CVS)
+        try (OutputStream out = Files.newOutputStream(file1.toPath())) {
+            out.write("This is a test".getBytes(StandardCharsets.UTF_8));
+        }
+
+        final File file2 = new File(tempDirFile, "test2.txt");
+
+        FileUtils.writeStringToFile(file2, filename, UTF_8);
+        assertTrue(file2.exists());
+        assertTrue(file2.length() > 0);
+
+        final String file2contents = FileUtils.readFileToString(file2, UTF_8);
+        assertEquals(filename, file2contents, "Second file's contents correct");
+
+        assertTrue(file2.delete());
+
+        final String contents = FileUtils.readFileToString(new File(filename), UTF_8);
+        assertEquals("This is a test", contents, "FileUtils.fileRead()");
+
+    }
+
+    @Test
+    public void testForceDeleteAFile1() throws Exception {
+        final File destination = new File(tempDirFile, "copy1.txt");
+        destination.createNewFile();
+        assertTrue(destination.exists(), "Copy1.txt doesn't exist to delete");
+        FileUtils.forceDelete(destination);
+        assertFalse(destination.exists(), "Check No Exist");
+    }
+
+    @Test
+    public void testForceDeleteAFile2() throws Exception {
+        final File destination = new File(tempDirFile, "copy2.txt");
+        destination.createNewFile();
+        assertTrue(destination.exists(), "Copy2.txt doesn't exist to delete");
+        FileUtils.forceDelete(destination);
+        assertFalse(destination.exists(), "Check No Exist");
+    }
+
+    @Test
+    public void testForceDeleteAFile3() {
+        final File destination = new File(tempDirFile, "no_such_file");
+        assertFalse(destination.exists(), "Check No Exist");
+        assertThrows(IOException.class, () -> FileUtils.forceDelete(destination));
+
+    }
+
+    @Test
+    public void testForceDeleteDir() throws Exception {
+        final File testDirectory = tempDirFile;
+        assertTrue(testDirectory.exists(), "TestDirectory must exist");
+        FileUtils.forceDelete(testDirectory);
+        assertFalse(testDirectory.exists(), "TestDirectory must not exist");
+    }
+
+    @Test
+    public void testForceDeleteReadOnlyFile() throws Exception {
+        try (TempFile destination = TempFile.create("test-", ".txt")) {
+            final File file = destination.toFile();
+            assertTrue(file.setReadOnly());
+            assertTrue(file.canRead());
+            assertFalse(file.canWrite());
+            // sanity check that File.delete() deletes read-only files.
+            assertTrue(file.delete());
+        }
+        try (TempFile destination = TempFile.create("test-", ".txt")) {
+            final File file = destination.toFile();
+            // real test
+            assertTrue(file.setReadOnly());
+            assertTrue(file.canRead());
+            assertFalse(file.canWrite());
+            assertTrue(file.exists(), "File doesn't exist to delete");
+            FileUtils.forceDelete(file);
+            assertFalse(file.exists(), "Check deletion");
+        }
+    }
+
+    @Test
+    public void testForceMkdir() throws Exception {
+        // Tests with existing directory
+        FileUtils.forceMkdir(tempDirFile);
+
+        // Creates test file
+        final File testFile = new File(tempDirFile, getName());
+        testFile.createNewFile();
+        assertTrue(testFile.exists(), "Test file does not exist.");
+
+        // Tests with existing file
+        assertThrows(IOException.class, () -> FileUtils.forceMkdir(testFile));
+
+        testFile.delete();
+
+        // Tests with non-existent directory
+        FileUtils.forceMkdir(testFile);
+        assertTrue(testFile.exists(), "Directory was not created.");
+
+        // noop
+        FileUtils.forceMkdir(null);
+    }
+
+    @Test
+    public void testForceMkdirParent() throws Exception {
+        // Tests with existing directory
+        assertTrue(tempDirFile.exists());
+        final File testParentDir = new File(tempDirFile, "testForceMkdirParent");
+        testParentDir.delete();
+        assertFalse(testParentDir.exists());
+        final File testFile = new File(testParentDir, "test.txt");
+        assertFalse(testParentDir.exists());
+        assertFalse(testFile.exists());
+        // Create
+        FileUtils.forceMkdirParent(testFile);
+        assertTrue(testParentDir.exists());
+        assertFalse(testFile.exists());
+        // Again
+        FileUtils.forceMkdirParent(testFile);
+        assertTrue(testParentDir.exists());
+        assertFalse(testFile.exists());
+    }
+
+    @Test
+    public void testGetFile() {
+        final File expected_A = new File("src");
+        final File expected_B = new File(expected_A, "main");
+        final File expected_C = new File(expected_B, "java");
+        assertEquals(expected_A, FileUtils.getFile("src"), "A");
+        assertEquals(expected_B, FileUtils.getFile("src", "main"), "B");
+        assertEquals(expected_C, FileUtils.getFile("src", "main", "java"), "C");
+        assertThrows(NullPointerException.class, () -> FileUtils.getFile((String[]) null));
+
+    }
+
+    @Test
+    public void testGetFile_Parent() {
+        final File parent = new File("parent");
+        final File expected_A = new File(parent, "src");
+        final File expected_B = new File(expected_A, "main");
+        final File expected_C = new File(expected_B, "java");
+        assertEquals(expected_A, FileUtils.getFile(parent, "src"), "A");
+        assertEquals(expected_B, FileUtils.getFile(parent, "src", "main"), "B");
+        assertEquals(expected_C, FileUtils.getFile(parent, "src", "main", "java"), "C");
+        assertThrows(NullPointerException.class, () -> FileUtils.getFile(parent, (String[]) null));
+        assertThrows(NullPointerException.class, () -> FileUtils.getFile((File) null, "src"));
+    }
+
+    @Test
+    public void testGetTempDirectory() {
+        final File tempDirectory = new File(FileUtils.getTempDirectoryPath());
+        assertEquals(tempDirectory, FileUtils.getTempDirectory());
+    }
+
+    @Test
+    public void testGetTempDirectoryPath() {
+        assertEquals(System.getProperty("java.io.tmpdir"), FileUtils.getTempDirectoryPath());
+    }
+
+    @Test
+    public void testGetUserDirectory() {
+        final File userDirectory = new File(System.getProperty("user.home"));
+        assertEquals(userDirectory, FileUtils.getUserDirectory());
+    }
+
+    @Test
+    public void testGetUserDirectoryPath() {
+        assertEquals(System.getProperty("user.home"), FileUtils.getUserDirectoryPath());
+    }
+
+    @Test
+    public void testIO276() throws Exception {
+        final File dir = new File("target", "IO276");
+        assertTrue(dir.mkdirs(), dir + " should not be present");
+        final File file = new File(dir, "IO276.txt");
+        assertTrue(file.createNewFile(), file + " should not be present");
+        FileUtils.forceDeleteOnExit(dir);
+        // If this does not work, test will fail next time (assuming target is not cleaned)
+    }
+
+    @Test
+    public void testIO300() {
+        final File testDirectory = tempDirFile;
+        final File src = new File(testDirectory, "dir1");
+        final File dest = new File(src, "dir2");
+        assertTrue(dest.mkdirs());
+        assertTrue(src.exists());
+        assertThrows(IOException.class, () -> FileUtils.moveDirectoryToDirectory(src, dest, false));
+        assertTrue(src.exists());
+    }
+
+    @Test
+    public void testIO575() throws IOException {
+        final Path sourceDir = Files.createTempDirectory("source-dir");
+        final String filename = "some-file";
+        final Path sourceFile = Files.createFile(sourceDir.resolve(filename));
+
+        assertEquals(SystemUtils.IS_OS_WINDOWS, sourceFile.toFile().canExecute());
+
+        sourceFile.toFile().setExecutable(true);
+
+        assertTrue(sourceFile.toFile().canExecute());
+
+        final Path destDir = Files.createTempDirectory("some-empty-destination");
+
+        FileUtils.copyDirectory(sourceDir.toFile(), destDir.toFile());
+
+        final Path destFile = destDir.resolve(filename);
+
+        assertTrue(destFile.toFile().exists());
+        assertTrue(destFile.toFile().canExecute());
+    }
+
+    @Test
+    public void testIsDirectory() throws IOException {
+        assertFalse(FileUtils.isDirectory(null));
+
+        assertTrue(FileUtils.isDirectory(tempDirFile));
+        assertFalse(FileUtils.isDirectory(testFile1));
+
+        final File tempDirAsFile;
+        try (TempDirectory tempDir = TempDirectory.create(getClass().getCanonicalName())) {
+            tempDirAsFile = tempDir.toFile();
+            assertTrue(FileUtils.isDirectory(tempDirAsFile));
+        }
+        assertFalse(FileUtils.isDirectory(tempDirAsFile));
+    }
+
+    @Test
+    public void testIsEmptyDirectory() throws IOException {
+        try (TempDirectory tempDir = TempDirectory.create(getClass().getCanonicalName())) {
+            final File tempDirAsFile = tempDir.toFile();
+            Assertions.assertTrue(FileUtils.isEmptyDirectory(tempDirAsFile));
+        }
+        Assertions.assertFalse(FileUtils.isEmptyDirectory(PathUtilsIsEmptyTest.DIR_SIZE_1.toFile()));
+    }
+
+    @ParameterizedTest
+    @ValueSource(longs = {1L, 100L, 1_000L, 10_000L, 100_000L, 1_000_000L})
+    public void testIsFileNewerOlder(final long millis) throws Exception {
+        // Files
+        final File oldFile = new File(tempDirFile, "FileUtils-old.txt");
+        final File refFile = new File(tempDirFile, "FileUtils-reference.txt");
+        final File newFile = new File(tempDirFile, "FileUtils-new.txt");
+        final File invalidFile = new File(tempDirFile, "FileUtils-invalid-file.txt");
+        // Paths
+        final Path oldPath = oldFile.toPath();
+        final Path refPath = refFile.toPath();
+        final Path newPath = newFile.toPath();
+        // FileTimes
+        // TODO What is wrong with Java 8 on macOS? Or is this a macOS file system issue?
+        final long actualMillis = SystemUtils.IS_OS_MAC && SystemUtils.IS_JAVA_1_8 ? millis + 1000 : millis;
+        final FileTime oldFileTime = FileTime.from(actualMillis * 1, TimeUnit.MILLISECONDS);
+        final FileTime refFileTime = FileTime.from(actualMillis * 2, TimeUnit.MILLISECONDS);
+        final FileTime testFileTime = FileTime.from(actualMillis * 3, TimeUnit.MILLISECONDS);
+        final FileTime newFileTime = FileTime.from(actualMillis * 4, TimeUnit.MILLISECONDS);
+
+        // Create fixtures
+        try (final OutputStream output = new BufferedOutputStream(Files.newOutputStream(oldPath))) {
+            TestUtils.generateTestData(output, 0);
+        }
+        Files.setLastModifiedTime(oldPath, oldFileTime);
+
+        try (final OutputStream output = new BufferedOutputStream(Files.newOutputStream(refPath))) {
+            TestUtils.generateTestData(output, 0);
+        }
+        Files.setLastModifiedTime(refPath, refFileTime);
+
+        final Date date = new Date(testFileTime.toMillis());
+        final long now = date.getTime();
+        final Instant instant = date.toInstant();
+        final ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault());
+        final OffsetDateTime offsetDateTime = zonedDateTime.toOffsetDateTime();
+        final LocalDateTime localDateTime = zonedDateTime.toLocalDateTime();
+        final LocalDate localDate = zonedDateTime.toLocalDate();
+        final LocalDate localDatePlusDay = localDate.plusDays(1);
+        final LocalTime localTime0 = LocalTime.MIDNIGHT;
+        final OffsetTime offsetTime0 = OffsetTime.of(localTime0, ZoneOffset.UTC);
+
+        try (final OutputStream output = new BufferedOutputStream(Files.newOutputStream(newPath))) {
+            TestUtils.generateTestData(output, 0);
+        }
+        Files.setLastModifiedTime(newPath, newFileTime);
+
+        // Test
+        assertFalse(FileUtils.isFileNewer(oldFile, refFile), "Old File - Newer - File");
+        assertFalse(FileUtils.isFileNewer(oldFile, date), "Old File - Newer - Date");
+        assertFalse(FileUtils.isFileNewer(oldFile, now), "Old File - Newer - Mili");
+        assertFalse(FileUtils.isFileNewer(oldFile, instant), "Old File - Newer - Instant");
+        assertFalse(FileUtils.isFileNewer(oldFile, zonedDateTime), "Old File - Newer - ZonedDateTime");
+        assertFalse(FileUtils.isFileNewer(oldFile, offsetDateTime), "Old File - Newer - OffsetDateTime");
+        assertFalse(FileUtils.isFileNewer(oldFile, localDateTime), "Old File - Newer - LocalDateTime");
+        assertFalse(FileUtils.isFileNewer(oldFile, localDateTime, ZoneId.systemDefault()), "Old File - Newer - LocalDateTime,ZoneId");
+        assertFalse(FileUtils.isFileNewer(oldFile, localDate), "Old File - Newer - LocalDate");
+        assertTrue(FileUtils.isFileNewer(oldFile, localDate, localTime0), "Old File - Newer - LocalDate,LocalTime");
+        assertTrue(FileUtils.isFileNewer(oldFile, localDate, offsetTime0), "Old File - Newer - LocalDate,OffsetTime");
+        assertFalse(FileUtils.isFileNewer(oldFile, localDatePlusDay), "Old File - Newer - LocalDate plus one day");
+        assertFalse(FileUtils.isFileNewer(oldFile, localDatePlusDay, localTime0), "Old File - Newer - LocalDate plus one day,LocalTime");
+        assertFalse(FileUtils.isFileNewer(oldFile, localDatePlusDay, offsetTime0), "Old File - Newer - LocalDate plus one day,OffsetTime");
+
+        assertTrue(FileUtils.isFileNewer(newFile, refFile), "New File - Newer - File");
+        assertTrue(FileUtils.isFileNewer(newFile, date), "New File - Newer - Date");
+        assertTrue(FileUtils.isFileNewer(newFile, now), "New File - Newer - Mili");
+        assertTrue(FileUtils.isFileNewer(newFile, instant), "New File - Newer - Instant");
+        assertTrue(FileUtils.isFileNewer(newFile, zonedDateTime), "New File - Newer - ZonedDateTime");
+        assertTrue(FileUtils.isFileNewer(newFile, offsetDateTime), "New File - Newer - OffsetDateTime");
+        assertTrue(FileUtils.isFileNewer(newFile, localDateTime), "New File - Newer - LocalDateTime");
+        assertTrue(FileUtils.isFileNewer(newFile, localDateTime, ZoneId.systemDefault()), "New File - Newer - LocalDateTime,ZoneId");
+        assertFalse(FileUtils.isFileNewer(newFile, localDate), "New File - Newer - LocalDate");
+        assertTrue(FileUtils.isFileNewer(newFile, localDate, localTime0), "New File - Newer - LocalDate,LocalTime");
+        assertTrue(FileUtils.isFileNewer(newFile, localDate, offsetTime0), "New File - Newer - LocalDate,OffsetTime");
+        assertFalse(FileUtils.isFileNewer(newFile, localDatePlusDay), "New File - Newer - LocalDate plus one day");
+        assertFalse(FileUtils.isFileNewer(newFile, localDatePlusDay, localTime0), "New File - Newer - LocalDate plus one day,LocalTime");
+        assertFalse(FileUtils.isFileNewer(newFile, localDatePlusDay, offsetTime0), "New File - Newer - LocalDate plus one day,OffsetTime");
+        assertFalse(FileUtils.isFileNewer(invalidFile, refFile), "Invalid - Newer - File");
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.isFileNewer(newFile, invalidFile));
+
+        // Test isFileOlder()
+        assertTrue(FileUtils.isFileOlder(oldFile, refFile), "Old File - Older - File");
+        assertTrue(FileUtils.isFileOlder(oldFile, date), "Old File - Older - Date");
+        assertTrue(FileUtils.isFileOlder(oldFile, now), "Old File - Older - Mili");
+        assertTrue(FileUtils.isFileOlder(oldFile, instant), "Old File - Older - Instant");
+        assertTrue(FileUtils.isFileOlder(oldFile, zonedDateTime), "Old File - Older - ZonedDateTime");
+        assertTrue(FileUtils.isFileOlder(oldFile, offsetDateTime), "Old File - Older - OffsetDateTime");
+        assertTrue(FileUtils.isFileOlder(oldFile, localDateTime), "Old File - Older - LocalDateTime");
+        assertTrue(FileUtils.isFileOlder(oldFile, localDateTime, ZoneId.systemDefault()), "Old File - Older - LocalDateTime,LocalTime");
+        assertTrue(FileUtils.isFileOlder(oldFile, localDate), "Old File - Older - LocalDate");
+        assertFalse(FileUtils.isFileOlder(oldFile, localDate, localTime0), "Old File - Older - LocalDate,LocalTime");
+        assertFalse(FileUtils.isFileOlder(oldFile, localDate, offsetTime0), "Old File - Older - LocalDate,OffsetTime");
+        assertTrue(FileUtils.isFileOlder(oldFile, localDatePlusDay), "Old File - Older - LocalDate plus one day");
+        assertTrue(FileUtils.isFileOlder(oldFile, localDatePlusDay, localTime0), "Old File - Older - LocalDate plus one day,LocalTime");
+        assertTrue(FileUtils.isFileOlder(oldFile, localDatePlusDay, offsetTime0), "Old File - Older - LocalDate plus one day,OffsetTime");
+
+        assertFalse(FileUtils.isFileOlder(newFile, refFile), "New File - Older - File");
+        assertFalse(FileUtils.isFileOlder(newFile, date), "New File - Older - Date");
+        assertFalse(FileUtils.isFileOlder(newFile, now), "New File - Older - Mili");
+        assertFalse(FileUtils.isFileOlder(newFile, instant), "New File - Older - Instant");
+        assertFalse(FileUtils.isFileOlder(newFile, zonedDateTime), "New File - Older - ZonedDateTime");
+        assertFalse(FileUtils.isFileOlder(newFile, offsetDateTime), "New File - Older - OffsetDateTime");
+        assertFalse(FileUtils.isFileOlder(newFile, localDateTime), "New File - Older - LocalDateTime");
+        assertFalse(FileUtils.isFileOlder(newFile, localDateTime, ZoneId.systemDefault()), "New File - Older - LocalDateTime,ZoneId");
+        assertTrue(FileUtils.isFileOlder(newFile, localDate), "New File - Older - LocalDate");
+        assertFalse(FileUtils.isFileOlder(newFile, localDate, localTime0), "New File - Older - LocalDate,LocalTime");
+        assertFalse(FileUtils.isFileOlder(newFile, localDate, offsetTime0), "New File - Older - LocalDate,OffsetTime");
+        assertTrue(FileUtils.isFileOlder(newFile, localDatePlusDay), "New File - Older - LocalDate plus one day");
+        assertTrue(FileUtils.isFileOlder(newFile, localDatePlusDay, localTime0), "New File - Older - LocalDate plus one day,LocalTime");
+        assertTrue(FileUtils.isFileOlder(newFile, localDatePlusDay, offsetTime0), "New File - Older - LocalDate plus one day,OffsetTime");
+
+        assertFalse(FileUtils.isFileOlder(invalidFile, refFile), "Invalid - Older - File");
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.isFileOlder(newFile, invalidFile));
+
+        // Null File
+        assertThrows(NullPointerException.class, () -> FileUtils.isFileNewer(null, now));
+
+        // Null reference File
+        assertThrows(NullPointerException.class, () -> FileUtils.isFileNewer(oldFile, (File) null));
+
+        // Invalid reference File
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.isFileNewer(oldFile, invalidFile));
+
+        // Null reference Date
+        assertThrows(NullPointerException.class, () -> FileUtils.isFileNewer(oldFile, (Date) null));
+
+        // ----- Test isFileOlder() exceptions -----
+        // Null File
+        assertThrows(NullPointerException.class, () -> FileUtils.isFileOlder(null, now));
+
+        // Null reference File
+        assertThrows(NullPointerException.class, () -> FileUtils.isFileOlder(oldFile, (File) null));
+
+        // Null reference Date
+        assertThrows(NullPointerException.class, () -> FileUtils.isFileOlder(oldFile, (Date) null));
+
+        // Invalid reference File
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.isFileOlder(oldFile, invalidFile));
+    }
+
+    @Test
+    public void testIsRegularFile() throws IOException {
+        assertFalse(FileUtils.isRegularFile(null));
+
+        assertFalse(FileUtils.isRegularFile(tempDirFile));
+        assertTrue(FileUtils.isRegularFile(testFile1));
+
+        Files.delete(testFile1.toPath());
+        assertFalse(FileUtils.isRegularFile(testFile1));
+    }
+
+    @Test
+    public void testIterateFiles() throws Exception {
+        final File srcDir = tempDirFile;
+        final File subDir = new File(srcDir, "list_test");
+        final File subSubDir = new File(subDir, "subSubDir");
+        final File notSubSubDir = new File(subDir, "notSubSubDir");
+        assertTrue(subDir.mkdir());
+        assertTrue(subSubDir.mkdir());
+        assertTrue(notSubSubDir.mkdir());
+        Iterator<File> iterator = null;
+        try {
+            // Need list to be appendable
+            final List<String> expectedFileNames = new ArrayList<>(
+                Arrays.asList("a.txt", "b.txt", "c.txt", "d.txt", "e.txt", "f.txt"));
+            final int[] fileSizes = {123, 234, 345, 456, 678, 789};
+            assertEquals(expectedFileNames.size(), fileSizes.length);
+            Collections.sort(expectedFileNames);
+            Arrays.sort(fileSizes);
+            for (int i = 0; i < fileSizes.length; ++i) {
+                TestUtils.generateTestData(new File(subDir, expectedFileNames.get(i)), fileSizes[i]);
+            }
+            //
+            final String subSubFileName = "z.txt";
+            TestUtils.generateTestData(new File(subSubDir, subSubFileName), 1);
+            expectedFileNames.add(subSubFileName);
+            //
+            final String notSubSubFileName = "not.txt";
+            TestUtils.generateTestData(new File(notSubSubDir, notSubSubFileName), 1);
+
+            final WildcardFileFilter allFilesFileFilter = new WildcardFileFilter("*.*");
+            final NameFileFilter dirFilter = new NameFileFilter("subSubDir");
+            iterator = FileUtils.iterateFiles(subDir, allFilesFileFilter, dirFilter);
+
+            final Map<String, String> matchedFileNames = new HashMap<>();
+            final List<String> actualFileNames = new ArrayList<>();
+
+            while (iterator.hasNext()) {
+                boolean found = false;
+                final String fileName = iterator.next().getName();
+                actualFileNames.add(fileName);
+
+                for (int j = 0; !found && j < expectedFileNames.size(); ++j) {
+                    final String expectedFileName = expectedFileNames.get(j);
+                    if (expectedFileName.equals(fileName)) {
+                        matchedFileNames.put(expectedFileName, expectedFileName);
+                        found = true;
+                    }
+                }
+            }
+            assertEquals(expectedFileNames.size(), matchedFileNames.size());
+            Collections.sort(actualFileNames);
+            assertEquals(expectedFileNames, actualFileNames);
+        } finally {
+            consumeRemaining(iterator);
+            notSubSubDir.delete();
+            subSubDir.delete();
+            subDir.delete();
+        }
+    }
+
+    @Test
+    public void testIterateFilesAndDirs() throws IOException {
+        final File srcDir = tempDirFile;
+        // temporaryFolder/srcDir
+        // - subdir1
+        // -- subdir2
+        // --- a.txt
+        // --- subdir3
+        // --- subdir4
+        final File subDir1 = new File(srcDir, "subdir1");
+        final File subDir2 = new File(subDir1, "subdir2");
+        final File subDir3 = new File(subDir2, "subdir3");
+        final File subDir4 = new File(subDir2, "subdir4");
+        assertTrue(subDir1.mkdir());
+        assertTrue(subDir2.mkdir());
+        assertTrue(subDir3.mkdir());
+        assertTrue(subDir4.mkdir());
+        final File someFile = new File(subDir2, "a.txt");
+        final WildcardFileFilter fileFilterAllFiles = new WildcardFileFilter("*.*");
+        final WildcardFileFilter fileFilterAllDirs = new WildcardFileFilter("*");
+        final WildcardFileFilter fileFilterExtTxt = new WildcardFileFilter("*.txt");
+        try {
+            try (OutputStream output = new BufferedOutputStream(Files.newOutputStream(someFile.toPath()))) {
+                TestUtils.generateTestData(output, 100);
+            }
+            //
+            // "*.*" and "*"
+            Collection<File> expectedFilesAndDirs = Arrays.asList(subDir1, subDir2, someFile, subDir3, subDir4);
+            iterateFilesAndDirs(subDir1, fileFilterAllFiles, fileFilterAllDirs, expectedFilesAndDirs);
+            //
+            // "*.txt" and "*"
+            final int filesCount;
+            expectedFilesAndDirs = Arrays.asList(subDir1, subDir2, someFile, subDir3, subDir4);
+            iterateFilesAndDirs(subDir1, fileFilterExtTxt, fileFilterAllDirs, expectedFilesAndDirs);
+            //
+            // "*.*" and "subdir2"
+            expectedFilesAndDirs = Arrays.asList(subDir1, subDir2, someFile);
+            iterateFilesAndDirs(subDir1, fileFilterAllFiles, new NameFileFilter("subdir2"), expectedFilesAndDirs);
+            //
+            // "*.txt" and "subdir2"
+            expectedFilesAndDirs = Arrays.asList(subDir1, subDir2, someFile);
+            iterateFilesAndDirs(subDir1, fileFilterExtTxt, new NameFileFilter("subdir2"), expectedFilesAndDirs);
+        } finally {
+            someFile.delete();
+            subDir4.delete();
+            subDir3.delete();
+            subDir2.delete();
+            subDir1.delete();
+        }
+    }
+
+    @Test
+    public void testIterateFilesOnlyNoDirs() throws IOException {
+        final File directory = tempDirFile;
+        assertTrue(new File(directory, "TEST").mkdir());
+        assertTrue(new File(directory, "test.txt").createNewFile());
+
+        final IOFileFilter filter = new WildcardFileFilter("*", IOCase.INSENSITIVE);
+        FileUtils.iterateFiles(directory, filter, null).forEachRemaining(file -> assertFalse(file.isDirectory(), file::getAbsolutePath));
+    }
+
+    @Test
+    public void testListFiles() throws Exception {
+        final File srcDir = tempDirFile;
+        final File subDir = new File(srcDir, "list_test");
+        final File subDir2 = new File(subDir, "subdir");
+        subDir.mkdir();
+        subDir2.mkdir();
+        try {
+
+            final String[] expectedFileNames = {"a.txt", "b.txt", "c.txt", "d.txt", "e.txt", "f.txt"};
+            final int[] fileSizes = {123, 234, 345, 456, 678, 789};
+
+            for (int i = 0; i < expectedFileNames.length; ++i) {
+                final File theFile = new File(subDir, expectedFileNames[i]);
+                if (!theFile.getParentFile().exists()) {
+                    fail("Cannot create file " + theFile + " as the parent directory does not exist");
+                }
+                try (final BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(theFile.toPath()))) {
+                    TestUtils.generateTestData(output, fileSizes[i]);
+                }
+            }
+
+            final Collection<File> actualFiles = FileUtils.listFiles(subDir, new WildcardFileFilter("*.*"), new WildcardFileFilter("*"));
+
+            final int count = actualFiles.size();
+            final Object[] fileObjs = actualFiles.toArray();
+
+            assertEquals(expectedFileNames.length, actualFiles.size(), actualFiles::toString);
+
+            final Map<String, String> foundFileNames = new HashMap<>();
+
+            for (int i = 0; i < count; ++i) {
+                boolean found = false;
+                for (int j = 0; !found && j < expectedFileNames.length; ++j) {
+                    if (expectedFileNames[j].equals(((File) fileObjs[i]).getName())) {
+                        foundFileNames.put(expectedFileNames[j], expectedFileNames[j]);
+                        found = true;
+                    }
+                }
+            }
+
+            assertEquals(foundFileNames.size(), expectedFileNames.length, foundFileNames::toString);
+        } finally {
+            subDir.delete();
+        }
+    }
+
+    @Test
+    public void testListFilesOnlyNoDirs() throws IOException {
+        final File directory = tempDirFile;
+        assertTrue(new File(directory, "TEST").mkdir());
+        assertTrue(new File(directory, "test.txt").createNewFile());
+
+        final IOFileFilter filter = new WildcardFileFilter("*", IOCase.INSENSITIVE);
+        for (final File file : FileUtils.listFiles(directory, filter, null)) {
+            assertFalse(file.isDirectory(), file::getAbsolutePath);
+        }
+    }
+
+    @Test
+    public void testListFilesWithDirs() throws IOException {
+        final File srcDir = tempDirFile;
+
+        final File subDir1 = new File(srcDir, "subdir");
+        final File subDir2 = new File(subDir1, "subdir2");
+        subDir1.mkdir();
+        subDir2.mkdir();
+        try {
+            final File someFile = new File(subDir2, "a.txt");
+            if (!someFile.getParentFile().exists()) {
+                fail("Cannot create file " + someFile + " as the parent directory does not exist");
+            }
+            try (final BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(someFile.toPath()))) {
+                TestUtils.generateTestData(output, 100);
+            }
+
+            final File subDir3 = new File(subDir2, "subdir3");
+            subDir3.mkdir();
+
+            final Collection<File> files = FileUtils.listFilesAndDirs(subDir1, new WildcardFileFilter("*.*"),
+                new WildcardFileFilter("*"));
+
+            assertEquals(4, files.size());
+            assertTrue(files.contains(subDir1), "Should contain the directory.");
+            assertTrue(files.contains(subDir2), "Should contain the directory.");
+            assertTrue(files.contains(someFile), "Should contain the file.");
+            assertTrue(files.contains(subDir3), "Should contain the directory.");
+        } finally {
+            subDir1.delete();
+        }
+    }
+
+    @Test
+    public void testMoveDirectory_CopyDelete() throws Exception {
+
+        final File dir = tempDirFile;
+        final File src = new File(dir, "testMoveDirectory2Source") {
+            private static final long serialVersionUID = 1L;
+
+            // Force renameTo to fail
+            @Override
+            public boolean renameTo(final File dest) {
+                return false;
+            }
+        };
+        final File testDir = new File(src, "foo");
+        final File testFile = new File(testDir, "bar");
+        testDir.mkdirs();
+        if (!testFile.getParentFile().exists()) {
+            fail("Cannot create file " + testFile
+                    + " as the parent directory does not exist");
+        }
+        try (final OutputStream output = new BufferedOutputStream(Files.newOutputStream(testFile.toPath()))) {
+            TestUtils.generateTestData(output, 0);
+        }
+        final File destination = new File(dir, "testMoveDirectory1Dest");
+        FileUtils.deleteDirectory(destination);
+
+        // Move the directory
+        FileUtils.moveDirectory(src, destination);
+
+        // Check results
+        assertTrue(destination.exists(), "Check Exist");
+        assertFalse(src.exists(), "Original deleted");
+        final File movedDir = new File(destination, testDir.getName());
+        final File movedFile = new File(movedDir, testFile.getName());
+        assertTrue(movedDir.exists(), "Check dir moved");
+        assertTrue(movedFile.exists(), "Check file moved");
+    }
+
+    @Test
+    public void testMoveDirectory_Errors() throws Exception {
+        assertThrows(NullPointerException.class, () -> FileUtils.moveDirectory(null, new File("foo")));
+        assertThrows(NullPointerException.class, () -> FileUtils.moveDirectory(new File("foo"), null));
+        assertThrows(FileNotFoundException.class, () -> FileUtils.moveDirectory(new File("nonexistant"), new File("foo")));
+
+        final File testFile = new File(tempDirFile, "testMoveDirectoryFile");
+        if (!testFile.getParentFile().exists()) {
+            fail("Cannot create file " + testFile + " as the parent directory does not exist");
+        }
+        try (final OutputStream output = new BufferedOutputStream(Files.newOutputStream(testFile.toPath()))) {
+            TestUtils.generateTestData(output, 0);
+        }
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.moveDirectory(testFile, new File("foo")));
+        final File testSrcFile = new File(tempDirFile, "testMoveDirectorySource");
+        final File testDestFile = new File(tempDirFile, "testMoveDirectoryDest");
+        testSrcFile.mkdir();
+        testDestFile.mkdir();
+        assertThrows(FileExistsException.class, () -> FileUtils.moveDirectory(testSrcFile, testDestFile),
+            "Expected FileExistsException when dest already exists");
+
+    }
+
+    @Test
+    public void testMoveDirectory_Rename() throws Exception {
+        final File dir = tempDirFile;
+        final File src = new File(dir, "testMoveDirectory1Source");
+        final File testDir = new File(src, "foo");
+        final File testFile = new File(testDir, "bar");
+        testDir.mkdirs();
+        if (!testFile.getParentFile().exists()) {
+            fail("Cannot create file " + testFile
+                    + " as the parent directory does not exist");
+        }
+        try (final OutputStream output = new BufferedOutputStream(Files.newOutputStream(testFile.toPath()))) {
+            TestUtils.generateTestData(output, 0);
+        }
+        final File destination = new File(dir, "testMoveDirectory1Dest");
+        FileUtils.deleteDirectory(destination);
+
+        // Move the directory
+        FileUtils.moveDirectory(src, destination);
+
+        // Check results
+        assertTrue(destination.exists(), "Check Exist");
+        assertFalse(src.exists(), "Original deleted");
+        final File movedDir = new File(destination, testDir.getName());
+        final File movedFile = new File(movedDir, testFile.getName());
+        assertTrue(movedDir.exists(), "Check dir moved");
+        assertTrue(movedFile.exists(), "Check file moved");
+    }
+
+    @Test
+    public void testMoveDirectoryToDirectory() throws Exception {
+        final File dir = tempDirFile;
+        final File src = new File(dir, "testMoveDirectory1Source");
+        final File testChildDir = new File(src, "foo");
+        final File testFile = new File(testChildDir, "bar");
+        testChildDir.mkdirs();
+        if (!testFile.getParentFile().exists()) {
+            fail("Cannot create file " + testFile
+                    + " as the parent directory does not exist");
+        }
+        try (final OutputStream output = new BufferedOutputStream(Files.newOutputStream(testFile.toPath()))) {
+            TestUtils.generateTestData(output, 0);
+        }
+        final File destDir = new File(dir, "testMoveDirectory1Dest");
+        FileUtils.deleteDirectory(destDir);
+        assertFalse(destDir.exists(), "Check Exist before");
+
+        // Move the directory
+        FileUtils.moveDirectoryToDirectory(src, destDir, true);
+
+        // Check results
+        assertTrue(destDir.exists(), "Check Exist after");
+        assertFalse(src.exists(), "Original deleted");
+        final File movedDir = new File(destDir, src.getName());
+        final File movedChildDir = new File(movedDir, testChildDir.getName());
+        final File movedFile = new File(movedChildDir, testFile.getName());
+        assertTrue(movedDir.exists(), "Check dir moved");
+        assertTrue(movedChildDir.exists(), "Check child dir moved");
+        assertTrue(movedFile.exists(), "Check file moved");
+    }
+
+    @Test
+    public void testMoveDirectoryToDirectory_Errors() throws Exception {
+        assertThrows(NullPointerException.class, () -> FileUtils.moveDirectoryToDirectory(null, new File("foo"), true));
+        assertThrows(NullPointerException.class, () -> FileUtils.moveDirectoryToDirectory(new File("foo"), null, true));
+        final File testFile1 = new File(tempDirFile, "testMoveFileFile1");
+        final File testFile2 = new File(tempDirFile, "testMoveFileFile2");
+        if (!testFile1.getParentFile().exists()) {
+            fail("Cannot create file " + testFile1 + " as the parent directory does not exist");
+        }
+        try (final BufferedOutputStream output1 = new BufferedOutputStream(Files.newOutputStream(testFile1.toPath()))) {
+            TestUtils.generateTestData(output1, 0);
+        }
+        if (!testFile2.getParentFile().exists()) {
+            fail("Cannot create file " + testFile2 + " as the parent directory does not exist");
+        }
+        try (final BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(testFile2.toPath()))) {
+            TestUtils.generateTestData(output, 0);
+        }
+        assertThrows(IOException.class, () -> FileUtils.moveDirectoryToDirectory(testFile1, testFile2, true));
+
+        final File nonexistant = new File(tempDirFile, "testMoveFileNonExistant");
+        assertThrows(IOException.class, () -> FileUtils.moveDirectoryToDirectory(testFile1, nonexistant, false));
+    }
+
+    @Test
+    public void testMoveFile_CopyDelete() throws Exception {
+        final File destination = new File(tempDirFile, "move2.txt");
+        final File src = new File(testFile1.getAbsolutePath()) {
+            private static final long serialVersionUID = 1L;
+
+            // Force renameTo to fail, as if destination is on another
+            // filesystem
+            @Override
+            public boolean renameTo(final File f) {
+                return false;
+            }
+        };
+        FileUtils.moveFile(src, destination);
+        assertTrue(destination.exists(), "Check Exist");
+        assertFalse(src.exists(), "Original deleted");
+    }
+
+    @Test
+    public void testMoveFile_CopyDelete_Failed() {
+        final File destination = new File(tempDirFile, "move3.txt");
+        final File src = new File(testFile1.getAbsolutePath()) {
+            private static final long serialVersionUID = 1L;
+
+            // Force delete failure
+            @Override
+            public boolean delete() {
+                return false;
+            }
+
+            // Force renameTo to fail, as if destination is on another
+            // filesystem
+            @Override
+            public boolean renameTo(final File f) {
+                return false;
+            }
+
+        };
+        assertThrows(IOException.class, () -> FileUtils.moveFile(src, destination));
+        // expected
+        assertFalse(destination.exists(), "Check Rollback");
+        assertTrue(src.exists(), "Original exists");
+    }
+
+    @Test
+    public void testMoveFile_CopyDelete_WithFileDatePreservation() throws Exception {
+        final File destination = new File(tempDirFile, "move2.txt");
+
+        backDateFile10Minutes(testFile1); // set test file back 10 minutes
+
+        final File src = new File(testFile1.getAbsolutePath()) {
+            private static final long serialVersionUID = 1L;
+
+            // Force renameTo to fail, as if destination is on another
+            // filesystem
+            @Override
+            public boolean renameTo(final File f) {
+                return false;
+            }
+        };
+        final long expected = getLastModifiedMillis(testFile1);
+
+        FileUtils.moveFile(src, destination, StandardCopyOption.COPY_ATTRIBUTES);
+        assertTrue(destination.exists(), "Check Exist");
+        assertFalse(src.exists(), "Original deleted");
+
+        final long destLastMod = getLastModifiedMillis(destination);
+        final long delta = destLastMod - expected;
+        assertEquals(expected, destLastMod, "Check last modified date same as input, delta " + delta);
+    }
+
+    @Test
+    public void testMoveFile_CopyDelete_WithoutFileDatePreservation() throws Exception {
+        final File destination = new File(tempDirFile, "move2.txt");
+
+        backDateFile10Minutes(testFile1); // set test file back 10 minutes
+
+        // destination file time should not be less than this (allowing for granularity)
+        final long nowMillis = System.currentTimeMillis() - 1000L;
+
+        final File src = new File(testFile1.getAbsolutePath()) {
+            private static final long serialVersionUID = 1L;
+
+            // Force renameTo to fail, as if destination is on another
+            // filesystem
+            @Override
+            public boolean renameTo(final File f) {
+                return false;
+            }
+        };
+        final long unexpectedMillis = getLastModifiedMillis(testFile1);
+
+        FileUtils.moveFile(src, destination, PathUtils.EMPTY_COPY_OPTIONS);
+        assertTrue(destination.exists(), "Check Exist");
+        assertFalse(src.exists(), "Original deleted");
+
+        // On Windows, the last modified time is copied by default.
+        if (!SystemUtils.IS_OS_WINDOWS) {
+            final long destLastModMillis = getLastModifiedMillis(destination);
+            final long deltaMillis = destLastModMillis - unexpectedMillis;
+            assertNotEquals(unexpectedMillis, destLastModMillis,
+                "Check last modified date not same as input, delta " + deltaMillis);
+            assertTrue(destLastModMillis > nowMillis,
+                destLastModMillis + " > " + nowMillis + " (delta " + deltaMillis + ")");
+        }
+    }
+
+    @Test
+    public void testMoveFile_Errors() throws Exception {
+        assertThrows(NullPointerException.class, () -> FileUtils.moveFile(null, new File("foo")));
+        assertThrows(NullPointerException.class, () -> FileUtils.moveFile(new File("foo"), null));
+        assertThrows(FileNotFoundException.class, () -> FileUtils.moveFile(new File("nonexistant"), new File("foo")));
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.moveFile(tempDirFile, new File("foo")));
+        final File testSourceFile = new File(tempDirFile, "testMoveFileSource");
+        final File testDestFile = new File(tempDirFile, "testMoveFileSource");
+        if (!testSourceFile.getParentFile().exists()) {
+            fail("Cannot create file " + testSourceFile + " as the parent directory does not exist");
+        }
+        final BufferedOutputStream output1 = new BufferedOutputStream(Files.newOutputStream(testSourceFile.toPath()));
+        try {
+            TestUtils.generateTestData(output1, 0);
+        } finally {
+            IOUtils.closeQuietly(output1);
+        }
+        assertTrue(testDestFile.getParentFile().exists(), () -> "Cannot create file " + testDestFile + " as the parent directory does not exist");
+        final BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(testDestFile.toPath()));
+        try {
+            TestUtils.generateTestData(output, 0);
+        } finally {
+            IOUtils.closeQuietly(output);
+        }
+        assertThrows(FileExistsException.class, () -> FileUtils.moveFile(testSourceFile, testDestFile),
+            "Expected FileExistsException when dest already exists");
+    }
+
+    @Test
+    public void testMoveFile_Rename() throws Exception {
+        final File destination = new File(tempDirFile, "move1.txt");
+
+        FileUtils.moveFile(testFile1, destination);
+        assertTrue(destination.exists(), "Check Exist");
+        assertFalse(testFile1.exists(), "Original deleted");
+    }
+
+    @Test
+    public void testMoveFileToDirectory() throws Exception {
+        final File destDir = new File(tempDirFile, "moveFileDestDir");
+        final File movedFile = new File(destDir, testFile1.getName());
+        assertFalse(destDir.exists(), "Check Exist before");
+        assertFalse(movedFile.exists(), "Check Exist before");
+
+        FileUtils.moveFileToDirectory(testFile1, destDir, true);
+        assertTrue(movedFile.exists(), "Check Exist after");
+        assertFalse(testFile1.exists(), "Original deleted");
+    }
+
+    @Test
+    public void testMoveFileToDirectory_Errors() throws Exception {
+        assertThrows(NullPointerException.class, () -> FileUtils.moveFileToDirectory(null, new File("foo"), true));
+        assertThrows(NullPointerException.class, () -> FileUtils.moveFileToDirectory(new File("foo"), null, true));
+        final File testFile1 = new File(tempDirFile, "testMoveFileFile1");
+        final File testFile2 = new File(tempDirFile, "testMoveFileFile2");
+        if (!testFile1.getParentFile().exists()) {
+            fail("Cannot create file " + testFile1 + " as the parent directory does not exist");
+        }
+        try (final BufferedOutputStream output1 = new BufferedOutputStream(Files.newOutputStream(testFile1.toPath()))) {
+            TestUtils.generateTestData(output1, 0);
+        }
+        if (!testFile2.getParentFile().exists()) {
+            fail("Cannot create file " + testFile2 + " as the parent directory does not exist");
+        }
+        final BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(testFile2.toPath()));
+        try {
+            TestUtils.generateTestData(output, 0);
+        } finally {
+            IOUtils.closeQuietly(output);
+        }
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.moveFileToDirectory(testFile1, testFile2, true));
+
+        final File nonexistant = new File(tempDirFile, "testMoveFileNonExistant");
+        assertThrows(IOException.class, () -> FileUtils.moveFileToDirectory(testFile1, nonexistant, false));
+    }
+
+    @Test
+    public void testMoveToDirectory() throws Exception {
+        final File destDir = new File(tempDirFile, "testMoveToDirectoryDestDir");
+        final File testDir = new File(tempDirFile, "testMoveToDirectoryTestDir");
+        final File testFile = new File(tempDirFile, "testMoveToDirectoryTestFile");
+        testDir.mkdirs();
+        if (!testFile.getParentFile().exists()) {
+            fail("Cannot create file " + testFile
+                    + " as the parent directory does not exist");
+        }
+        final BufferedOutputStream output =
+                new BufferedOutputStream(Files.newOutputStream(testFile.toPath()));
+        try {
+            TestUtils.generateTestData(output, 0);
+        } finally {
+            IOUtils.closeQuietly(output);
+        }
+        final File movedFile = new File(destDir, testFile.getName());
+        final File movedDir = new File(destDir, testFile.getName());
+
+        assertFalse(movedFile.exists(), "Check File Doesnt exist");
+        assertFalse(movedDir.exists(), "Check Dir Doesnt exist");
+
+        // Test moving a file
+        FileUtils.moveToDirectory(testFile, destDir, true);
+        assertTrue(movedFile.exists(), "Check File exists");
+        assertFalse(testFile.exists(), "Check Original File doesn't exist");
+
+        // Test moving a directory
+        FileUtils.moveToDirectory(testDir, destDir, true);
+        assertTrue(movedDir.exists(), "Check Dir exists");
+        assertFalse(testDir.exists(), "Check Original Dir doesn't exist");
+    }
+
+    @Test
+    public void testMoveToDirectory_Errors() throws Exception {
+        assertThrows(NullPointerException.class, () -> FileUtils.moveDirectoryToDirectory(null, new File("foo"), true));
+        assertThrows(NullPointerException.class, () -> FileUtils.moveDirectoryToDirectory(new File("foo"), null, true));
+        final File nonexistant = new File(tempDirFile, "nonexistant");
+        final File destDir = new File(tempDirFile, "MoveToDirectoryDestDir");
+        assertThrows(IOException.class, () -> FileUtils.moveToDirectory(nonexistant, destDir, true), "Expected IOException when source does not exist");
+
+    }
+
+    @Test
+    public void testReadFileToByteArray() throws Exception {
+        final File file = new File(tempDirFile, "read.txt");
+        Files.write(file.toPath(), new byte[] {11, 21, 31});
+
+        final byte[] data = FileUtils.readFileToByteArray(file);
+        assertEquals(3, data.length);
+        assertEquals(11, data[0]);
+        assertEquals(21, data[1]);
+        assertEquals(31, data[2]);
+    }
+
+    @Test
+    public void testReadFileToStringWithDefaultEncoding() throws Exception {
+        final File file = new File(tempDirFile, "read.obj");
+        final String fixture = "Hello /u1234";
+        Files.write(file.toPath(), fixture.getBytes());
+
+        assertEquals(fixture, FileUtils.readFileToString(file));
+    }
+
+    @Test
+    public void testReadFileToStringWithEncoding() throws Exception {
+        final File file = new File(tempDirFile, "read.obj");
+        final byte[] text = "Hello /u1234".getBytes(StandardCharsets.UTF_8);
+        Files.write(file.toPath(), text);
+
+        final String data = FileUtils.readFileToString(file, "UTF8");
+        assertEquals("Hello /u1234", data);
+    }
+
+    @Test
+    public void testReadLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        try {
+            final String[] data = {"hello", "/u1234", "", "this is", "some text"};
+            TestUtils.createLineBasedFile(file, data);
+
+            final List<String> lines = FileUtils.readLines(file, UTF_8);
+            assertEquals(Arrays.asList(data), lines);
+        } finally {
+            TestUtils.deleteFile(file);
+        }
+    }
+
+    @Test
+    public void testSizeOf() throws Exception {
+        final File file = new File(tempDirFile, getName());
+
+        // Null argument
+        assertThrows(NullPointerException.class, () -> FileUtils.sizeOf(null));
+
+        // Non-existent file
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.sizeOf(file));
+
+        // Creates file
+        file.createNewFile();
+
+        // New file
+        assertEquals(0, FileUtils.sizeOf(file));
+        file.delete();
+
+        // Existing file
+        assertEquals(testFile1Size, FileUtils.sizeOf(testFile1), "Unexpected files size");
+
+        // Existing directory
+        assertEquals(TEST_DIRECTORY_SIZE, FileUtils.sizeOf(tempDirFile), "Unexpected directory size");
+    }
+
+    @Test
+    public void testSizeOfAsBigInteger() throws Exception {
+        final File file = new File(tempDirFile, getName());
+
+        // Null argument
+        assertThrows(NullPointerException.class, () -> FileUtils.sizeOfAsBigInteger(null));
+        // Non-existent file
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.sizeOfAsBigInteger(file));
+
+        // Creates file
+        file.createNewFile();
+
+        // New file
+        assertEquals(BigInteger.ZERO, FileUtils.sizeOfAsBigInteger(file));
+        file.delete();
+
+        // Existing file
+        assertEquals(BigInteger.valueOf(testFile1Size), FileUtils.sizeOfAsBigInteger(testFile1),
+                "Unexpected files size");
+
+        // Existing directory
+        assertEquals(TEST_DIRECTORY_SIZE_BI, FileUtils.sizeOfAsBigInteger(tempDirFile),
+                "Unexpected directory size");
+    }
+
+    @Test
+    public void testSizeOfDirectory() throws Exception {
+        final File file = new File(tempDirFile, getName());
+
+        // Null argument
+        assertThrows(NullPointerException.class, () -> FileUtils.sizeOfDirectory(null));
+        // Non-existent file
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.sizeOfAsBigInteger(file));
+
+        // Creates file
+        file.createNewFile();
+
+        // Existing file
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.sizeOfDirectory(file));
+
+        // Existing directory
+        file.delete();
+        file.mkdir();
+
+        // Create a cyclic symlink
+        this.createCircularSymLink(file);
+
+        assertEquals(TEST_DIRECTORY_SIZE, FileUtils.sizeOfDirectory(file), "Unexpected directory size");
+    }
+
+    @Test
+    public void testSizeOfDirectoryAsBigInteger() throws Exception {
+        final File file = new File(tempDirFile, getName());
+
+        // Null argument
+        assertThrows(NullPointerException.class, () -> FileUtils.sizeOfDirectoryAsBigInteger(null));
+        // Non-existent file
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.sizeOfDirectoryAsBigInteger(file));
+
+        // Creates file
+        file.createNewFile();
+
+        // Existing file
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.sizeOfDirectoryAsBigInteger(file));
+
+        // Existing directory
+        file.delete();
+        file.mkdir();
+
+        createCircularSymLink(file);
+
+        assertEquals(TEST_DIRECTORY_SIZE_BI, FileUtils.sizeOfDirectoryAsBigInteger(file), "Unexpected directory size");
+
+        // Existing directory which size is greater than zero
+        file.delete();
+        file.mkdir();
+
+        final File nonEmptyFile = new File(file, "nonEmptyFile" + System.nanoTime());
+        assertTrue(nonEmptyFile.getParentFile().exists(), () -> "Cannot create file " + nonEmptyFile + " as the parent directory does not exist");
+        final OutputStream output = new BufferedOutputStream(Files.newOutputStream(nonEmptyFile.toPath()));
+        try {
+            TestUtils.generateTestData(output, TEST_DIRECTORY_SIZE_GT_ZERO_BI.longValue());
+        } finally {
+            IOUtils.closeQuietly(output);
+        }
+
+        assertEquals(TEST_DIRECTORY_SIZE_GT_ZERO_BI, FileUtils.sizeOfDirectoryAsBigInteger(file), "Unexpected directory size");
+
+        nonEmptyFile.delete();
+        file.delete();
+    }
+
+    @Test
+    public void testToFile1() throws Exception {
+        final URL url = new URL("file", null, "a/b/c/file.txt");
+        final File file = FileUtils.toFile(url);
+        assertTrue(file.toString().contains("file.txt"));
+    }
+
+    @Test
+    public void testToFile2() throws Exception {
+        final URL url = new URL("file", null, "a/b/c/file%20n%61me%2520.tx%74");
+        final File file = FileUtils.toFile(url);
+        assertTrue(file.toString().contains("file name%20.txt"));
+    }
+
+    @Test
+    public void testToFile3() throws Exception {
+        assertNull(FileUtils.toFile(null));
+        assertNull(FileUtils.toFile(new URL("http://jakarta.apache.org")));
+    }
+
+    @Test
+    public void testToFile4() throws Exception {
+        final URL url = new URL("file", null, "a/b/c/file%%20%me.txt%");
+        final File file = FileUtils.toFile(url);
+        assertTrue(file.toString().contains("file% %me.txt%"));
+    }
+
+    /* IO-252 */
+    @Test
+    public void testToFile5() throws Exception {
+        final URL url = new URL("file", null, "both%20are%20100%20%25%20true");
+        final File file = FileUtils.toFile(url);
+        assertEquals("both are 100 % true", file.toString());
+    }
+
+    @Test
+    public void testToFiles1() throws Exception {
+        final URL[] urls = {
+                new URL("file", null, "file1.txt"),
+                new URL("file", null, "file2.txt"),
+        };
+        final File[] files = FileUtils.toFiles(urls);
+
+        assertEquals(urls.length, files.length);
+        assertTrue(files[0].toString().contains("file1.txt"), "File: " + files[0]);
+        assertTrue(files[1].toString().contains("file2.txt"), "File: " + files[1]);
+    }
+
+    @Test
+    public void testToFiles2() throws Exception {
+        final URL[] urls = {
+                new URL("file", null, "file1.txt"),
+                null,
+        };
+        final File[] files = FileUtils.toFiles(urls);
+
+        assertEquals(urls.length, files.length);
+        assertTrue(files[0].toString().contains("file1.txt"), "File: " + files[0]);
+        assertNull(files[1], "File: " + files[1]);
+    }
+
+    @Test
+    public void testToFiles3() throws Exception {
+        final URL[] urls = null;
+        final File[] files = FileUtils.toFiles(urls);
+
+        assertEquals(0, files.length);
+    }
+
+    @Test
+    public void testToFiles3a() throws Exception {
+        final URL[] urls = {}; // empty array
+        final File[] files = FileUtils.toFiles(urls);
+
+        assertEquals(0, files.length);
+    }
+
+    @Test
+    public void testToFiles4() throws Exception {
+        final URL[] urls = {
+                new URL("file", null, "file1.txt"),
+                new URL("http", "jakarta.apache.org", "file1.txt"),
+        };
+        assertThrows(IllegalArgumentException.class, () -> FileUtils.toFiles(urls));
+    }
+
+    @Test
+    public void testToFileUtf8() throws Exception {
+        final URL url = new URL("file", null, "/home/%C3%A4%C3%B6%C3%BC%C3%9F");
+        final File file = FileUtils.toFile(url);
+        assertTrue(file.toString().contains("\u00E4\u00F6\u00FC\u00DF"));
+    }
+
+    @Test
+    public void testTouch() throws IOException {
+        assertThrows(NullPointerException.class, () -> FileUtils.touch(null));
+
+        final File file = new File(tempDirFile, "touch.txt");
+        if (file.exists()) {
+            file.delete();
+        }
+        assertFalse(file.exists(), "Bad test: test file still exists");
+        FileUtils.touch(file);
+        assertTrue(file.exists(), "FileUtils.touch() created file");
+        try (OutputStream out = Files.newOutputStream(file.toPath())) {
+            assertEquals(0, file.length(), "Created empty file.");
+            out.write(0);
+        }
+        assertEquals(1, file.length(), "Wrote one byte to file");
+        final long y2k = new GregorianCalendar(2000, 0, 1).getTime().getTime();
+        final boolean res = setLastModifiedMillis(file, y2k);  // 0L fails on Win98
+        assertTrue(res, "Bad test: set lastModified failed");
+        assertEquals(y2k, getLastModifiedMillis(file), "Bad test: set lastModified set incorrect value");
+        final long nowMillis = System.currentTimeMillis();
+        FileUtils.touch(file);
+        assertEquals(1, file.length(), "FileUtils.touch() didn't empty the file.");
+        assertNotEquals(y2k, getLastModifiedMillis(file), "FileUtils.touch() changed lastModified");
+        final int delta = 3000;
+        assertTrue(getLastModifiedMillis(file) >= nowMillis - delta, "FileUtils.touch() changed lastModified to more than now-3s");
+        assertTrue(getLastModifiedMillis(file) <= nowMillis + delta, "FileUtils.touch() changed lastModified to less than now+3s");
+    }
+
+    @Test
+    public void testToURLs1() throws Exception {
+        final File[] files = {
+                new File(tempDirFile, "file1.txt"),
+                new File(tempDirFile, "file2.txt"),
+                new File(tempDirFile, "test file.txt"),
+        };
+        final URL[] urls = FileUtils.toURLs(files);
+
+        assertEquals(files.length, urls.length);
+        assertTrue(urls[0].toExternalForm().startsWith("file:"));
+        assertTrue(urls[0].toExternalForm().contains("file1.txt"));
+        assertTrue(urls[1].toExternalForm().startsWith("file:"));
+        assertTrue(urls[1].toExternalForm().contains("file2.txt"));
+
+        // Test escaped char
+        assertTrue(urls[2].toExternalForm().startsWith("file:"));
+        assertTrue(urls[2].toExternalForm().contains("test%20file.txt"));
+    }
+
+    @Test
+    public void testToURLs3a() throws Exception {
+        final File[] files = {}; // empty array
+        final URL[] urls = FileUtils.toURLs(files);
+
+        assertEquals(0, urls.length);
+    }
+
+    @Test
+    public void testWrite_WithAppendOptionFalse_ShouldDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        FileUtils.write(file, "this is brand new data", false);
+
+        final String expected = "this is brand new data";
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWrite_WithAppendOptionTrue_ShouldNotDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        FileUtils.write(file, "this is brand new data", true);
+
+        final String expected = "This line was there before you..."
+                + "this is brand new data";
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteByteArrayToFile() throws Exception {
+        final File file = new File(tempDirFile, "write.obj");
+        final byte[] data = {11, 21, 31};
+        FileUtils.writeByteArrayToFile(file, data);
+        TestUtils.assertEqualContent(data, file);
+    }
+
+    @Test
+    public void testWriteByteArrayToFile_WithAppendOptionFalse_ShouldDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        FileUtils.writeByteArrayToFile(file, "this is brand new data".getBytes(), false);
+
+        final String expected = "this is brand new data";
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteByteArrayToFile_WithAppendOptionTrue_ShouldNotDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        FileUtils.writeByteArrayToFile(file, "this is brand new data".getBytes(), true);
+
+        final String expected = "This line was there before you..."
+                + "this is brand new data";
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteByteArrayToFile_WithOffsetAndLength() throws Exception {
+        final File file = new File(tempDirFile, "write.obj");
+        final byte[] data = {11, 21, 32, 41, 51};
+        final byte[] writtenData = new byte[3];
+        System.arraycopy(data, 1, writtenData, 0, 3);
+        FileUtils.writeByteArrayToFile(file, data, 1, 3);
+        TestUtils.assertEqualContent(writtenData, file);
+    }
+
+    @Test
+    public void testWriteByteArrayToFile_WithOffsetAndLength_WithAppendOptionTrue_ShouldDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        final byte[] data = "SKIP_THIS_this is brand new data_AND_SKIP_THIS".getBytes(StandardCharsets.UTF_8);
+        FileUtils.writeByteArrayToFile(file, data, 10, 22, false);
+
+        final String expected = "this is brand new data";
+        final String actual = FileUtils.readFileToString(file, StandardCharsets.UTF_8);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteByteArrayToFile_WithOffsetAndLength_WithAppendOptionTrue_ShouldNotDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        final byte[] data = "SKIP_THIS_this is brand new data_AND_SKIP_THIS".getBytes(StandardCharsets.UTF_8);
+        FileUtils.writeByteArrayToFile(file, data, 10, 22, true);
+
+        final String expected = "This line was there before you..." + "this is brand new data";
+        final String actual = FileUtils.readFileToString(file, StandardCharsets.UTF_8);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteCharSequence1() throws Exception {
+        final File file = new File(tempDirFile, "write.txt");
+        FileUtils.write(file, "Hello /u1234", "UTF8");
+        final byte[] text = "Hello /u1234".getBytes(StandardCharsets.UTF_8);
+        TestUtils.assertEqualContent(text, file);
+    }
+
+    @Test
+    public void testWriteCharSequence2() throws Exception {
+        final File file = new File(tempDirFile, "write.txt");
+        FileUtils.write(file, "Hello /u1234", (String) null);
+        final byte[] text = "Hello /u1234".getBytes();
+        TestUtils.assertEqualContent(text, file);
+    }
+
+
+    @Test
+    public void testWriteLines_3arg_nullSeparator() throws Exception {
+        final Object[] data = {
+                "hello", new StringBuffer("world"), "", "this is", null, "some text"};
+        final List<Object> list = Arrays.asList(data);
+
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeLines(file, "US-ASCII", list);
+
+        final String expected = "hello" + System.lineSeparator() + "world" + System.lineSeparator() +
+                System.lineSeparator() + "this is" + System.lineSeparator() +
+                System.lineSeparator() + "some text" + System.lineSeparator();
+        final String actual = FileUtils.readFileToString(file, "US-ASCII");
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLines_3argsWithAppendOptionFalse_ShouldDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        final List<String> linesToAppend = Arrays.asList("my first line", "The second Line");
+        FileUtils.writeLines(file, linesToAppend, false);
+
+        final String expected = "my first line"
+                + System.lineSeparator() + "The second Line"
+                + System.lineSeparator();
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLines_3argsWithAppendOptionTrue_ShouldNotDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        final List<String> linesToAppend = Arrays.asList("my first line", "The second Line");
+        FileUtils.writeLines(file, linesToAppend, true);
+
+        final String expected = "This line was there before you..."
+                + "my first line"
+                + System.lineSeparator() + "The second Line"
+                + System.lineSeparator();
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLines_4arg() throws Exception {
+        final Object[] data = {
+                "hello", new StringBuffer("world"), "", "this is", null, "some text"};
+        final List<Object> list = Arrays.asList(data);
+
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeLines(file, "US-ASCII", list, "*");
+
+        final String expected = "hello*world**this is**some text*";
+        final String actual = FileUtils.readFileToString(file, "US-ASCII");
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLines_4arg_nullSeparator() throws Exception {
+        final Object[] data = {
+                "hello", new StringBuffer("world"), "", "this is", null, "some text"};
+        final List<Object> list = Arrays.asList(data);
+
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeLines(file, "US-ASCII", list, null);
+
+        final String expected = "hello" + System.lineSeparator() + "world" + System.lineSeparator() +
+                System.lineSeparator() + "this is" + System.lineSeparator() +
+                System.lineSeparator() + "some text" + System.lineSeparator();
+        final String actual = FileUtils.readFileToString(file, "US-ASCII");
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLines_4arg_Writer_nullData() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeLines(file, "US-ASCII", null, "*");
+
+        assertEquals(0, file.length(), "Sizes differ");
+    }
+
+    @Test
+    public void testWriteLines_4argsWithAppendOptionFalse_ShouldDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        final List<String> linesToAppend = Arrays.asList("my first line", "The second Line");
+        FileUtils.writeLines(file, linesToAppend, null, false);
+
+        final String expected = "my first line"
+                + System.lineSeparator() + "The second Line"
+                + System.lineSeparator();
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLines_4argsWithAppendOptionTrue_ShouldNotDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        final List<String> linesToAppend = Arrays.asList("my first line", "The second Line");
+        FileUtils.writeLines(file, linesToAppend, null, true);
+
+        final String expected = "This line was there before you..."
+                + "my first line"
+                + System.lineSeparator() + "The second Line"
+                + System.lineSeparator();
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLines_5argsWithAppendOptionFalse_ShouldDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        final List<String> linesToAppend = Arrays.asList("my first line", "The second Line");
+        FileUtils.writeLines(file, null, linesToAppend, null, false);
+
+        final String expected = "my first line"
+                + System.lineSeparator() + "The second Line"
+                + System.lineSeparator();
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLines_5argsWithAppendOptionTrue_ShouldNotDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        final List<String> linesToAppend = Arrays.asList("my first line", "The second Line");
+        FileUtils.writeLines(file, null, linesToAppend, null, true);
+
+        final String expected = "This line was there before you..."
+                + "my first line"
+                + System.lineSeparator() + "The second Line"
+                + System.lineSeparator();
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLinesEncoding_WithAppendOptionFalse_ShouldDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        final List<String> linesToAppend = Arrays.asList("my first line", "The second Line");
+        FileUtils.writeLines(file, null, linesToAppend, false);
+
+        final String expected = "my first line"
+                + System.lineSeparator() + "The second Line"
+                + System.lineSeparator();
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLinesEncoding_WithAppendOptionTrue_ShouldNotDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        final List<String> linesToAppend = Arrays.asList("my first line", "The second Line");
+        FileUtils.writeLines(file, null, linesToAppend, true);
+
+        final String expected = "This line was there before you..."
+                + "my first line"
+                + System.lineSeparator() + "The second Line"
+                + System.lineSeparator();
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteStringToFile_WithAppendOptionFalse_ShouldDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        FileUtils.writeStringToFile(file, "this is brand new data", false);
+
+        final String expected = "this is brand new data";
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteStringToFile_WithAppendOptionTrue_ShouldNotDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        FileUtils.writeStringToFile(file, "this is brand new data", true);
+
+        final String expected = "This line was there before you..."
+                + "this is brand new data";
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteStringToFileIntoNonExistentSubdir() throws Exception {
+        final File file = new File(tempDirFile, "subdir/write.txt");
+        FileUtils.writeStringToFile(file, "Hello /u1234", (Charset) null);
+        final byte[] text = "Hello /u1234".getBytes();
+        TestUtils.assertEqualContent(text, file);
+    }
+
+    @Test
+    public void testWriteStringToFileIntoSymlinkedDir() throws Exception {
+        final Path symlinkDir = createTempSymlinkedRelativeDir();
+
+        final File file = symlinkDir.resolve("file").toFile();
+        FileUtils.writeStringToFile(file, "Hello /u1234", StandardCharsets.UTF_8);
+
+        final byte[] text = "Hello /u1234".getBytes();
+        TestUtils.assertEqualContent(text, file);
+    }
+
+    @Test
+    public void testWriteStringToFileWithCharset() throws Exception {
+        final File file = new File(tempDirFile, "write.txt");
+        FileUtils.writeStringToFile(file, "Hello /u1234", "UTF8");
+        final byte[] text = "Hello /u1234".getBytes(StandardCharsets.UTF_8);
+        TestUtils.assertEqualContent(text, file);
+    }
+
+    @Test
+    public void testWriteStringToFileWithEncoding_WithAppendOptionFalse_ShouldDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        FileUtils.writeStringToFile(file, "this is brand new data", (String) null, false);
+
+        final String expected = "this is brand new data";
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteStringToFileWithEncoding_WithAppendOptionTrue_ShouldNotDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        FileUtils.writeStringToFile(file, "this is brand new data", (String) null, true);
+
+        final String expected = "This line was there before you..."
+                + "this is brand new data";
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteStringToFileWithNullCharset() throws Exception {
+        final File file = new File(tempDirFile, "write.txt");
+        FileUtils.writeStringToFile(file, "Hello /u1234", (Charset) null);
+        final byte[] text = "Hello /u1234".getBytes();
+        TestUtils.assertEqualContent(text, file);
+    }
+
+    @Test
+    public void testWriteStringToFileWithNullStringCharset() throws Exception {
+        final File file = new File(tempDirFile, "write.txt");
+        FileUtils.writeStringToFile(file, "Hello /u1234", (String) null);
+        final byte[] text = "Hello /u1234".getBytes();
+        TestUtils.assertEqualContent(text, file);
+    }
+
+    @Test
+    public void testWriteWithEncoding_WithAppendOptionFalse_ShouldDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        FileUtils.write(file, "this is brand new data", (String) null, false);
+
+        final String expected = "this is brand new data";
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteWithEncoding_WithAppendOptionTrue_ShouldNotDeletePreviousFileLines() throws Exception {
+        final File file = TestUtils.newFile(tempDirFile, "lines.txt");
+        FileUtils.writeStringToFile(file, "This line was there before you...");
+
+        FileUtils.write(file, "this is brand new data", (String) null, true);
+
+        final String expected = "This line was there before you..."
+                + "this is brand new data";
+        final String actual = FileUtils.readFileToString(file);
+        assertEquals(expected, actual);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/FileUtilsWaitForTest.java b/src/test/java/org/apache/commons/io/FileUtilsWaitForTest.java
new file mode 100644
index 0000000..6dc52d5
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FileUtilsWaitForTest.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * This is used to test FileUtils.waitFor() method for correctness.
+ * <p>
+ * This class has been broken out from FileUtilsTestCase to solve issues as per BZ 38927
+ * </p>
+ *
+ * @see FileUtils
+ */
+public class FileUtilsWaitForTest {
+
+    @Test
+    public void testWaitFor0() {
+        FileUtils.waitFor(FileUtils.current(), 0);
+    }
+
+    /**
+     * TODO Fails randomly.
+     */
+    @Test
+    public void testWaitForInterrupted() throws InterruptedException {
+        final AtomicBoolean wasInterrupted = new AtomicBoolean();
+        final CountDownLatch started = new CountDownLatch(2);
+        final int seconds = 10;
+        final Thread thread1 = new Thread(() -> {
+            started.countDown();
+            assertTrue(FileUtils.waitFor(FileUtils.current(), seconds));
+            wasInterrupted.set(Thread.currentThread().isInterrupted());
+        });
+        thread1.start();
+        // Make sure the thread does not finish before we interrupt it:
+        started.countDown();
+        thread1.interrupt();
+        started.await();
+        thread1.join();
+        assertTrue(wasInterrupted.get());
+    }
+
+    @Test
+    public void testWaitForNegativeDuration() {
+        FileUtils.waitFor(FileUtils.current(), -1);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/FilenameUtilsTest.java b/src/test/java/org/apache/commons/io/FilenameUtilsTest.java
new file mode 100644
index 0000000..6862067
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FilenameUtilsTest.java
@@ -0,0 +1,1200 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+
+import org.apache.commons.io.test.TestUtils;
+import org.apache.commons.lang3.SystemUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * This is used to test FilenameUtils for correctness.
+ *
+ * @see FilenameUtils
+ */
+public class FilenameUtilsTest {
+
+    private static final String SEP = "" + File.separatorChar;
+
+    private static final boolean WINDOWS = File.separatorChar == '\\';
+
+    @TempDir
+    public Path temporaryFolder;
+
+    private Path testFile1;
+    private Path testFile2;
+
+    private int testFile1Size;
+    private int testFile2Size;
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        testFile1 = Files.createTempFile(temporaryFolder, "test", "1");
+        testFile2 = Files.createTempFile(temporaryFolder, "test", "2");
+
+        testFile1Size = (int) Files.size(testFile1);
+        testFile2Size = (int) Files.size(testFile2);
+        if (!Files.exists(testFile1.getParent())) {
+            throw new IOException("Cannot create file " + testFile1
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output3 =
+                new BufferedOutputStream(Files.newOutputStream(testFile1))) {
+            TestUtils.generateTestData(output3, testFile1Size);
+        }
+        if (!Files.exists(testFile2.getParent())) {
+            throw new IOException("Cannot create file " + testFile2
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output2 =
+                new BufferedOutputStream(Files.newOutputStream(testFile2))) {
+            TestUtils.generateTestData(output2, testFile2Size);
+        }
+        if (!Files.exists(testFile1.getParent())) {
+            throw new IOException("Cannot create file " + testFile1
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output1 =
+                new BufferedOutputStream(Files.newOutputStream(testFile1))) {
+            TestUtils.generateTestData(output1, testFile1Size);
+        }
+        if (!Files.exists(testFile2.getParent())) {
+            throw new IOException("Cannot create file " + testFile2
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output =
+                new BufferedOutputStream(Files.newOutputStream(testFile2))) {
+            TestUtils.generateTestData(output, testFile2Size);
+        }
+    }
+
+    @Test
+    public void testConcat() {
+        assertNull(FilenameUtils.concat("", null));
+        assertNull(FilenameUtils.concat(null, null));
+        assertNull(FilenameUtils.concat(null, ""));
+        assertNull(FilenameUtils.concat(null, "a"));
+        assertEquals(SEP + "a", FilenameUtils.concat(null, "/a"));
+
+        assertNull(FilenameUtils.concat("", ":")); // invalid prefix
+        assertNull(FilenameUtils.concat(":", "")); // invalid prefix
+
+        assertEquals("f" + SEP, FilenameUtils.concat("", "f/"));
+        assertEquals("f", FilenameUtils.concat("", "f"));
+        assertEquals("a" + SEP + "f" + SEP, FilenameUtils.concat("a/", "f/"));
+        assertEquals("a" + SEP + "f", FilenameUtils.concat("a", "f"));
+        assertEquals("a" + SEP + "b" + SEP + "f" + SEP, FilenameUtils.concat("a/b/", "f/"));
+        assertEquals("a" + SEP + "b" + SEP + "f", FilenameUtils.concat("a/b", "f"));
+
+        assertEquals("a" + SEP + "f" + SEP, FilenameUtils.concat("a/b/", "../f/"));
+        assertEquals("a" + SEP + "f", FilenameUtils.concat("a/b", "../f"));
+        assertEquals("a" + SEP + "c" + SEP + "g" + SEP, FilenameUtils.concat("a/b/../c/", "f/../g/"));
+        assertEquals("a" + SEP + "c" + SEP + "g", FilenameUtils.concat("a/b/../c", "f/../g"));
+
+        assertEquals("a" + SEP + "c.txt" + SEP + "f", FilenameUtils.concat("a/c.txt", "f"));
+
+        assertEquals(SEP + "f" + SEP, FilenameUtils.concat("", "/f/"));
+        assertEquals(SEP + "f", FilenameUtils.concat("", "/f"));
+        assertEquals(SEP + "f" + SEP, FilenameUtils.concat("a/", "/f/"));
+        assertEquals(SEP + "f", FilenameUtils.concat("a", "/f"));
+
+        assertEquals(SEP + "c" + SEP + "d", FilenameUtils.concat("a/b/", "/c/d"));
+        assertEquals("C:c" + SEP + "d", FilenameUtils.concat("a/b/", "C:c/d"));
+        assertEquals("C:" + SEP + "c" + SEP + "d", FilenameUtils.concat("a/b/", "C:/c/d"));
+        assertEquals("~" + SEP + "c" + SEP + "d", FilenameUtils.concat("a/b/", "~/c/d"));
+        assertEquals("~user" + SEP + "c" + SEP + "d", FilenameUtils.concat("a/b/", "~user/c/d"));
+        assertEquals("~" + SEP, FilenameUtils.concat("a/b/", "~"));
+        assertEquals("~user" + SEP, FilenameUtils.concat("a/b/", "~user"));
+    }
+
+    @Test
+    public void testDirectoryContains() {
+        assertTrue(FilenameUtils.directoryContains("/foo", "/foo/bar"));
+        assertTrue(FilenameUtils.directoryContains("/foo/", "/foo/bar"));
+        assertTrue(FilenameUtils.directoryContains("C:\\foo", "C:\\foo\\bar"));
+        assertTrue(FilenameUtils.directoryContains("C:\\foo\\", "C:\\foo\\bar"));
+
+        assertFalse(FilenameUtils.directoryContains("/foo", "/foo"));
+        assertFalse(FilenameUtils.directoryContains("/foo", "/foobar"));
+        assertFalse(FilenameUtils.directoryContains("C:\\foo", "C:\\foobar"));
+        assertFalse(FilenameUtils.directoryContains("/foo", null));
+        assertFalse(FilenameUtils.directoryContains("", ""));
+        assertFalse(FilenameUtils.directoryContains("", "/foo"));
+        assertFalse(FilenameUtils.directoryContains("/foo", ""));
+    }
+
+    @Test
+    public void testEquals() {
+        assertTrue(FilenameUtils.equals(null, null));
+        assertFalse(FilenameUtils.equals(null, ""));
+        assertFalse(FilenameUtils.equals("", null));
+        assertTrue(FilenameUtils.equals("", ""));
+        assertTrue(FilenameUtils.equals("file.txt", "file.txt"));
+        assertFalse(FilenameUtils.equals("file.txt", "FILE.TXT"));
+        assertFalse(FilenameUtils.equals("a\\b\\file.txt", "a/b/file.txt"));
+    }
+
+    @Test
+    public void testEquals_fullControl() {
+        assertFalse(FilenameUtils.equals("file.txt", "FILE.TXT", true, IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.equals("file.txt", "FILE.TXT", true, IOCase.INSENSITIVE));
+        assertEquals(WINDOWS, FilenameUtils.equals("file.txt", "FILE.TXT", true, IOCase.SYSTEM));
+        assertFalse(FilenameUtils.equals("file.txt", "FILE.TXT", true, null));
+    }
+
+    @Test
+    public void testEqualsNormalized() {
+        assertTrue(FilenameUtils.equalsNormalized(null, null));
+        assertFalse(FilenameUtils.equalsNormalized(null, ""));
+        assertFalse(FilenameUtils.equalsNormalized("", null));
+        assertTrue(FilenameUtils.equalsNormalized("", ""));
+        assertTrue(FilenameUtils.equalsNormalized("file.txt", "file.txt"));
+        assertFalse(FilenameUtils.equalsNormalized("file.txt", "FILE.TXT"));
+        assertTrue(FilenameUtils.equalsNormalized("a\\b\\file.txt", "a/b/file.txt"));
+        assertFalse(FilenameUtils.equalsNormalized("a/b/", "a/b"));
+    }
+
+    /**
+     * Test for https://issues.apache.org/jira/browse/IO-128
+     */
+    @Test
+    public void testEqualsNormalizedError_IO_128() {
+        assertFalse(FilenameUtils.equalsNormalizedOnSystem("//file.txt", "file.txt"));
+        assertFalse(FilenameUtils.equalsNormalizedOnSystem("file.txt", "//file.txt"));
+        assertFalse(FilenameUtils.equalsNormalizedOnSystem("//file.txt", "//file.txt"));
+    }
+
+    @Test
+    public void testEqualsNormalizedOnSystem() {
+        assertTrue(FilenameUtils.equalsNormalizedOnSystem(null, null));
+        assertFalse(FilenameUtils.equalsNormalizedOnSystem(null, ""));
+        assertFalse(FilenameUtils.equalsNormalizedOnSystem("", null));
+        assertTrue(FilenameUtils.equalsNormalizedOnSystem("", ""));
+        assertTrue(FilenameUtils.equalsNormalizedOnSystem("file.txt", "file.txt"));
+        assertEquals(WINDOWS, FilenameUtils.equalsNormalizedOnSystem("file.txt", "FILE.TXT"));
+        assertTrue(FilenameUtils.equalsNormalizedOnSystem("a\\b\\file.txt", "a/b/file.txt"));
+        assertFalse(FilenameUtils.equalsNormalizedOnSystem("a/b/", "a/b"));
+    }
+
+    @Test
+    public void testEqualsOnSystem() {
+        assertTrue(FilenameUtils.equalsOnSystem(null, null));
+        assertFalse(FilenameUtils.equalsOnSystem(null, ""));
+        assertFalse(FilenameUtils.equalsOnSystem("", null));
+        assertTrue(FilenameUtils.equalsOnSystem("", ""));
+        assertTrue(FilenameUtils.equalsOnSystem("file.txt", "file.txt"));
+        assertEquals(WINDOWS, FilenameUtils.equalsOnSystem("file.txt", "FILE.TXT"));
+        assertFalse(FilenameUtils.equalsOnSystem("a\\b\\file.txt", "a/b/file.txt"));
+    }
+
+    @Test
+    public void testGetBaseName() {
+        assertNull(FilenameUtils.getBaseName(null));
+        assertEquals("noseperator", FilenameUtils.getBaseName("noseperator.inthispath"));
+        assertEquals("c", FilenameUtils.getBaseName("a/b/c.txt"));
+        assertEquals("c", FilenameUtils.getBaseName("a/b/c"));
+        assertEquals("", FilenameUtils.getBaseName("a/b/c/"));
+        assertEquals("c", FilenameUtils.getBaseName("a\\b\\c"));
+        assertEquals("file.txt", FilenameUtils.getBaseName("file.txt.bak"));
+    }
+
+    @Test
+    public void testGetBaseName_with_null_character() {
+        assertThrows(IllegalArgumentException.class, () -> FilenameUtils.getBaseName("fil\u0000e.txt.bak"));
+    }
+
+    @Test
+    public void testGetExtension() {
+        assertNull(FilenameUtils.getExtension(null));
+        assertEquals("ext", FilenameUtils.getExtension("file.ext"));
+        assertEquals("", FilenameUtils.getExtension("README"));
+        assertEquals("com", FilenameUtils.getExtension("domain.dot.com"));
+        assertEquals("jpeg", FilenameUtils.getExtension("image.jpeg"));
+        assertEquals("", FilenameUtils.getExtension("a.b/c"));
+        assertEquals("txt", FilenameUtils.getExtension("a.b/c.txt"));
+        assertEquals("", FilenameUtils.getExtension("a/b/c"));
+        assertEquals("", FilenameUtils.getExtension("a.b\\c"));
+        assertEquals("txt", FilenameUtils.getExtension("a.b\\c.txt"));
+        assertEquals("", FilenameUtils.getExtension("a\\b\\c"));
+        assertEquals("", FilenameUtils.getExtension("C:\\temp\\foo.bar\\README"));
+        assertEquals("ext", FilenameUtils.getExtension("../filename.ext"));
+
+        if (FilenameUtils.isSystemWindows()) {
+            // Special case handling for NTFS ADS names
+            try {
+                FilenameUtils.getExtension("foo.exe:bar.txt");
+                throw new AssertionError("Expected Exception");
+            } catch (final IllegalArgumentException e) {
+                assertEquals("NTFS ADS separator (':') in file name is forbidden.", e.getMessage());
+            }
+        } else {
+            // Upwards compatibility:
+            assertEquals("txt", FilenameUtils.getExtension("foo.exe:bar.txt"));
+        }
+    }
+
+    @Test
+    public void testGetFullPath() {
+        assertNull(FilenameUtils.getFullPath(null));
+        assertEquals("", FilenameUtils.getFullPath("noseperator.inthispath"));
+        assertEquals("a/b/", FilenameUtils.getFullPath("a/b/c.txt"));
+        assertEquals("a/b/", FilenameUtils.getFullPath("a/b/c"));
+        assertEquals("a/b/c/", FilenameUtils.getFullPath("a/b/c/"));
+        assertEquals("a\\b\\", FilenameUtils.getFullPath("a\\b\\c"));
+
+        assertNull(FilenameUtils.getFullPath(":"));
+        assertNull(FilenameUtils.getFullPath("1:/a/b/c.txt"));
+        assertNull(FilenameUtils.getFullPath("1:"));
+        assertNull(FilenameUtils.getFullPath("1:a"));
+        assertNull(FilenameUtils.getFullPath("///a/b/c.txt"));
+        assertNull(FilenameUtils.getFullPath("//a"));
+
+        assertEquals("", FilenameUtils.getFullPath(""));
+
+        if (SystemUtils.IS_OS_WINDOWS) {
+            assertEquals("C:", FilenameUtils.getFullPath("C:"));
+        }
+        if (SystemUtils.IS_OS_LINUX) {
+            assertEquals("", FilenameUtils.getFullPath("C:"));
+        }
+
+        assertEquals("C:/", FilenameUtils.getFullPath("C:/"));
+        assertEquals("//server/", FilenameUtils.getFullPath("//server/"));
+        assertEquals("~/", FilenameUtils.getFullPath("~"));
+        assertEquals("~/", FilenameUtils.getFullPath("~/"));
+        assertEquals("~user/", FilenameUtils.getFullPath("~user"));
+        assertEquals("~user/", FilenameUtils.getFullPath("~user/"));
+
+        assertEquals("a/b/", FilenameUtils.getFullPath("a/b/c.txt"));
+        assertEquals("/a/b/", FilenameUtils.getFullPath("/a/b/c.txt"));
+        assertEquals("C:", FilenameUtils.getFullPath("C:a"));
+        assertEquals("C:a/b/", FilenameUtils.getFullPath("C:a/b/c.txt"));
+        assertEquals("C:/a/b/", FilenameUtils.getFullPath("C:/a/b/c.txt"));
+        assertEquals("//server/a/b/", FilenameUtils.getFullPath("//server/a/b/c.txt"));
+        assertEquals("~/a/b/", FilenameUtils.getFullPath("~/a/b/c.txt"));
+        assertEquals("~user/a/b/", FilenameUtils.getFullPath("~user/a/b/c.txt"));
+    }
+
+    @Test
+    public void testGetFullPathNoEndSeparator() {
+        assertNull(FilenameUtils.getFullPathNoEndSeparator(null));
+        assertEquals("", FilenameUtils.getFullPathNoEndSeparator("noseperator.inthispath"));
+        assertEquals("a/b", FilenameUtils.getFullPathNoEndSeparator("a/b/c.txt"));
+        assertEquals("a/b", FilenameUtils.getFullPathNoEndSeparator("a/b/c"));
+        assertEquals("a/b/c", FilenameUtils.getFullPathNoEndSeparator("a/b/c/"));
+        assertEquals("a\\b", FilenameUtils.getFullPathNoEndSeparator("a\\b\\c"));
+
+        assertNull(FilenameUtils.getFullPathNoEndSeparator(":"));
+        assertNull(FilenameUtils.getFullPathNoEndSeparator("1:/a/b/c.txt"));
+        assertNull(FilenameUtils.getFullPathNoEndSeparator("1:"));
+        assertNull(FilenameUtils.getFullPathNoEndSeparator("1:a"));
+        assertNull(FilenameUtils.getFullPathNoEndSeparator("///a/b/c.txt"));
+        assertNull(FilenameUtils.getFullPathNoEndSeparator("//a"));
+
+        assertEquals("", FilenameUtils.getFullPathNoEndSeparator(""));
+
+        if (SystemUtils.IS_OS_WINDOWS) {
+            assertEquals("C:", FilenameUtils.getFullPathNoEndSeparator("C:"));
+        }
+        if (SystemUtils.IS_OS_LINUX) {
+            assertEquals("", FilenameUtils.getFullPathNoEndSeparator("C:"));
+        }
+
+        assertEquals("C:/", FilenameUtils.getFullPathNoEndSeparator("C:/"));
+        assertEquals("//server/", FilenameUtils.getFullPathNoEndSeparator("//server/"));
+        assertEquals("~", FilenameUtils.getFullPathNoEndSeparator("~"));
+        assertEquals("~/", FilenameUtils.getFullPathNoEndSeparator("~/"));
+        assertEquals("~user", FilenameUtils.getFullPathNoEndSeparator("~user"));
+        assertEquals("~user/", FilenameUtils.getFullPathNoEndSeparator("~user/"));
+
+        assertEquals("a/b", FilenameUtils.getFullPathNoEndSeparator("a/b/c.txt"));
+        assertEquals("/a/b", FilenameUtils.getFullPathNoEndSeparator("/a/b/c.txt"));
+        assertEquals("C:", FilenameUtils.getFullPathNoEndSeparator("C:a"));
+        assertEquals("C:a/b", FilenameUtils.getFullPathNoEndSeparator("C:a/b/c.txt"));
+        assertEquals("C:/a/b", FilenameUtils.getFullPathNoEndSeparator("C:/a/b/c.txt"));
+        assertEquals("//server/a/b", FilenameUtils.getFullPathNoEndSeparator("//server/a/b/c.txt"));
+        assertEquals("~/a/b", FilenameUtils.getFullPathNoEndSeparator("~/a/b/c.txt"));
+        assertEquals("~user/a/b", FilenameUtils.getFullPathNoEndSeparator("~user/a/b/c.txt"));
+    }
+
+    /**
+     * Test for https://issues.apache.org/jira/browse/IO-248
+     */
+    @Test
+    public void testGetFullPathNoEndSeparator_IO_248() {
+
+        // Test single separator
+        assertEquals("/", FilenameUtils.getFullPathNoEndSeparator("/"));
+        assertEquals("\\", FilenameUtils.getFullPathNoEndSeparator("\\"));
+
+        // Test one level directory
+        assertEquals("/", FilenameUtils.getFullPathNoEndSeparator("/abc"));
+        assertEquals("\\", FilenameUtils.getFullPathNoEndSeparator("\\abc"));
+
+        // Test one level directory
+        assertEquals("/abc", FilenameUtils.getFullPathNoEndSeparator("/abc/xyz"));
+        assertEquals("\\abc", FilenameUtils.getFullPathNoEndSeparator("\\abc\\xyz"));
+    }
+
+    @Test
+    public void testGetName() {
+        assertNull(FilenameUtils.getName(null));
+        assertEquals("noseperator.inthispath", FilenameUtils.getName("noseperator.inthispath"));
+        assertEquals("c.txt", FilenameUtils.getName("a/b/c.txt"));
+        assertEquals("c", FilenameUtils.getName("a/b/c"));
+        assertEquals("", FilenameUtils.getName("a/b/c/"));
+        assertEquals("c", FilenameUtils.getName("a\\b\\c"));
+    }
+
+    @Test
+    public void testGetPath() {
+        assertNull(FilenameUtils.getPath(null));
+        assertEquals("", FilenameUtils.getPath("noseperator.inthispath"));
+        assertEquals("", FilenameUtils.getPath("/noseperator.inthispath"));
+        assertEquals("", FilenameUtils.getPath("\\noseperator.inthispath"));
+        assertEquals("a/b/", FilenameUtils.getPath("a/b/c.txt"));
+        assertEquals("a/b/", FilenameUtils.getPath("a/b/c"));
+        assertEquals("a/b/c/", FilenameUtils.getPath("a/b/c/"));
+        assertEquals("a\\b\\", FilenameUtils.getPath("a\\b\\c"));
+
+        assertNull(FilenameUtils.getPath(":"));
+        assertNull(FilenameUtils.getPath("1:/a/b/c.txt"));
+        assertNull(FilenameUtils.getPath("1:"));
+        assertNull(FilenameUtils.getPath("1:a"));
+        assertNull(FilenameUtils.getPath("///a/b/c.txt"));
+        assertNull(FilenameUtils.getPath("//a"));
+
+        assertEquals("", FilenameUtils.getPath(""));
+        assertEquals("", FilenameUtils.getPath("C:"));
+        assertEquals("", FilenameUtils.getPath("C:/"));
+        assertEquals("", FilenameUtils.getPath("//server/"));
+        assertEquals("", FilenameUtils.getPath("~"));
+        assertEquals("", FilenameUtils.getPath("~/"));
+        assertEquals("", FilenameUtils.getPath("~user"));
+        assertEquals("", FilenameUtils.getPath("~user/"));
+
+        assertEquals("a/b/", FilenameUtils.getPath("a/b/c.txt"));
+        assertEquals("a/b/", FilenameUtils.getPath("/a/b/c.txt"));
+        assertEquals("", FilenameUtils.getPath("C:a"));
+        assertEquals("a/b/", FilenameUtils.getPath("C:a/b/c.txt"));
+        assertEquals("a/b/", FilenameUtils.getPath("C:/a/b/c.txt"));
+        assertEquals("a/b/", FilenameUtils.getPath("//server/a/b/c.txt"));
+        assertEquals("a/b/", FilenameUtils.getPath("~/a/b/c.txt"));
+        assertEquals("a/b/", FilenameUtils.getPath("~user/a/b/c.txt"));
+    }
+
+
+    @Test
+    public void testGetPath_with_null_character() {
+        assertThrows(IllegalArgumentException.class, () -> FilenameUtils.getPath("~user/a/\u0000b/c.txt"));
+    }
+
+    @Test
+    public void testGetPathNoEndSeparator() {
+        assertNull(FilenameUtils.getPath(null));
+        assertEquals("", FilenameUtils.getPath("noseperator.inthispath"));
+        assertEquals("", FilenameUtils.getPathNoEndSeparator("/noseperator.inthispath"));
+        assertEquals("", FilenameUtils.getPathNoEndSeparator("\\noseperator.inthispath"));
+        assertEquals("a/b", FilenameUtils.getPathNoEndSeparator("a/b/c.txt"));
+        assertEquals("a/b", FilenameUtils.getPathNoEndSeparator("a/b/c"));
+        assertEquals("a/b/c", FilenameUtils.getPathNoEndSeparator("a/b/c/"));
+        assertEquals("a\\b", FilenameUtils.getPathNoEndSeparator("a\\b\\c"));
+
+        assertNull(FilenameUtils.getPathNoEndSeparator(":"));
+        assertNull(FilenameUtils.getPathNoEndSeparator("1:/a/b/c.txt"));
+        assertNull(FilenameUtils.getPathNoEndSeparator("1:"));
+        assertNull(FilenameUtils.getPathNoEndSeparator("1:a"));
+        assertNull(FilenameUtils.getPathNoEndSeparator("///a/b/c.txt"));
+        assertNull(FilenameUtils.getPathNoEndSeparator("//a"));
+
+        assertEquals("", FilenameUtils.getPathNoEndSeparator(""));
+        assertEquals("", FilenameUtils.getPathNoEndSeparator("C:"));
+        assertEquals("", FilenameUtils.getPathNoEndSeparator("C:/"));
+        assertEquals("", FilenameUtils.getPathNoEndSeparator("//server/"));
+        assertEquals("", FilenameUtils.getPathNoEndSeparator("~"));
+        assertEquals("", FilenameUtils.getPathNoEndSeparator("~/"));
+        assertEquals("", FilenameUtils.getPathNoEndSeparator("~user"));
+        assertEquals("", FilenameUtils.getPathNoEndSeparator("~user/"));
+
+        assertEquals("a/b", FilenameUtils.getPathNoEndSeparator("a/b/c.txt"));
+        assertEquals("a/b", FilenameUtils.getPathNoEndSeparator("/a/b/c.txt"));
+        assertEquals("", FilenameUtils.getPathNoEndSeparator("C:a"));
+        assertEquals("a/b", FilenameUtils.getPathNoEndSeparator("C:a/b/c.txt"));
+        assertEquals("a/b", FilenameUtils.getPathNoEndSeparator("C:/a/b/c.txt"));
+        assertEquals("a/b", FilenameUtils.getPathNoEndSeparator("//server/a/b/c.txt"));
+        assertEquals("a/b", FilenameUtils.getPathNoEndSeparator("~/a/b/c.txt"));
+        assertEquals("a/b", FilenameUtils.getPathNoEndSeparator("~user/a/b/c.txt"));
+    }
+
+    @Test
+    public void testGetPathNoEndSeparator_with_null_character() {
+        assertThrows(IllegalArgumentException.class, () -> FilenameUtils.getPathNoEndSeparator("~user/a\u0000/b/c.txt"));
+    }
+
+    @Test
+    public void testGetPrefix() {
+        assertNull(FilenameUtils.getPrefix(null));
+        assertNull(FilenameUtils.getPrefix(":"));
+        assertNull(FilenameUtils.getPrefix("1:\\a\\b\\c.txt"));
+        assertNull(FilenameUtils.getPrefix("1:"));
+        assertNull(FilenameUtils.getPrefix("1:a"));
+        assertNull(FilenameUtils.getPrefix("\\\\\\a\\b\\c.txt"));
+        assertNull(FilenameUtils.getPrefix("\\\\a"));
+
+        assertEquals("", FilenameUtils.getPrefix(""));
+        assertEquals("\\", FilenameUtils.getPrefix("\\"));
+
+        if (SystemUtils.IS_OS_WINDOWS) {
+            assertEquals("C:", FilenameUtils.getPrefix("C:"));
+        }
+        if (SystemUtils.IS_OS_LINUX) {
+            assertEquals("", FilenameUtils.getPrefix("C:"));
+        }
+
+        assertEquals("C:\\", FilenameUtils.getPrefix("C:\\"));
+        assertEquals("//server/", FilenameUtils.getPrefix("//server/"));
+        assertEquals("~/", FilenameUtils.getPrefix("~"));
+        assertEquals("~/", FilenameUtils.getPrefix("~/"));
+        assertEquals("~user/", FilenameUtils.getPrefix("~user"));
+        assertEquals("~user/", FilenameUtils.getPrefix("~user/"));
+
+        assertEquals("", FilenameUtils.getPrefix("a\\b\\c.txt"));
+        assertEquals("\\", FilenameUtils.getPrefix("\\a\\b\\c.txt"));
+        assertEquals("C:\\", FilenameUtils.getPrefix("C:\\a\\b\\c.txt"));
+        assertEquals("\\\\server\\", FilenameUtils.getPrefix("\\\\server\\a\\b\\c.txt"));
+
+        assertEquals("", FilenameUtils.getPrefix("a/b/c.txt"));
+        assertEquals("/", FilenameUtils.getPrefix("/a/b/c.txt"));
+        assertEquals("C:/", FilenameUtils.getPrefix("C:/a/b/c.txt"));
+        assertEquals("//server/", FilenameUtils.getPrefix("//server/a/b/c.txt"));
+        assertEquals("~/", FilenameUtils.getPrefix("~/a/b/c.txt"));
+        assertEquals("~user/", FilenameUtils.getPrefix("~user/a/b/c.txt"));
+
+        assertEquals("", FilenameUtils.getPrefix("a\\b\\c.txt"));
+        assertEquals("\\", FilenameUtils.getPrefix("\\a\\b\\c.txt"));
+        assertEquals("~\\", FilenameUtils.getPrefix("~\\a\\b\\c.txt"));
+        assertEquals("~user\\", FilenameUtils.getPrefix("~user\\a\\b\\c.txt"));
+    }
+
+    @Test
+    public void testGetPrefix_with_null_character() {
+        assertThrows(IllegalArgumentException.class, () -> FilenameUtils.getPrefix("~u\u0000ser\\a\\b\\c.txt"));
+    }
+
+    @Test
+    public void testGetPrefixLength() {
+        assertEquals(-1, FilenameUtils.getPrefixLength(null));
+        assertEquals(-1, FilenameUtils.getPrefixLength(":"));
+        assertEquals(-1, FilenameUtils.getPrefixLength("1:\\a\\b\\c.txt"));
+        assertEquals(-1, FilenameUtils.getPrefixLength("1:"));
+        assertEquals(-1, FilenameUtils.getPrefixLength("1:a"));
+        assertEquals(-1, FilenameUtils.getPrefixLength("\\\\\\a\\b\\c.txt"));
+        assertEquals(-1, FilenameUtils.getPrefixLength("\\\\a"));
+
+        assertEquals(0, FilenameUtils.getPrefixLength(""));
+        assertEquals(1, FilenameUtils.getPrefixLength("\\"));
+
+        if (SystemUtils.IS_OS_WINDOWS) {
+            assertEquals(2, FilenameUtils.getPrefixLength("C:"));
+        }
+        if (SystemUtils.IS_OS_LINUX) {
+            assertEquals(0, FilenameUtils.getPrefixLength("C:"));
+        }
+
+        assertEquals(3, FilenameUtils.getPrefixLength("C:\\"));
+        assertEquals(9, FilenameUtils.getPrefixLength("//server/"));
+        assertEquals(2, FilenameUtils.getPrefixLength("~"));
+        assertEquals(2, FilenameUtils.getPrefixLength("~/"));
+        assertEquals(6, FilenameUtils.getPrefixLength("~user"));
+        assertEquals(6, FilenameUtils.getPrefixLength("~user/"));
+
+        assertEquals(0, FilenameUtils.getPrefixLength("a\\b\\c.txt"));
+        assertEquals(1, FilenameUtils.getPrefixLength("\\a\\b\\c.txt"));
+        assertEquals(2, FilenameUtils.getPrefixLength("C:a\\b\\c.txt"));
+        assertEquals(3, FilenameUtils.getPrefixLength("C:\\a\\b\\c.txt"));
+        assertEquals(9, FilenameUtils.getPrefixLength("\\\\server\\a\\b\\c.txt"));
+
+        assertEquals(0, FilenameUtils.getPrefixLength("a/b/c.txt"));
+        assertEquals(1, FilenameUtils.getPrefixLength("/a/b/c.txt"));
+        assertEquals(3, FilenameUtils.getPrefixLength("C:/a/b/c.txt"));
+        assertEquals(9, FilenameUtils.getPrefixLength("//server/a/b/c.txt"));
+        assertEquals(2, FilenameUtils.getPrefixLength("~/a/b/c.txt"));
+        assertEquals(6, FilenameUtils.getPrefixLength("~user/a/b/c.txt"));
+
+        assertEquals(0, FilenameUtils.getPrefixLength("a\\b\\c.txt"));
+        assertEquals(1, FilenameUtils.getPrefixLength("\\a\\b\\c.txt"));
+        assertEquals(2, FilenameUtils.getPrefixLength("~\\a\\b\\c.txt"));
+        assertEquals(6, FilenameUtils.getPrefixLength("~user\\a\\b\\c.txt"));
+
+        assertEquals(9, FilenameUtils.getPrefixLength("//server/a/b/c.txt"));
+        assertEquals(-1, FilenameUtils.getPrefixLength("\\\\\\a\\b\\c.txt"));
+        assertEquals(-1, FilenameUtils.getPrefixLength("///a/b/c.txt"));
+
+        assertEquals(1, FilenameUtils.getPrefixLength("/:foo"));
+        assertEquals(1, FilenameUtils.getPrefixLength("/:/"));
+        assertEquals(1, FilenameUtils.getPrefixLength("/:::::::.txt"));
+
+        assertEquals(12, FilenameUtils.getPrefixLength("\\\\127.0.0.1\\a\\b\\c.txt"));
+        assertEquals(6, FilenameUtils.getPrefixLength("\\\\::1\\a\\b\\c.txt"));
+        assertEquals(21, FilenameUtils.getPrefixLength("\\\\server.example.org\\a\\b\\c.txt"));
+        assertEquals(10, FilenameUtils.getPrefixLength("\\\\server.\\a\\b\\c.txt"));
+
+        assertEquals(-1, FilenameUtils.getPrefixLength("\\\\-server\\a\\b\\c.txt"));
+        assertEquals(-1, FilenameUtils.getPrefixLength("\\\\.\\a\\b\\c.txt"));
+        assertEquals(-1, FilenameUtils.getPrefixLength("\\\\..\\a\\b\\c.txt"));
+    }
+
+    @Test
+    public void testIndexOfExtension() {
+        assertEquals(-1, FilenameUtils.indexOfExtension(null));
+        assertEquals(-1, FilenameUtils.indexOfExtension("file"));
+        assertEquals(4, FilenameUtils.indexOfExtension("file.txt"));
+        assertEquals(13, FilenameUtils.indexOfExtension("a.txt/b.txt/c.txt"));
+        assertEquals(-1, FilenameUtils.indexOfExtension("a/b/c"));
+        assertEquals(-1, FilenameUtils.indexOfExtension("a\\b\\c"));
+        assertEquals(-1, FilenameUtils.indexOfExtension("a/b.notextension/c"));
+        assertEquals(-1, FilenameUtils.indexOfExtension("a\\b.notextension\\c"));
+
+        if (FilenameUtils.isSystemWindows()) {
+            // Special case handling for NTFS ADS names
+            try {
+                FilenameUtils.indexOfExtension("foo.exe:bar.txt");
+                throw new AssertionError("Expected Exception");
+            } catch (final IllegalArgumentException e) {
+                assertEquals("NTFS ADS separator (':') in file name is forbidden.", e.getMessage());
+            }
+        } else {
+            // Upwards compatibility on other systems
+            assertEquals(11, FilenameUtils.indexOfExtension("foo.exe:bar.txt"));
+        }
+
+    }
+
+    @Test
+    public void testIndexOfLastSeparator() {
+        assertEquals(-1, FilenameUtils.indexOfLastSeparator(null));
+        assertEquals(-1, FilenameUtils.indexOfLastSeparator("noseperator.inthispath"));
+        assertEquals(3, FilenameUtils.indexOfLastSeparator("a/b/c"));
+        assertEquals(3, FilenameUtils.indexOfLastSeparator("a\\b\\c"));
+    }
+
+    @Test
+    public void testInjectionFailure() {
+        assertThrows(IllegalArgumentException.class, () -> FilenameUtils.getName("a\\b\\\u0000c"));
+    }
+
+    @Test
+    public void testIsExtension() {
+        assertFalse(FilenameUtils.isExtension(null, (String) null));
+        assertFalse(FilenameUtils.isExtension("file.txt", (String) null));
+        assertTrue(FilenameUtils.isExtension("file", (String) null));
+        assertFalse(FilenameUtils.isExtension("file.txt", ""));
+        assertTrue(FilenameUtils.isExtension("file", ""));
+        assertTrue(FilenameUtils.isExtension("file.txt", "txt"));
+        assertFalse(FilenameUtils.isExtension("file.txt", "rtf"));
+
+        assertFalse(FilenameUtils.isExtension("a/b/file.txt", (String) null));
+        assertFalse(FilenameUtils.isExtension("a/b/file.txt", ""));
+        assertTrue(FilenameUtils.isExtension("a/b/file.txt", "txt"));
+        assertFalse(FilenameUtils.isExtension("a/b/file.txt", "rtf"));
+
+        assertFalse(FilenameUtils.isExtension("a.b/file.txt", (String) null));
+        assertFalse(FilenameUtils.isExtension("a.b/file.txt", ""));
+        assertTrue(FilenameUtils.isExtension("a.b/file.txt", "txt"));
+        assertFalse(FilenameUtils.isExtension("a.b/file.txt", "rtf"));
+
+        assertFalse(FilenameUtils.isExtension("a\\b\\file.txt", (String) null));
+        assertFalse(FilenameUtils.isExtension("a\\b\\file.txt", ""));
+        assertTrue(FilenameUtils.isExtension("a\\b\\file.txt", "txt"));
+        assertFalse(FilenameUtils.isExtension("a\\b\\file.txt", "rtf"));
+
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt", (String) null));
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt", ""));
+        assertTrue(FilenameUtils.isExtension("a.b\\file.txt", "txt"));
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt", "rtf"));
+
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt", "TXT"));
+    }
+
+    @Test
+    public void testIsExtension_injection() {
+        assertThrows(IllegalArgumentException.class, () -> FilenameUtils.isExtension("a.b\\fi\u0000le.txt", "TXT"));
+    }
+
+    @Test
+    public void testIsExtensionArray() {
+        assertFalse(FilenameUtils.isExtension(null, (String[]) null));
+        assertFalse(FilenameUtils.isExtension("file.txt", (String[]) null));
+        assertTrue(FilenameUtils.isExtension("file", (String[]) null));
+        assertFalse(FilenameUtils.isExtension("file.txt"));
+        assertTrue(FilenameUtils.isExtension("file.txt", new String[]{"txt"}));
+        assertFalse(FilenameUtils.isExtension("file.txt", new String[]{"rtf"}));
+        assertTrue(FilenameUtils.isExtension("file", "rtf", ""));
+        assertTrue(FilenameUtils.isExtension("file.txt", "rtf", "txt"));
+
+        assertFalse(FilenameUtils.isExtension("a/b/file.txt", (String[]) null));
+        assertFalse(FilenameUtils.isExtension("a/b/file.txt"));
+        assertTrue(FilenameUtils.isExtension("a/b/file.txt", new String[]{"txt"}));
+        assertFalse(FilenameUtils.isExtension("a/b/file.txt", new String[]{"rtf"}));
+        assertTrue(FilenameUtils.isExtension("a/b/file.txt", "rtf", "txt"));
+
+        assertFalse(FilenameUtils.isExtension("a.b/file.txt", (String[]) null));
+        assertFalse(FilenameUtils.isExtension("a.b/file.txt"));
+        assertTrue(FilenameUtils.isExtension("a.b/file.txt", new String[]{"txt"}));
+        assertFalse(FilenameUtils.isExtension("a.b/file.txt", new String[]{"rtf"}));
+        assertTrue(FilenameUtils.isExtension("a.b/file.txt", "rtf", "txt"));
+
+        assertFalse(FilenameUtils.isExtension("a\\b\\file.txt", (String[]) null));
+        assertFalse(FilenameUtils.isExtension("a\\b\\file.txt"));
+        assertTrue(FilenameUtils.isExtension("a\\b\\file.txt", new String[]{"txt"}));
+        assertFalse(FilenameUtils.isExtension("a\\b\\file.txt", new String[]{"rtf"}));
+        assertTrue(FilenameUtils.isExtension("a\\b\\file.txt", "rtf", "txt"));
+
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt", (String[]) null));
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt"));
+        assertTrue(FilenameUtils.isExtension("a.b\\file.txt", new String[]{"txt"}));
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt", new String[]{"rtf"}));
+        assertTrue(FilenameUtils.isExtension("a.b\\file.txt", "rtf", "txt"));
+
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt", new String[]{"TXT"}));
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt", "TXT", "RTF"));
+    }
+
+    @Test
+    public void testIsExtensionCollection() {
+        assertFalse(FilenameUtils.isExtension(null, (Collection<String>) null));
+        assertFalse(FilenameUtils.isExtension("file.txt", (Collection<String>) null));
+        assertTrue(FilenameUtils.isExtension("file", (Collection<String>) null));
+        assertFalse(FilenameUtils.isExtension("file.txt", new ArrayList<>()));
+        assertTrue(FilenameUtils.isExtension("file.txt", new ArrayList<>(Arrays.asList("txt"))));
+        assertFalse(FilenameUtils.isExtension("file.txt", new ArrayList<>(Arrays.asList("rtf"))));
+        assertTrue(FilenameUtils.isExtension("file", new ArrayList<>(Arrays.asList("rtf", ""))));
+        assertTrue(FilenameUtils.isExtension("file.txt", new ArrayList<>(Arrays.asList("rtf", "txt"))));
+
+        assertFalse(FilenameUtils.isExtension("a/b/file.txt", (Collection<String>) null));
+        assertFalse(FilenameUtils.isExtension("a/b/file.txt", new ArrayList<>()));
+        assertTrue(FilenameUtils.isExtension("a/b/file.txt", new ArrayList<>(Arrays.asList("txt"))));
+        assertFalse(FilenameUtils.isExtension("a/b/file.txt", new ArrayList<>(Arrays.asList("rtf"))));
+        assertTrue(FilenameUtils.isExtension("a/b/file.txt", new ArrayList<>(Arrays.asList("rtf", "txt"))));
+
+        assertFalse(FilenameUtils.isExtension("a.b/file.txt", (Collection<String>) null));
+        assertFalse(FilenameUtils.isExtension("a.b/file.txt", new ArrayList<>()));
+        assertTrue(FilenameUtils.isExtension("a.b/file.txt", new ArrayList<>(Arrays.asList("txt"))));
+        assertFalse(FilenameUtils.isExtension("a.b/file.txt", new ArrayList<>(Arrays.asList("rtf"))));
+        assertTrue(FilenameUtils.isExtension("a.b/file.txt", new ArrayList<>(Arrays.asList("rtf", "txt"))));
+
+        assertFalse(FilenameUtils.isExtension("a\\b\\file.txt", (Collection<String>) null));
+        assertFalse(FilenameUtils.isExtension("a\\b\\file.txt", new ArrayList<>()));
+        assertTrue(FilenameUtils.isExtension("a\\b\\file.txt", new ArrayList<>(Arrays.asList("txt"))));
+        assertFalse(FilenameUtils.isExtension("a\\b\\file.txt", new ArrayList<>(Arrays.asList("rtf"))));
+        assertTrue(FilenameUtils.isExtension("a\\b\\file.txt", new ArrayList<>(Arrays.asList("rtf", "txt"))));
+
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt", (Collection<String>) null));
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt", new ArrayList<>()));
+        assertTrue(FilenameUtils.isExtension("a.b\\file.txt", new ArrayList<>(Arrays.asList("txt"))));
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt", new ArrayList<>(Arrays.asList("rtf"))));
+        assertTrue(FilenameUtils.isExtension("a.b\\file.txt", new ArrayList<>(Arrays.asList("rtf", "txt"))));
+
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt", new ArrayList<>(Arrays.asList("TXT"))));
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt", new ArrayList<>(Arrays.asList("TXT", "RTF"))));
+    }
+
+    @Test
+    public void testIsExtensionVarArgs() {
+        assertTrue(FilenameUtils.isExtension("file.txt", "txt"));
+        assertFalse(FilenameUtils.isExtension("file.txt", "rtf"));
+        assertTrue(FilenameUtils.isExtension("file", "rtf", ""));
+        assertTrue(FilenameUtils.isExtension("file.txt", "rtf", "txt"));
+
+        assertTrue(FilenameUtils.isExtension("a/b/file.txt", "txt"));
+        assertFalse(FilenameUtils.isExtension("a/b/file.txt", "rtf"));
+        assertTrue(FilenameUtils.isExtension("a/b/file.txt", "rtf", "txt"));
+
+        assertTrue(FilenameUtils.isExtension("a.b/file.txt", "txt"));
+        assertFalse(FilenameUtils.isExtension("a.b/file.txt", "rtf"));
+        assertTrue(FilenameUtils.isExtension("a.b/file.txt", "rtf", "txt"));
+
+        assertTrue(FilenameUtils.isExtension("a\\b\\file.txt", "txt"));
+        assertFalse(FilenameUtils.isExtension("a\\b\\file.txt", "rtf"));
+        assertTrue(FilenameUtils.isExtension("a\\b\\file.txt", "rtf", "txt"));
+
+        assertTrue(FilenameUtils.isExtension("a.b\\file.txt", "txt"));
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt", "rtf"));
+        assertTrue(FilenameUtils.isExtension("a.b\\file.txt", "rtf", "txt"));
+
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt", "TXT"));
+        assertFalse(FilenameUtils.isExtension("a.b\\file.txt", "TXT", "RTF"));
+    }
+
+    @Test
+    public void testNormalize() {
+        assertNull(FilenameUtils.normalize(null));
+        assertNull(FilenameUtils.normalize(":"));
+        assertNull(FilenameUtils.normalize("1:\\a\\b\\c.txt"));
+        assertNull(FilenameUtils.normalize("1:"));
+        assertNull(FilenameUtils.normalize("1:a"));
+        assertNull(FilenameUtils.normalize("\\\\\\a\\b\\c.txt"));
+        assertNull(FilenameUtils.normalize("\\\\a"));
+
+        assertEquals("a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalize("a\\b/c.txt"));
+        assertEquals("" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalize("\\a\\b/c.txt"));
+        assertEquals("C:" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalize("C:\\a\\b/c.txt"));
+        assertEquals("" + SEP + "" + SEP + "server" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalize("\\\\server\\a\\b/c.txt"));
+        assertEquals("~" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalize("~\\a\\b/c.txt"));
+        assertEquals("~user" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalize("~user\\a\\b/c.txt"));
+
+        assertEquals("a" + SEP + "c", FilenameUtils.normalize("a/b/../c"));
+        assertEquals("c", FilenameUtils.normalize("a/b/../../c"));
+        assertEquals("c" + SEP, FilenameUtils.normalize("a/b/../../c/"));
+        assertNull(FilenameUtils.normalize("a/b/../../../c"));
+        assertEquals("a" + SEP, FilenameUtils.normalize("a/b/.."));
+        assertEquals("a" + SEP, FilenameUtils.normalize("a/b/../"));
+        assertEquals("", FilenameUtils.normalize("a/b/../.."));
+        assertEquals("", FilenameUtils.normalize("a/b/../../"));
+        assertNull(FilenameUtils.normalize("a/b/../../.."));
+        assertEquals("a" + SEP + "d", FilenameUtils.normalize("a/b/../c/../d"));
+        assertEquals("a" + SEP + "d" + SEP, FilenameUtils.normalize("a/b/../c/../d/"));
+        assertEquals("a" + SEP + "b" + SEP + "d", FilenameUtils.normalize("a/b//d"));
+        assertEquals("a" + SEP + "b" + SEP, FilenameUtils.normalize("a/b/././."));
+        assertEquals("a" + SEP + "b" + SEP, FilenameUtils.normalize("a/b/./././"));
+        assertEquals("a" + SEP, FilenameUtils.normalize("./a/"));
+        assertEquals("a", FilenameUtils.normalize("./a"));
+        assertEquals("", FilenameUtils.normalize("./"));
+        assertEquals("", FilenameUtils.normalize("."));
+        assertNull(FilenameUtils.normalize("../a"));
+        assertNull(FilenameUtils.normalize(".."));
+        assertEquals("", FilenameUtils.normalize(""));
+
+        assertEquals(SEP + "a", FilenameUtils.normalize("/a"));
+        assertEquals(SEP + "a" + SEP, FilenameUtils.normalize("/a/"));
+        assertEquals(SEP + "a" + SEP + "c", FilenameUtils.normalize("/a/b/../c"));
+        assertEquals(SEP + "c", FilenameUtils.normalize("/a/b/../../c"));
+        assertNull(FilenameUtils.normalize("/a/b/../../../c"));
+        assertEquals(SEP + "a" + SEP, FilenameUtils.normalize("/a/b/.."));
+        assertEquals(SEP + "", FilenameUtils.normalize("/a/b/../.."));
+        assertNull(FilenameUtils.normalize("/a/b/../../.."));
+        assertEquals(SEP + "a" + SEP + "d", FilenameUtils.normalize("/a/b/../c/../d"));
+        assertEquals(SEP + "a" + SEP + "b" + SEP + "d", FilenameUtils.normalize("/a/b//d"));
+        assertEquals(SEP + "a" + SEP + "b" + SEP, FilenameUtils.normalize("/a/b/././."));
+        assertEquals(SEP + "a", FilenameUtils.normalize("/./a"));
+        assertEquals(SEP + "", FilenameUtils.normalize("/./"));
+        assertEquals(SEP + "", FilenameUtils.normalize("/."));
+        assertNull(FilenameUtils.normalize("/../a"));
+        assertNull(FilenameUtils.normalize("/.."));
+        assertEquals(SEP + "", FilenameUtils.normalize("/"));
+
+        assertEquals("~" + SEP + "a", FilenameUtils.normalize("~/a"));
+        assertEquals("~" + SEP + "a" + SEP, FilenameUtils.normalize("~/a/"));
+        assertEquals("~" + SEP + "a" + SEP + "c", FilenameUtils.normalize("~/a/b/../c"));
+        assertEquals("~" + SEP + "c", FilenameUtils.normalize("~/a/b/../../c"));
+        assertNull(FilenameUtils.normalize("~/a/b/../../../c"));
+        assertEquals("~" + SEP + "a" + SEP, FilenameUtils.normalize("~/a/b/.."));
+        assertEquals("~" + SEP + "", FilenameUtils.normalize("~/a/b/../.."));
+        assertNull(FilenameUtils.normalize("~/a/b/../../.."));
+        assertEquals("~" + SEP + "a" + SEP + "d", FilenameUtils.normalize("~/a/b/../c/../d"));
+        assertEquals("~" + SEP + "a" + SEP + "b" + SEP + "d", FilenameUtils.normalize("~/a/b//d"));
+        assertEquals("~" + SEP + "a" + SEP + "b" + SEP, FilenameUtils.normalize("~/a/b/././."));
+        assertEquals("~" + SEP + "a", FilenameUtils.normalize("~/./a"));
+        assertEquals("~" + SEP, FilenameUtils.normalize("~/./"));
+        assertEquals("~" + SEP, FilenameUtils.normalize("~/."));
+        assertNull(FilenameUtils.normalize("~/../a"));
+        assertNull(FilenameUtils.normalize("~/.."));
+        assertEquals("~" + SEP, FilenameUtils.normalize("~/"));
+        assertEquals("~" + SEP, FilenameUtils.normalize("~"));
+
+        assertEquals("~user" + SEP + "a", FilenameUtils.normalize("~user/a"));
+        assertEquals("~user" + SEP + "a" + SEP, FilenameUtils.normalize("~user/a/"));
+        assertEquals("~user" + SEP + "a" + SEP + "c", FilenameUtils.normalize("~user/a/b/../c"));
+        assertEquals("~user" + SEP + "c", FilenameUtils.normalize("~user/a/b/../../c"));
+        assertNull(FilenameUtils.normalize("~user/a/b/../../../c"));
+        assertEquals("~user" + SEP + "a" + SEP, FilenameUtils.normalize("~user/a/b/.."));
+        assertEquals("~user" + SEP + "", FilenameUtils.normalize("~user/a/b/../.."));
+        assertNull(FilenameUtils.normalize("~user/a/b/../../.."));
+        assertEquals("~user" + SEP + "a" + SEP + "d", FilenameUtils.normalize("~user/a/b/../c/../d"));
+        assertEquals("~user" + SEP + "a" + SEP + "b" + SEP + "d", FilenameUtils.normalize("~user/a/b//d"));
+        assertEquals("~user" + SEP + "a" + SEP + "b" + SEP, FilenameUtils.normalize("~user/a/b/././."));
+        assertEquals("~user" + SEP + "a", FilenameUtils.normalize("~user/./a"));
+        assertEquals("~user" + SEP + "", FilenameUtils.normalize("~user/./"));
+        assertEquals("~user" + SEP + "", FilenameUtils.normalize("~user/."));
+        assertNull(FilenameUtils.normalize("~user/../a"));
+        assertNull(FilenameUtils.normalize("~user/.."));
+        assertEquals("~user" + SEP, FilenameUtils.normalize("~user/"));
+        assertEquals("~user" + SEP, FilenameUtils.normalize("~user"));
+
+        assertEquals("C:" + SEP + "a", FilenameUtils.normalize("C:/a"));
+        assertEquals("C:" + SEP + "a" + SEP, FilenameUtils.normalize("C:/a/"));
+        assertEquals("C:" + SEP + "a" + SEP + "c", FilenameUtils.normalize("C:/a/b/../c"));
+        assertEquals("C:" + SEP + "c", FilenameUtils.normalize("C:/a/b/../../c"));
+        assertNull(FilenameUtils.normalize("C:/a/b/../../../c"));
+        assertEquals("C:" + SEP + "a" + SEP, FilenameUtils.normalize("C:/a/b/.."));
+        assertEquals("C:" + SEP + "", FilenameUtils.normalize("C:/a/b/../.."));
+        assertNull(FilenameUtils.normalize("C:/a/b/../../.."));
+        assertEquals("C:" + SEP + "a" + SEP + "d", FilenameUtils.normalize("C:/a/b/../c/../d"));
+        assertEquals("C:" + SEP + "a" + SEP + "b" + SEP + "d", FilenameUtils.normalize("C:/a/b//d"));
+        assertEquals("C:" + SEP + "a" + SEP + "b" + SEP, FilenameUtils.normalize("C:/a/b/././."));
+        assertEquals("C:" + SEP + "a", FilenameUtils.normalize("C:/./a"));
+        assertEquals("C:" + SEP + "", FilenameUtils.normalize("C:/./"));
+        assertEquals("C:" + SEP + "", FilenameUtils.normalize("C:/."));
+        assertNull(FilenameUtils.normalize("C:/../a"));
+        assertNull(FilenameUtils.normalize("C:/.."));
+        assertEquals("C:" + SEP + "", FilenameUtils.normalize("C:/"));
+
+        assertEquals("C:" + "a", FilenameUtils.normalize("C:a"));
+        assertEquals("C:" + "a" + SEP, FilenameUtils.normalize("C:a/"));
+        assertEquals("C:" + "a" + SEP + "c", FilenameUtils.normalize("C:a/b/../c"));
+        assertEquals("C:" + "c", FilenameUtils.normalize("C:a/b/../../c"));
+        assertNull(FilenameUtils.normalize("C:a/b/../../../c"));
+        assertEquals("C:" + "a" + SEP, FilenameUtils.normalize("C:a/b/.."));
+        assertEquals("C:" + "", FilenameUtils.normalize("C:a/b/../.."));
+        assertNull(FilenameUtils.normalize("C:a/b/../../.."));
+        assertEquals("C:" + "a" + SEP + "d", FilenameUtils.normalize("C:a/b/../c/../d"));
+        assertEquals("C:" + "a" + SEP + "b" + SEP + "d", FilenameUtils.normalize("C:a/b//d"));
+        assertEquals("C:" + "a" + SEP + "b" + SEP, FilenameUtils.normalize("C:a/b/././."));
+        assertEquals("C:" + "a", FilenameUtils.normalize("C:./a"));
+        assertEquals("C:" + "", FilenameUtils.normalize("C:./"));
+        assertEquals("C:" + "", FilenameUtils.normalize("C:."));
+        assertNull(FilenameUtils.normalize("C:../a"));
+        assertNull(FilenameUtils.normalize("C:.."));
+        assertEquals("C:" + "", FilenameUtils.normalize("C:"));
+
+        assertEquals(SEP + SEP + "server" + SEP + "a", FilenameUtils.normalize("//server/a"));
+        assertEquals(SEP + SEP + "server" + SEP + "a" + SEP, FilenameUtils.normalize("//server/a/"));
+        assertEquals(SEP + SEP + "server" + SEP + "a" + SEP + "c", FilenameUtils.normalize("//server/a/b/../c"));
+        assertEquals(SEP + SEP + "server" + SEP + "c", FilenameUtils.normalize("//server/a/b/../../c"));
+        assertNull(FilenameUtils.normalize("//server/a/b/../../../c"));
+        assertEquals(SEP + SEP + "server" + SEP + "a" + SEP, FilenameUtils.normalize("//server/a/b/.."));
+        assertEquals(SEP + SEP + "server" + SEP + "", FilenameUtils.normalize("//server/a/b/../.."));
+        assertNull(FilenameUtils.normalize("//server/a/b/../../.."));
+        assertEquals(SEP + SEP + "server" + SEP + "a" + SEP + "d", FilenameUtils.normalize("//server/a/b/../c/../d"));
+        assertEquals(SEP + SEP + "server" + SEP + "a" + SEP + "b" + SEP + "d", FilenameUtils.normalize("//server/a/b//d"));
+        assertEquals(SEP + SEP + "server" + SEP + "a" + SEP + "b" + SEP, FilenameUtils.normalize("//server/a/b/././."));
+        assertEquals(SEP + SEP + "server" + SEP + "a", FilenameUtils.normalize("//server/./a"));
+        assertEquals(SEP + SEP + "server" + SEP + "", FilenameUtils.normalize("//server/./"));
+        assertEquals(SEP + SEP + "server" + SEP + "", FilenameUtils.normalize("//server/."));
+        assertNull(FilenameUtils.normalize("//server/../a"));
+        assertNull(FilenameUtils.normalize("//server/.."));
+        assertEquals(SEP + SEP + "server" + SEP + "", FilenameUtils.normalize("//server/"));
+
+        assertEquals(SEP + SEP + "127.0.0.1" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalize("\\\\127.0.0.1\\a\\b\\c.txt"));
+        assertEquals(SEP + SEP + "::1" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalize("\\\\::1\\a\\b\\c.txt"));
+        assertEquals(SEP + SEP + "1::" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalize("\\\\1::\\a\\b\\c.txt"));
+        assertEquals(SEP + SEP + "server.example.org" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalize("\\\\server.example.org\\a\\b\\c.txt"));
+        assertEquals(SEP + SEP + "server.sub.example.org" + SEP + "a" + SEP + "b" + SEP + "c.txt",
+            FilenameUtils.normalize("\\\\server.sub.example.org\\a\\b\\c.txt"));
+        assertEquals(SEP + SEP + "server." + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalize("\\\\server.\\a\\b\\c.txt"));
+        assertEquals(SEP + SEP + "1::127.0.0.1" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalize("\\\\1::127.0.0.1\\a\\b\\c.txt"));
+
+        // not valid IPv4 addresses but technically a valid "reg-name"s according to RFC1034
+        assertEquals(SEP + SEP + "127.0.0.256" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalize("\\\\127.0.0.256\\a\\b\\c.txt"));
+        assertEquals(SEP + SEP + "127.0.0.01" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalize("\\\\127.0.0.01\\a\\b\\c.txt"));
+
+        assertNull(FilenameUtils.normalize("\\\\-server\\a\\b\\c.txt"));
+        assertNull(FilenameUtils.normalize("\\\\.\\a\\b\\c.txt"));
+        assertNull(FilenameUtils.normalize("\\\\..\\a\\b\\c.txt"));
+        assertNull(FilenameUtils.normalize("\\\\127.0..1\\a\\b\\c.txt"));
+        assertNull(FilenameUtils.normalize("\\\\::1::2\\a\\b\\c.txt"));
+        assertNull(FilenameUtils.normalize("\\\\:1\\a\\b\\c.txt"));
+        assertNull(FilenameUtils.normalize("\\\\1:\\a\\b\\c.txt"));
+        assertNull(FilenameUtils.normalize("\\\\1:2:3:4:5:6:7:8:9\\a\\b\\c.txt"));
+        assertNull(FilenameUtils.normalize("\\\\g:2:3:4:5:6:7:8\\a\\b\\c.txt"));
+        assertNull(FilenameUtils.normalize("\\\\1ffff:2:3:4:5:6:7:8\\a\\b\\c.txt"));
+        assertNull(FilenameUtils.normalize("\\\\1:2\\a\\b\\c.txt"));
+        // IO-556
+        assertNull(FilenameUtils.normalize("//../foo"));
+        assertNull(FilenameUtils.normalize("\\\\..\\foo"));
+    }
+
+    /**
+     */
+    @Test
+    public void testNormalize_with_null_character() {
+        assertThrows(IllegalArgumentException.class, () -> FilenameUtils.normalize("a\\b/c\u0000.txt"));
+        assertThrows(IllegalArgumentException.class, () -> FilenameUtils.normalize("\u0000a\\b/c.txt"));
+    }
+
+    @Test
+    public void testNormalizeFromJavaDoc() {
+        // Examples from javadoc
+        assertEquals(SEP + "foo" + SEP, FilenameUtils.normalize("/foo//"));
+        assertEquals(SEP + "foo" + SEP, FilenameUtils.normalize(SEP + "foo" + SEP + "." + SEP));
+        assertEquals(SEP + "bar", FilenameUtils.normalize(SEP + "foo" + SEP + ".." + SEP + "bar"));
+        assertEquals(SEP + "bar" + SEP, FilenameUtils.normalize(SEP + "foo" + SEP + ".." + SEP + "bar" + SEP));
+        assertEquals(SEP + "baz", FilenameUtils.normalize(SEP + "foo" + SEP + ".." + SEP + "bar" + SEP + ".." + SEP + "baz"));
+        assertEquals(SEP + SEP + "foo" + SEP + "bar", FilenameUtils.normalize("//foo//./bar"));
+        assertNull(FilenameUtils.normalize(SEP + ".." + SEP));
+        assertNull(FilenameUtils.normalize(".." + SEP + "foo"));
+        assertEquals("foo" + SEP, FilenameUtils.normalize("foo" + SEP + "bar" + SEP + ".."));
+        assertNull(FilenameUtils.normalize("foo" + SEP + ".." + SEP + ".." + SEP + "bar"));
+        assertEquals("bar", FilenameUtils.normalize("foo" + SEP + ".." + SEP + "bar"));
+        assertEquals(SEP + SEP + "server" + SEP + "bar", FilenameUtils.normalize(SEP + SEP + "server" + SEP + "foo" + SEP + ".." + SEP + "bar"));
+        assertNull(FilenameUtils.normalize(SEP + SEP + "server" + SEP + ".." + SEP + "bar"));
+        assertEquals("C:" + SEP + "bar", FilenameUtils.normalize("C:" + SEP + "foo" + SEP + ".." + SEP + "bar"));
+        assertNull(FilenameUtils.normalize("C:" + SEP + ".." + SEP + "bar"));
+        assertEquals("~" + SEP + "bar" + SEP, FilenameUtils.normalize("~" + SEP + "foo" + SEP + ".." + SEP + "bar" + SEP));
+        assertNull(FilenameUtils.normalize("~" + SEP + ".." + SEP + "bar"));
+
+        assertEquals(SEP + SEP + "foo" + SEP + "bar", FilenameUtils.normalize("//foo//./bar"));
+        assertEquals(SEP + SEP + "foo" + SEP + "bar", FilenameUtils.normalize("\\\\foo\\\\.\\bar"));
+    }
+
+    @Test
+    public void testNormalizeNoEndSeparator() {
+        assertNull(FilenameUtils.normalizeNoEndSeparator(null));
+        assertNull(FilenameUtils.normalizeNoEndSeparator(":"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("1:\\a\\b\\c.txt"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("1:"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("1:a"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("\\\\\\a\\b\\c.txt"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("\\\\a"));
+
+        assertEquals("a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalizeNoEndSeparator("a\\b/c.txt"));
+        assertEquals("" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalizeNoEndSeparator("\\a\\b/c.txt"));
+        assertEquals("C:" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalizeNoEndSeparator("C:\\a\\b/c.txt"));
+        assertEquals("" + SEP + "" + SEP + "server" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalizeNoEndSeparator("\\\\server\\a\\b/c.txt"));
+        assertEquals("~" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalizeNoEndSeparator("~\\a\\b/c.txt"));
+        assertEquals("~user" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalizeNoEndSeparator("~user\\a\\b/c.txt"));
+        assertEquals("C:" + SEP + "a" + SEP + "b" + SEP + "c.txt", FilenameUtils.normalizeNoEndSeparator("C:\\\\a\\\\b\\\\c.txt"));
+
+        assertEquals("a" + SEP + "c", FilenameUtils.normalizeNoEndSeparator("a/b/../c"));
+        assertEquals("c", FilenameUtils.normalizeNoEndSeparator("a/b/../../c"));
+        assertEquals("c", FilenameUtils.normalizeNoEndSeparator("a/b/../../c/"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("a/b/../../../c"));
+        assertEquals("a", FilenameUtils.normalizeNoEndSeparator("a/b/.."));
+        assertEquals("a", FilenameUtils.normalizeNoEndSeparator("a/b/../"));
+        assertEquals("", FilenameUtils.normalizeNoEndSeparator("a/b/../.."));
+        assertEquals("", FilenameUtils.normalizeNoEndSeparator("a/b/../../"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("a/b/../../.."));
+        assertEquals("a" + SEP + "d", FilenameUtils.normalizeNoEndSeparator("a/b/../c/../d"));
+        assertEquals("a" + SEP + "d", FilenameUtils.normalizeNoEndSeparator("a/b/../c/../d/"));
+        assertEquals("a" + SEP + "b" + SEP + "d", FilenameUtils.normalizeNoEndSeparator("a/b//d"));
+        assertEquals("a" + SEP + "b", FilenameUtils.normalizeNoEndSeparator("a/b/././."));
+        assertEquals("a" + SEP + "b", FilenameUtils.normalizeNoEndSeparator("a/b/./././"));
+        assertEquals("a", FilenameUtils.normalizeNoEndSeparator("./a/"));
+        assertEquals("a", FilenameUtils.normalizeNoEndSeparator("./a"));
+        assertEquals("", FilenameUtils.normalizeNoEndSeparator("./"));
+        assertEquals("", FilenameUtils.normalizeNoEndSeparator("."));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("../a"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator(".."));
+        assertEquals("", FilenameUtils.normalizeNoEndSeparator(""));
+
+        assertEquals(SEP + "a", FilenameUtils.normalizeNoEndSeparator("/a"));
+        assertEquals(SEP + "a", FilenameUtils.normalizeNoEndSeparator("/a/"));
+        assertEquals(SEP + "a" + SEP + "c", FilenameUtils.normalizeNoEndSeparator("/a/b/../c"));
+        assertEquals(SEP + "c", FilenameUtils.normalizeNoEndSeparator("/a/b/../../c"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("/a/b/../../../c"));
+        assertEquals(SEP + "a", FilenameUtils.normalizeNoEndSeparator("/a/b/.."));
+        assertEquals(SEP + "", FilenameUtils.normalizeNoEndSeparator("/a/b/../.."));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("/a/b/../../.."));
+        assertEquals(SEP + "a" + SEP + "d", FilenameUtils.normalizeNoEndSeparator("/a/b/../c/../d"));
+        assertEquals(SEP + "a" + SEP + "b" + SEP + "d", FilenameUtils.normalizeNoEndSeparator("/a/b//d"));
+        assertEquals(SEP + "a" + SEP + "b", FilenameUtils.normalizeNoEndSeparator("/a/b/././."));
+        assertEquals(SEP + "a", FilenameUtils.normalizeNoEndSeparator("/./a"));
+        assertEquals(SEP + "", FilenameUtils.normalizeNoEndSeparator("/./"));
+        assertEquals(SEP + "", FilenameUtils.normalizeNoEndSeparator("/."));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("/../a"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("/.."));
+        assertEquals(SEP + "", FilenameUtils.normalizeNoEndSeparator("/"));
+
+        assertEquals("~" + SEP + "a", FilenameUtils.normalizeNoEndSeparator("~/a"));
+        assertEquals("~" + SEP + "a", FilenameUtils.normalizeNoEndSeparator("~/a/"));
+        assertEquals("~" + SEP + "a" + SEP + "c", FilenameUtils.normalizeNoEndSeparator("~/a/b/../c"));
+        assertEquals("~" + SEP + "c", FilenameUtils.normalizeNoEndSeparator("~/a/b/../../c"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("~/a/b/../../../c"));
+        assertEquals("~" + SEP + "a", FilenameUtils.normalizeNoEndSeparator("~/a/b/.."));
+        assertEquals("~" + SEP + "", FilenameUtils.normalizeNoEndSeparator("~/a/b/../.."));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("~/a/b/../../.."));
+        assertEquals("~" + SEP + "a" + SEP + "d", FilenameUtils.normalizeNoEndSeparator("~/a/b/../c/../d"));
+        assertEquals("~" + SEP + "a" + SEP + "b" + SEP + "d", FilenameUtils.normalizeNoEndSeparator("~/a/b//d"));
+        assertEquals("~" + SEP + "a" + SEP + "b", FilenameUtils.normalizeNoEndSeparator("~/a/b/././."));
+        assertEquals("~" + SEP + "a", FilenameUtils.normalizeNoEndSeparator("~/./a"));
+        assertEquals("~" + SEP, FilenameUtils.normalizeNoEndSeparator("~/./"));
+        assertEquals("~" + SEP, FilenameUtils.normalizeNoEndSeparator("~/."));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("~/../a"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("~/.."));
+        assertEquals("~" + SEP, FilenameUtils.normalizeNoEndSeparator("~/"));
+        assertEquals("~" + SEP, FilenameUtils.normalizeNoEndSeparator("~"));
+
+        assertEquals("~user" + SEP + "a", FilenameUtils.normalizeNoEndSeparator("~user/a"));
+        assertEquals("~user" + SEP + "a", FilenameUtils.normalizeNoEndSeparator("~user/a/"));
+        assertEquals("~user" + SEP + "a" + SEP + "c", FilenameUtils.normalizeNoEndSeparator("~user/a/b/../c"));
+        assertEquals("~user" + SEP + "c", FilenameUtils.normalizeNoEndSeparator("~user/a/b/../../c"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("~user/a/b/../../../c"));
+        assertEquals("~user" + SEP + "a", FilenameUtils.normalizeNoEndSeparator("~user/a/b/.."));
+        assertEquals("~user" + SEP + "", FilenameUtils.normalizeNoEndSeparator("~user/a/b/../.."));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("~user/a/b/../../.."));
+        assertEquals("~user" + SEP + "a" + SEP + "d", FilenameUtils.normalizeNoEndSeparator("~user/a/b/../c/../d"));
+        assertEquals("~user" + SEP + "a" + SEP + "b" + SEP + "d", FilenameUtils.normalizeNoEndSeparator("~user/a/b//d"));
+        assertEquals("~user" + SEP + "a" + SEP + "b", FilenameUtils.normalizeNoEndSeparator("~user/a/b/././."));
+        assertEquals("~user" + SEP + "a", FilenameUtils.normalizeNoEndSeparator("~user/./a"));
+        assertEquals("~user" + SEP + "", FilenameUtils.normalizeNoEndSeparator("~user/./"));
+        assertEquals("~user" + SEP + "", FilenameUtils.normalizeNoEndSeparator("~user/."));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("~user/../a"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("~user/.."));
+        assertEquals("~user" + SEP, FilenameUtils.normalizeNoEndSeparator("~user/"));
+        assertEquals("~user" + SEP, FilenameUtils.normalizeNoEndSeparator("~user"));
+
+        assertEquals("C:" + SEP + "a", FilenameUtils.normalizeNoEndSeparator("C:/a"));
+        assertEquals("C:" + SEP + "a", FilenameUtils.normalizeNoEndSeparator("C:/a/"));
+        assertEquals("C:" + SEP + "a" + SEP + "c", FilenameUtils.normalizeNoEndSeparator("C:/a/b/../c"));
+        assertEquals("C:" + SEP + "c", FilenameUtils.normalizeNoEndSeparator("C:/a/b/../../c"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("C:/a/b/../../../c"));
+        assertEquals("C:" + SEP + "a", FilenameUtils.normalizeNoEndSeparator("C:/a/b/.."));
+        assertEquals("C:" + SEP + "", FilenameUtils.normalizeNoEndSeparator("C:/a/b/../.."));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("C:/a/b/../../.."));
+        assertEquals("C:" + SEP + "a" + SEP + "d", FilenameUtils.normalizeNoEndSeparator("C:/a/b/../c/../d"));
+        assertEquals("C:" + SEP + "a" + SEP + "b" + SEP + "d", FilenameUtils.normalizeNoEndSeparator("C:/a/b//d"));
+        assertEquals("C:" + SEP + "a" + SEP + "b", FilenameUtils.normalizeNoEndSeparator("C:/a/b/././."));
+        assertEquals("C:" + SEP + "a", FilenameUtils.normalizeNoEndSeparator("C:/./a"));
+        assertEquals("C:" + SEP + "", FilenameUtils.normalizeNoEndSeparator("C:/./"));
+        assertEquals("C:" + SEP + "", FilenameUtils.normalizeNoEndSeparator("C:/."));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("C:/../a"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("C:/.."));
+        assertEquals("C:" + SEP + "", FilenameUtils.normalizeNoEndSeparator("C:/"));
+
+        assertEquals("C:" + "a", FilenameUtils.normalizeNoEndSeparator("C:a"));
+        assertEquals("C:" + "a", FilenameUtils.normalizeNoEndSeparator("C:a/"));
+        assertEquals("C:" + "a" + SEP + "c", FilenameUtils.normalizeNoEndSeparator("C:a/b/../c"));
+        assertEquals("C:" + "c", FilenameUtils.normalizeNoEndSeparator("C:a/b/../../c"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("C:a/b/../../../c"));
+        assertEquals("C:" + "a", FilenameUtils.normalizeNoEndSeparator("C:a/b/.."));
+        assertEquals("C:" + "", FilenameUtils.normalizeNoEndSeparator("C:a/b/../.."));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("C:a/b/../../.."));
+        assertEquals("C:" + "a" + SEP + "d", FilenameUtils.normalizeNoEndSeparator("C:a/b/../c/../d"));
+        assertEquals("C:" + "a" + SEP + "b" + SEP + "d", FilenameUtils.normalizeNoEndSeparator("C:a/b//d"));
+        assertEquals("C:" + "a" + SEP + "b", FilenameUtils.normalizeNoEndSeparator("C:a/b/././."));
+        assertEquals("C:" + "a", FilenameUtils.normalizeNoEndSeparator("C:./a"));
+        assertEquals("C:" + "", FilenameUtils.normalizeNoEndSeparator("C:./"));
+        assertEquals("C:" + "", FilenameUtils.normalizeNoEndSeparator("C:."));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("C:../a"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("C:.."));
+        assertEquals("C:" + "", FilenameUtils.normalizeNoEndSeparator("C:"));
+
+        assertEquals(SEP + SEP + "server" + SEP + "a", FilenameUtils.normalizeNoEndSeparator("//server/a"));
+        assertEquals(SEP + SEP + "server" + SEP + "a", FilenameUtils.normalizeNoEndSeparator("//server/a/"));
+        assertEquals(SEP + SEP + "server" + SEP + "a" + SEP + "c", FilenameUtils.normalizeNoEndSeparator("//server/a/b/../c"));
+        assertEquals(SEP + SEP + "server" + SEP + "c", FilenameUtils.normalizeNoEndSeparator("//server/a/b/../../c"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("//server/a/b/../../../c"));
+        assertEquals(SEP + SEP + "server" + SEP + "a", FilenameUtils.normalizeNoEndSeparator("//server/a/b/.."));
+        assertEquals(SEP + SEP + "server" + SEP + "", FilenameUtils.normalizeNoEndSeparator("//server/a/b/../.."));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("//server/a/b/../../.."));
+        assertEquals(SEP + SEP + "server" + SEP + "a" + SEP + "d", FilenameUtils.normalizeNoEndSeparator("//server/a/b/../c/../d"));
+        assertEquals(SEP + SEP + "server" + SEP + "a" + SEP + "b" + SEP + "d", FilenameUtils.normalizeNoEndSeparator("//server/a/b//d"));
+        assertEquals(SEP + SEP + "server" + SEP + "a" + SEP + "b", FilenameUtils.normalizeNoEndSeparator("//server/a/b/././."));
+        assertEquals(SEP + SEP + "server" + SEP + "a", FilenameUtils.normalizeNoEndSeparator("//server/./a"));
+        assertEquals(SEP + SEP + "server" + SEP + "", FilenameUtils.normalizeNoEndSeparator("//server/./"));
+        assertEquals(SEP + SEP + "server" + SEP + "", FilenameUtils.normalizeNoEndSeparator("//server/."));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("//server/../a"));
+        assertNull(FilenameUtils.normalizeNoEndSeparator("//server/.."));
+        assertEquals(SEP + SEP + "server" + SEP + "", FilenameUtils.normalizeNoEndSeparator("//server/"));
+    }
+
+    @Test
+    public void testNormalizeNoEndSeparatorUnixWin() {
+
+        // Normalize (Unix Separator)
+        assertEquals("/a/c", FilenameUtils.normalizeNoEndSeparator("/a/b/../c/", true));
+        assertEquals("/a/c", FilenameUtils.normalizeNoEndSeparator("\\a\\b\\..\\c\\", true));
+
+        // Normalize (Windows Separator)
+        assertEquals("\\a\\c", FilenameUtils.normalizeNoEndSeparator("/a/b/../c/", false));
+        assertEquals("\\a\\c", FilenameUtils.normalizeNoEndSeparator("\\a\\b\\..\\c\\", false));
+    }
+
+    @Test
+    public void testNormalizeUnixWin() {
+
+        // Normalize (Unix Separator)
+        assertEquals("/a/c/", FilenameUtils.normalize("/a/b/../c/", true));
+        assertEquals("/a/c/", FilenameUtils.normalize("\\a\\b\\..\\c\\", true));
+
+        // Normalize (Windows Separator)
+        assertEquals("\\a\\c\\", FilenameUtils.normalize("/a/b/../c/", false));
+        assertEquals("\\a\\c\\", FilenameUtils.normalize("\\a\\b\\..\\c\\", false));
+    }
+
+    @Test
+    public void testRemoveExtension() {
+        assertNull(FilenameUtils.removeExtension(null));
+        assertEquals("file", FilenameUtils.removeExtension("file.ext"));
+        assertEquals("README", FilenameUtils.removeExtension("README"));
+        assertEquals("domain.dot", FilenameUtils.removeExtension("domain.dot.com"));
+        assertEquals("image", FilenameUtils.removeExtension("image.jpeg"));
+        assertEquals("a.b/c", FilenameUtils.removeExtension("a.b/c"));
+        assertEquals("a.b/c", FilenameUtils.removeExtension("a.b/c.txt"));
+        assertEquals("a/b/c", FilenameUtils.removeExtension("a/b/c"));
+        assertEquals("a.b\\c", FilenameUtils.removeExtension("a.b\\c"));
+        assertEquals("a.b\\c", FilenameUtils.removeExtension("a.b\\c.txt"));
+        assertEquals("a\\b\\c", FilenameUtils.removeExtension("a\\b\\c"));
+        assertEquals("C:\\temp\\foo.bar\\README", FilenameUtils.removeExtension("C:\\temp\\foo.bar\\README"));
+        assertEquals("../filename", FilenameUtils.removeExtension("../filename.ext"));
+    }
+
+    @Test
+    public void testSeparatorsToSystem() {
+        if (WINDOWS) {
+            assertNull(FilenameUtils.separatorsToSystem(null));
+            assertEquals("\\a\\b\\c", FilenameUtils.separatorsToSystem("\\a\\b\\c"));
+            assertEquals("\\a\\b\\c.txt", FilenameUtils.separatorsToSystem("\\a\\b\\c.txt"));
+            assertEquals("\\a\\b\\c", FilenameUtils.separatorsToSystem("\\a\\b/c"));
+            assertEquals("\\a\\b\\c", FilenameUtils.separatorsToSystem("/a/b/c"));
+            assertEquals("D:\\a\\b\\c", FilenameUtils.separatorsToSystem("D:/a/b/c"));
+        } else {
+            assertNull(FilenameUtils.separatorsToSystem(null));
+            assertEquals("/a/b/c", FilenameUtils.separatorsToSystem("/a/b/c"));
+            assertEquals("/a/b/c.txt", FilenameUtils.separatorsToSystem("/a/b/c.txt"));
+            assertEquals("/a/b/c", FilenameUtils.separatorsToSystem("/a/b\\c"));
+            assertEquals("/a/b/c", FilenameUtils.separatorsToSystem("\\a\\b\\c"));
+            assertEquals("D:/a/b/c", FilenameUtils.separatorsToSystem("D:\\a\\b\\c"));
+        }
+    }
+
+    @Test
+    public void testSeparatorsToUnix() {
+        assertNull(FilenameUtils.separatorsToUnix(null));
+        assertEquals("/a/b/c", FilenameUtils.separatorsToUnix("/a/b/c"));
+        assertEquals("/a/b/c.txt", FilenameUtils.separatorsToUnix("/a/b/c.txt"));
+        assertEquals("/a/b/c", FilenameUtils.separatorsToUnix("/a/b\\c"));
+        assertEquals("/a/b/c", FilenameUtils.separatorsToUnix("\\a\\b\\c"));
+        assertEquals("D:/a/b/c", FilenameUtils.separatorsToUnix("D:\\a\\b\\c"));
+    }
+
+    @Test
+    public void testSeparatorsToWindows() {
+        assertNull(FilenameUtils.separatorsToWindows(null));
+        assertEquals("\\a\\b\\c", FilenameUtils.separatorsToWindows("\\a\\b\\c"));
+        assertEquals("\\a\\b\\c.txt", FilenameUtils.separatorsToWindows("\\a\\b\\c.txt"));
+        assertEquals("\\a\\b\\c", FilenameUtils.separatorsToWindows("\\a\\b/c"));
+        assertEquals("\\a\\b\\c", FilenameUtils.separatorsToWindows("/a/b/c"));
+        assertEquals("D:\\a\\b\\c", FilenameUtils.separatorsToWindows("D:/a/b/c"));
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/FilenameUtilsWildcardTest.java b/src/test/java/org/apache/commons/io/FilenameUtilsWildcardTest.java
new file mode 100644
index 0000000..439562a
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/FilenameUtilsWildcardTest.java
@@ -0,0 +1,245 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.util.Locale;
+
+import org.junit.jupiter.api.Test;
+
+public class FilenameUtilsWildcardTest {
+
+    private static final boolean WINDOWS = File.separatorChar == '\\';
+
+    // Testing:
+    //   FilenameUtils.wildcardMatch(String,String)
+
+    private void assertMatch(final String text, final String wildcard, final boolean expected) {
+        assertEquals(expected, FilenameUtils.wildcardMatch(text, wildcard), text + " " + wildcard);
+    }
+
+    /**
+     * See https://issues.apache.org/jira/browse/IO-246
+     */
+    @Test
+    public void test_IO_246() {
+
+        // Tests for "*?"
+        assertMatch("aaa", "aa*?", true);
+        // these ought to work as well, but "*?" does not work properly at present
+//      assertMatch("aaa", "a*?", true);
+//      assertMatch("aaa", "*?", true);
+
+        // Tests for "?*"
+        assertMatch("",    "?*",   false);
+        assertMatch("a",   "a?*",  false);
+        assertMatch("aa",  "aa?*", false);
+        assertMatch("a",   "?*",   true);
+        assertMatch("aa",  "?*",   true);
+        assertMatch("aaa", "?*",   true);
+
+        // Test ending on "?"
+        assertMatch("",    "?", false);
+        assertMatch("a",   "a?", false);
+        assertMatch("aa",  "aa?", false);
+        assertMatch("aab", "aa?", true);
+        assertMatch("aaa", "*a", true);
+    }
+
+    @Test
+    public void testLocaleIndependence() {
+        final Locale orig = Locale.getDefault();
+
+        final Locale[] locales = Locale.getAvailableLocales();
+
+        final String[][] data = {
+            { "I", "i"},
+            { "i", "I"},
+            { "i", "\u0130"},
+            { "i", "\u0131"},
+            { "\u03A3", "\u03C2"},
+            { "\u03A3", "\u03C3"},
+            { "\u03C2", "\u03C3"},
+        };
+
+        try {
+            for (int i = 0; i < data.length; i++) {
+                for (final Locale locale : locales) {
+                    Locale.setDefault(locale);
+                    assertTrue(data[i][0].equalsIgnoreCase(data[i][1]), "Test data corrupt: " + i);
+                    final boolean match = FilenameUtils.wildcardMatch(data[i][0], data[i][1], IOCase.INSENSITIVE);
+                    assertTrue(match, Locale.getDefault().toString() + ": " + i);
+                }
+            }
+        } finally {
+            Locale.setDefault(orig);
+        }
+    }
+
+    @Test
+    public void testMatch() {
+        assertFalse(FilenameUtils.wildcardMatch(null, "Foo"));
+        assertFalse(FilenameUtils.wildcardMatch("Foo", null));
+        assertTrue(FilenameUtils.wildcardMatch(null, null));
+        assertTrue(FilenameUtils.wildcardMatch("Foo", "Foo"));
+        assertTrue(FilenameUtils.wildcardMatch("", ""));
+        assertTrue(FilenameUtils.wildcardMatch("", "*"));
+        assertFalse(FilenameUtils.wildcardMatch("", "?"));
+        assertTrue(FilenameUtils.wildcardMatch("Foo", "Fo*"));
+        assertTrue(FilenameUtils.wildcardMatch("Foo", "Fo?"));
+        assertTrue(FilenameUtils.wildcardMatch("Foo Bar and Catflap", "Fo*"));
+        assertTrue(FilenameUtils.wildcardMatch("New Bookmarks", "N?w ?o?k??r?s"));
+        assertFalse(FilenameUtils.wildcardMatch("Foo", "Bar"));
+        assertTrue(FilenameUtils.wildcardMatch("Foo Bar Foo", "F*o Bar*"));
+        assertTrue(FilenameUtils.wildcardMatch("Adobe Acrobat Installer", "Ad*er"));
+        assertTrue(FilenameUtils.wildcardMatch("Foo", "*Foo"));
+        assertTrue(FilenameUtils.wildcardMatch("BarFoo", "*Foo"));
+        assertTrue(FilenameUtils.wildcardMatch("Foo", "Foo*"));
+        assertTrue(FilenameUtils.wildcardMatch("FooBar", "Foo*"));
+        assertFalse(FilenameUtils.wildcardMatch("FOO", "*Foo"));
+        assertFalse(FilenameUtils.wildcardMatch("BARFOO", "*Foo"));
+        assertFalse(FilenameUtils.wildcardMatch("FOO", "Foo*"));
+        assertFalse(FilenameUtils.wildcardMatch("FOOBAR", "Foo*"));
+    }
+
+    // A separate set of tests, added to this batch
+    @Test
+    public void testMatch2() {
+        assertMatch("log.txt", "log.txt", true);
+        assertMatch("log.txt1", "log.txt", false);
+
+        assertMatch("log.txt", "log.txt*", true);
+        assertMatch("log.txt", "log.txt*1", false);
+        assertMatch("log.txt", "*log.txt*", true);
+
+        assertMatch("log.txt", "*.txt", true);
+        assertMatch("txt.log", "*.txt", false);
+        assertMatch("config.ini", "*.ini", true);
+
+        assertMatch("config.txt.bak", "con*.txt", false);
+
+        assertMatch("log.txt9", "*.txt?", true);
+        assertMatch("log.txt", "*.txt?", false);
+
+        assertMatch("progtestcase.java~5~", "*test*.java~*~", true);
+        assertMatch("progtestcase.java;5~", "*test*.java~*~", false);
+        assertMatch("progtestcase.java~5", "*test*.java~*~", false);
+
+        assertMatch("log.txt", "log.*", true);
+
+        assertMatch("log.txt", "log?*", true);
+
+        assertMatch("log.txt12", "log.txt??", true);
+
+        assertMatch("log.log", "log**log", true);
+        assertMatch("log.log", "log**", true);
+        assertMatch("log.log", "log.**", true);
+        assertMatch("log.log", "**.log", true);
+        assertMatch("log.log", "**log", true);
+
+        assertMatch("log.log", "log*log", true);
+        assertMatch("log.log", "log*", true);
+        assertMatch("log.log", "log.*", true);
+        assertMatch("log.log", "*.log", true);
+        assertMatch("log.log", "*log", true);
+
+        assertMatch("log.log", "*log?", false);
+        assertMatch("log.log", "*log?*", true);
+        assertMatch("log.log.abc", "*log?abc", true);
+        assertMatch("log.log.abc.log.abc", "*log?abc", true);
+        assertMatch("log.log.abc.log.abc.d", "*log?abc?d", true);
+    }
+
+    @Test
+    public void testMatchCaseSpecified() {
+        assertFalse(FilenameUtils.wildcardMatch(null, "Foo", IOCase.SENSITIVE));
+        assertFalse(FilenameUtils.wildcardMatch("Foo", null, IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch(null, null, IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("Foo", "Foo", IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("", "", IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("Foo", "Fo*", IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("Foo", "Fo?", IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("Foo Bar and Catflap", "Fo*", IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("New Bookmarks", "N?w ?o?k??r?s", IOCase.SENSITIVE));
+        assertFalse(FilenameUtils.wildcardMatch("Foo", "Bar", IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("Foo Bar Foo", "F*o Bar*", IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("Adobe Acrobat Installer", "Ad*er", IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("Foo", "*Foo", IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("Foo", "Foo*", IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("Foo", "*Foo", IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("BarFoo", "*Foo", IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("Foo", "Foo*", IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("FooBar", "Foo*", IOCase.SENSITIVE));
+
+        assertFalse(FilenameUtils.wildcardMatch("FOO", "*Foo", IOCase.SENSITIVE));
+        assertFalse(FilenameUtils.wildcardMatch("BARFOO", "*Foo", IOCase.SENSITIVE));
+        assertFalse(FilenameUtils.wildcardMatch("FOO", "Foo*", IOCase.SENSITIVE));
+        assertFalse(FilenameUtils.wildcardMatch("FOOBAR", "Foo*", IOCase.SENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("FOO", "*Foo", IOCase.INSENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("BARFOO", "*Foo", IOCase.INSENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("FOO", "Foo*", IOCase.INSENSITIVE));
+        assertTrue(FilenameUtils.wildcardMatch("FOOBAR", "Foo*", IOCase.INSENSITIVE));
+        assertEquals(WINDOWS, FilenameUtils.wildcardMatch("FOO", "*Foo", IOCase.SYSTEM));
+        assertEquals(WINDOWS, FilenameUtils.wildcardMatch("BARFOO", "*Foo", IOCase.SYSTEM));
+        assertEquals(WINDOWS, FilenameUtils.wildcardMatch("FOO", "Foo*", IOCase.SYSTEM));
+        assertEquals(WINDOWS, FilenameUtils.wildcardMatch("FOOBAR", "Foo*", IOCase.SYSTEM));
+    }
+
+    @Test
+    public void testMatchOnSystem() {
+        assertFalse(FilenameUtils.wildcardMatchOnSystem(null, "Foo"));
+        assertFalse(FilenameUtils.wildcardMatchOnSystem("Foo", null));
+        assertTrue(FilenameUtils.wildcardMatchOnSystem(null, null));
+        assertTrue(FilenameUtils.wildcardMatchOnSystem("Foo", "Foo"));
+        assertTrue(FilenameUtils.wildcardMatchOnSystem("", ""));
+        assertTrue(FilenameUtils.wildcardMatchOnSystem("Foo", "Fo*"));
+        assertTrue(FilenameUtils.wildcardMatchOnSystem("Foo", "Fo?"));
+        assertTrue(FilenameUtils.wildcardMatchOnSystem("Foo Bar and Catflap", "Fo*"));
+        assertTrue(FilenameUtils.wildcardMatchOnSystem("New Bookmarks", "N?w ?o?k??r?s"));
+        assertFalse(FilenameUtils.wildcardMatchOnSystem("Foo", "Bar"));
+        assertTrue(FilenameUtils.wildcardMatchOnSystem("Foo Bar Foo", "F*o Bar*"));
+        assertTrue(FilenameUtils.wildcardMatchOnSystem("Adobe Acrobat Installer", "Ad*er"));
+        assertTrue(FilenameUtils.wildcardMatchOnSystem("Foo", "*Foo"));
+        assertTrue(FilenameUtils.wildcardMatchOnSystem("BarFoo", "*Foo"));
+        assertTrue(FilenameUtils.wildcardMatchOnSystem("Foo", "Foo*"));
+        assertTrue(FilenameUtils.wildcardMatchOnSystem("FooBar", "Foo*"));
+        assertEquals(WINDOWS, FilenameUtils.wildcardMatchOnSystem("FOO", "*Foo"));
+        assertEquals(WINDOWS, FilenameUtils.wildcardMatchOnSystem("BARFOO", "*Foo"));
+        assertEquals(WINDOWS, FilenameUtils.wildcardMatchOnSystem("FOO", "Foo*"));
+        assertEquals(WINDOWS, FilenameUtils.wildcardMatchOnSystem("FOOBAR", "Foo*"));
+    }
+
+    @Test
+    public void testSplitOnTokens() {
+        assertArrayEquals(new String[] { "Ad", "*", "er" }, FilenameUtils.splitOnTokens("Ad*er"));
+        assertArrayEquals(new String[] { "Ad", "?", "er" }, FilenameUtils.splitOnTokens("Ad?er"));
+        assertArrayEquals(new String[] { "Test", "*", "?", "One" }, FilenameUtils.splitOnTokens("Test*?One"));
+        assertArrayEquals(new String[] { "Test", "?", "*", "One" }, FilenameUtils.splitOnTokens("Test?*One"));
+        assertArrayEquals(new String[] { "*" }, FilenameUtils.splitOnTokens("****"));
+        assertArrayEquals(new String[] { "*", "?", "?", "*" }, FilenameUtils.splitOnTokens("*??*"));
+        assertArrayEquals(new String[] { "*", "?", "*", "?", "*" }, FilenameUtils.splitOnTokens("*?**?*"));
+        assertArrayEquals(new String[] { "*", "?", "*", "?", "*" }, FilenameUtils.splitOnTokens("*?***?*"));
+        assertArrayEquals(new String[] { "h", "?", "?", "*" }, FilenameUtils.splitOnTokens("h??*"));
+        assertArrayEquals(new String[] { "" }, FilenameUtils.splitOnTokens(""));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/HexDumpTest.java b/src/test/java/org/apache/commons/io/HexDumpTest.java
new file mode 100644
index 0000000..a7da7f1
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/HexDumpTest.java
@@ -0,0 +1,280 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+
+import org.apache.commons.io.output.ByteArrayOutputStream;
+import org.apache.commons.io.test.ThrowOnCloseOutputStream;
+import org.junit.jupiter.api.Test;
+
+
+/**
+ *
+ */
+public class HexDumpTest {
+
+    @Test
+    public void testDumpAppendable() throws IOException {
+        final byte[] testArray = new byte[256];
+
+        for (int j = 0; j < 256; j++) {
+            testArray[j] = (byte) j;
+        }
+
+        // verify proper behavior dumping the entire array
+        StringBuilder out = new StringBuilder();
+        HexDump.dump(testArray, out);
+        assertEquals(
+            "00000000 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F ................" + System.lineSeparator() +
+            "00000010 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F ................" + System.lineSeparator() +
+            "00000020 20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F  !\"#$%&'()*+,-./" + System.lineSeparator() +
+            "00000030 30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F 0123456789:;<=>?" + System.lineSeparator() +
+            "00000040 40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F @ABCDEFGHIJKLMNO" + System.lineSeparator() +
+            "00000050 50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F PQRSTUVWXYZ[\\]^_" + System.lineSeparator() +
+            "00000060 60 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F `abcdefghijklmno" + System.lineSeparator() +
+            "00000070 70 71 72 73 74 75 76 77 78 79 7A 7B 7C 7D 7E 7F pqrstuvwxyz{|}~." + System.lineSeparator() +
+            "00000080 80 81 82 83 84 85 86 87 88 89 8A 8B 8C 8D 8E 8F ................" + System.lineSeparator() +
+            "00000090 90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F ................" + System.lineSeparator() +
+            "000000A0 A0 A1 A2 A3 A4 A5 A6 A7 A8 A9 AA AB AC AD AE AF ................" + System.lineSeparator() +
+            "000000B0 B0 B1 B2 B3 B4 B5 B6 B7 B8 B9 BA BB BC BD BE BF ................" + System.lineSeparator() +
+            "000000C0 C0 C1 C2 C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF ................" + System.lineSeparator() +
+            "000000D0 D0 D1 D2 D3 D4 D5 D6 D7 D8 D9 DA DB DC DD DE DF ................" + System.lineSeparator() +
+            "000000E0 E0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF ................" + System.lineSeparator() +
+            "000000F0 F0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF ................" + System.lineSeparator(),
+            out.toString());
+
+        // verify proper behavior with non-zero offset, non-zero index and length shorter than array size
+        out = new StringBuilder();
+        HexDump.dump(testArray, 0x10000000, out, 0x28, 32);
+        assertEquals(
+            "10000028 28 29 2A 2B 2C 2D 2E 2F 30 31 32 33 34 35 36 37 ()*+,-./01234567" + System.lineSeparator() +
+            "10000038 38 39 3A 3B 3C 3D 3E 3F 40 41 42 43 44 45 46 47 89:;<=>?@ABCDEFG" + System.lineSeparator(),
+            out.toString());
+
+        // verify proper behavior with non-zero index and length shorter than array size
+        out = new StringBuilder();
+        HexDump.dump(testArray, 0, out, 0x40, 24);
+        assertEquals(
+            "00000040 40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F @ABCDEFGHIJKLMNO" + System.lineSeparator() +
+            "00000050 50 51 52 53 54 55 56 57                         PQRSTUVW" + System.lineSeparator(),
+            out.toString());
+
+        // verify proper behavior with negative index
+        assertThrows(ArrayIndexOutOfBoundsException.class, () -> HexDump.dump(testArray, 0x10000000, new StringBuilder(), -1, testArray.length));
+
+        // verify proper behavior with index that is too large
+        assertThrows(ArrayIndexOutOfBoundsException.class, () -> HexDump.dump(testArray, 0x10000000, new StringBuilder(), testArray.length, testArray.length));
+
+        // verify proper behavior with length that is negative
+        assertThrows(ArrayIndexOutOfBoundsException.class, () -> HexDump.dump(testArray, 0, new StringBuilder(), 0, -1));
+
+        // verify proper behavior with length that is too large
+        final Exception exception = assertThrows(ArrayIndexOutOfBoundsException.class, () -> HexDump.dump(testArray, 0, new StringBuilder(), 1,
+            testArray.length));
+        assertEquals("Range [1, 1 + 256) out of bounds for length 256", exception.getMessage());
+
+        // verify proper behavior with null appendable
+        assertThrows(NullPointerException.class, () -> HexDump.dump(testArray, 0x10000000, null, 0, testArray.length));
+    }
+
+    @Test
+    public void testDumpOutputStream() throws IOException {
+        final byte[] testArray = new byte[256];
+
+        for (int j = 0; j < 256; j++) {
+            testArray[j] = (byte) j;
+        }
+        ByteArrayOutputStream stream = new ByteArrayOutputStream();
+
+        HexDump.dump(testArray, 0, stream, 0);
+        byte[] outputArray = new byte[16 * (73 + System.lineSeparator().length())];
+
+        for (int j = 0; j < 16; j++) {
+            int offset = (73 + System.lineSeparator().length()) * j;
+
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) toHex(j);
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) ' ';
+            for (int k = 0; k < 16; k++) {
+                outputArray[offset++] = (byte) toHex(j);
+                outputArray[offset++] = (byte) toHex(k);
+                outputArray[offset++] = (byte) ' ';
+            }
+            for (int k = 0; k < 16; k++) {
+                outputArray[offset++] = (byte) toAscii(j * 16 + k);
+            }
+            System.arraycopy(System.lineSeparator().getBytes(), 0, outputArray, offset, System.lineSeparator().getBytes().length);
+        }
+        byte[] actualOutput = stream.toByteArray();
+
+        assertEquals(outputArray.length, actualOutput.length, "array size mismatch");
+        for (int j = 0; j < outputArray.length; j++) {
+            assertEquals(outputArray[j], actualOutput[j], "array[ " + j + "] mismatch");
+        }
+
+        // verify proper behavior with non-zero offset
+        stream = new ByteArrayOutputStream();
+        HexDump.dump(testArray, 0x10000000, stream, 0);
+        outputArray = new byte[16 * (73 + System.lineSeparator().length())];
+        for (int j = 0; j < 16; j++) {
+            int offset = (73 + System.lineSeparator().length()) * j;
+
+            outputArray[offset++] = (byte) '1';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) toHex(j);
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) ' ';
+            for (int k = 0; k < 16; k++) {
+                outputArray[offset++] = (byte) toHex(j);
+                outputArray[offset++] = (byte) toHex(k);
+                outputArray[offset++] = (byte) ' ';
+            }
+            for (int k = 0; k < 16; k++) {
+                outputArray[offset++] = (byte) toAscii(j * 16 + k);
+            }
+            System.arraycopy(System.lineSeparator().getBytes(), 0, outputArray, offset,
+                    System.lineSeparator().getBytes().length);
+        }
+        actualOutput = stream.toByteArray();
+        assertEquals(outputArray.length, actualOutput.length, "array size mismatch");
+        for (int j = 0; j < outputArray.length; j++) {
+            assertEquals(outputArray[j], actualOutput[j], "array[ " + j + "] mismatch");
+        }
+
+        // verify proper behavior with negative offset
+        stream = new ByteArrayOutputStream();
+        HexDump.dump(testArray, 0xFF000000, stream, 0);
+        outputArray = new byte[16 * (73 + System.lineSeparator().length())];
+        for (int j = 0; j < 16; j++) {
+            int offset = (73 + System.lineSeparator().length()) * j;
+
+            outputArray[offset++] = (byte) 'F';
+            outputArray[offset++] = (byte) 'F';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) toHex(j);
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) ' ';
+            for (int k = 0; k < 16; k++) {
+                outputArray[offset++] = (byte) toHex(j);
+                outputArray[offset++] = (byte) toHex(k);
+                outputArray[offset++] = (byte) ' ';
+            }
+            for (int k = 0; k < 16; k++) {
+                outputArray[offset++] = (byte) toAscii(j * 16 + k);
+            }
+            System.arraycopy(System.lineSeparator().getBytes(), 0, outputArray, offset,
+                    System.lineSeparator().getBytes().length);
+        }
+        actualOutput = stream.toByteArray();
+        assertEquals(outputArray.length, actualOutput.length, "array size mismatch");
+        for (int j = 0; j < outputArray.length; j++) {
+            assertEquals(outputArray[j], actualOutput[j], "array[ " + j + "] mismatch");
+        }
+
+        // verify proper behavior with non-zero index
+        stream = new ByteArrayOutputStream();
+        HexDump.dump(testArray, 0x10000000, stream, 0x81);
+        outputArray = new byte[8 * (73 + System.lineSeparator().length()) - 1];
+        for (int j = 0; j < 8; j++) {
+            int offset = (73 + System.lineSeparator().length()) * j;
+
+            outputArray[offset++] = (byte) '1';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) '0';
+            outputArray[offset++] = (byte) toHex(j + 8);
+            outputArray[offset++] = (byte) '1';
+            outputArray[offset++] = (byte) ' ';
+            for (int k = 0; k < 16; k++) {
+                final int index = 0x81 + j * 16 + k;
+
+                if (index < 0x100) {
+                    outputArray[offset++] = (byte) toHex(index / 16);
+                    outputArray[offset++] = (byte) toHex(index);
+                } else {
+                    outputArray[offset++] = (byte) ' ';
+                    outputArray[offset++] = (byte) ' ';
+                }
+                outputArray[offset++] = (byte) ' ';
+            }
+            for (int k = 0; k < 16; k++) {
+                final int index = 0x81 + j * 16 + k;
+
+                if (index < 0x100) {
+                    outputArray[offset++] = (byte) toAscii(index);
+                }
+            }
+            System.arraycopy(System.lineSeparator().getBytes(), 0, outputArray, offset,
+                    System.lineSeparator().getBytes().length);
+        }
+        actualOutput = stream.toByteArray();
+        assertEquals(outputArray.length, actualOutput.length, "array size mismatch");
+        for (int j = 0; j < outputArray.length; j++) {
+            assertEquals(outputArray[j], actualOutput[j], "array[ " + j + "] mismatch");
+        }
+
+        // verify proper behavior with negative index
+        assertThrows(ArrayIndexOutOfBoundsException.class, () -> HexDump.dump(testArray, 0x10000000, new ByteArrayOutputStream(), -1));
+
+        // verify proper behavior with index that is too large
+        assertThrows(ArrayIndexOutOfBoundsException.class, () -> HexDump.dump(testArray, 0x10000000, new ByteArrayOutputStream(), testArray.length));
+
+        // verify proper behavior with null stream
+        assertThrows(NullPointerException.class, () -> HexDump.dump(testArray, 0x10000000, null, 0));
+
+        // verify output stream is not closed by the dump method
+        HexDump.dump(testArray, 0, new ThrowOnCloseOutputStream(new ByteArrayOutputStream()), 0);
+    }
+
+    private char toAscii(final int c) {
+        char rval = '.';
+
+        if (c >= 32 && c <= 126) {
+            rval = (char) c;
+        }
+        return rval;
+    }
+
+    private char toHex(final int n) {
+        final char[] hexChars =
+                {
+                    '0', '1', '2', '3', '4', '5', '6', '7',
+                    '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'
+                };
+
+        return hexChars[n % 16];
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/IOCaseTest.java b/src/test/java/org/apache/commons/io/IOCaseTest.java
new file mode 100644
index 0000000..a73aa2f
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/IOCaseTest.java
@@ -0,0 +1,309 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOCase}.
+ */
+public class IOCaseTest {
+
+    private static final boolean WINDOWS = File.separatorChar == '\\';
+
+    private IOCase serialize(final IOCase value) throws Exception {
+        final ByteArrayOutputStream buf = new ByteArrayOutputStream();
+        try (final ObjectOutputStream out = new ObjectOutputStream(buf)) {
+            out.writeObject(value);
+            out.flush();
+        }
+
+        final ByteArrayInputStream bufin = new ByteArrayInputStream(buf.toByteArray());
+        final ObjectInputStream in = new ObjectInputStream(bufin);
+        return (IOCase) in.readObject();
+    }
+
+    @Test
+    public void test_checkCompare_case() {
+        assertEquals(0, IOCase.SENSITIVE.checkCompareTo("ABC", "ABC"));
+        assertTrue(IOCase.SENSITIVE.checkCompareTo("ABC", "abc") < 0);
+        assertTrue(IOCase.SENSITIVE.checkCompareTo("abc", "ABC") > 0);
+
+        assertEquals(0, IOCase.INSENSITIVE.checkCompareTo("ABC", "ABC"));
+        assertEquals(0, IOCase.INSENSITIVE.checkCompareTo("ABC", "abc"));
+        assertEquals(0, IOCase.INSENSITIVE.checkCompareTo("abc", "ABC"));
+
+        assertEquals(0, IOCase.SYSTEM.checkCompareTo("ABC", "ABC"));
+        assertEquals(WINDOWS, IOCase.SYSTEM.checkCompareTo("ABC", "abc") == 0);
+        assertEquals(WINDOWS, IOCase.SYSTEM.checkCompareTo("abc", "ABC") == 0);
+    }
+
+    @Test
+    public void test_checkCompare_functionality() {
+        assertTrue(IOCase.SENSITIVE.checkCompareTo("ABC", "") > 0);
+        assertTrue(IOCase.SENSITIVE.checkCompareTo("", "ABC") < 0);
+        assertTrue(IOCase.SENSITIVE.checkCompareTo("ABC", "DEF") < 0);
+        assertTrue(IOCase.SENSITIVE.checkCompareTo("DEF", "ABC") > 0);
+        assertEquals(0, IOCase.SENSITIVE.checkCompareTo("ABC", "ABC"));
+        assertEquals(0, IOCase.SENSITIVE.checkCompareTo("", ""));
+
+        assertThrows(NullPointerException.class, () -> IOCase.SENSITIVE.checkCompareTo("ABC", null));
+        assertThrows(NullPointerException.class, () -> IOCase.SENSITIVE.checkCompareTo(null, "ABC"));
+        assertThrows(NullPointerException.class, () -> IOCase.SENSITIVE.checkCompareTo(null, null));
+    }
+
+    @Test
+    public void test_checkEndsWith_case() {
+        assertTrue(IOCase.SENSITIVE.checkEndsWith("ABC", "BC"));
+        assertFalse(IOCase.SENSITIVE.checkEndsWith("ABC", "Bc"));
+
+        assertTrue(IOCase.INSENSITIVE.checkEndsWith("ABC", "BC"));
+        assertTrue(IOCase.INSENSITIVE.checkEndsWith("ABC", "Bc"));
+
+        assertTrue(IOCase.SYSTEM.checkEndsWith("ABC", "BC"));
+        assertEquals(WINDOWS, IOCase.SYSTEM.checkEndsWith("ABC", "Bc"));
+    }
+
+    @Test
+    public void test_checkEndsWith_functionality() {
+        assertTrue(IOCase.SENSITIVE.checkEndsWith("ABC", ""));
+        assertFalse(IOCase.SENSITIVE.checkEndsWith("ABC", "A"));
+        assertFalse(IOCase.SENSITIVE.checkEndsWith("ABC", "AB"));
+        assertTrue(IOCase.SENSITIVE.checkEndsWith("ABC", "ABC"));
+        assertTrue(IOCase.SENSITIVE.checkEndsWith("ABC", "BC"));
+        assertTrue(IOCase.SENSITIVE.checkEndsWith("ABC", "C"));
+        assertFalse(IOCase.SENSITIVE.checkEndsWith("ABC", "ABCD"));
+        assertFalse(IOCase.SENSITIVE.checkEndsWith("", "ABC"));
+        assertTrue(IOCase.SENSITIVE.checkEndsWith("", ""));
+
+        assertFalse(IOCase.SENSITIVE.checkEndsWith("ABC", null));
+        assertFalse(IOCase.SENSITIVE.checkEndsWith(null, "ABC"));
+        assertFalse(IOCase.SENSITIVE.checkEndsWith(null, null));
+    }
+    @Test
+    public void test_checkEquals_case() {
+        assertTrue(IOCase.SENSITIVE.checkEquals("ABC", "ABC"));
+        assertFalse(IOCase.SENSITIVE.checkEquals("ABC", "Abc"));
+
+        assertTrue(IOCase.INSENSITIVE.checkEquals("ABC", "ABC"));
+        assertTrue(IOCase.INSENSITIVE.checkEquals("ABC", "Abc"));
+
+        assertTrue(IOCase.SYSTEM.checkEquals("ABC", "ABC"));
+        assertEquals(WINDOWS, IOCase.SYSTEM.checkEquals("ABC", "Abc"));
+    }
+
+    @Test
+    public void test_checkEquals_functionality() {
+        assertFalse(IOCase.SENSITIVE.checkEquals("ABC", ""));
+        assertFalse(IOCase.SENSITIVE.checkEquals("ABC", "A"));
+        assertFalse(IOCase.SENSITIVE.checkEquals("ABC", "AB"));
+        assertTrue(IOCase.SENSITIVE.checkEquals("ABC", "ABC"));
+        assertFalse(IOCase.SENSITIVE.checkEquals("ABC", "BC"));
+        assertFalse(IOCase.SENSITIVE.checkEquals("ABC", "C"));
+        assertFalse(IOCase.SENSITIVE.checkEquals("ABC", "ABCD"));
+        assertFalse(IOCase.SENSITIVE.checkEquals("", "ABC"));
+        assertTrue(IOCase.SENSITIVE.checkEquals("", ""));
+
+        assertThrows(NullPointerException.class, () -> IOCase.SENSITIVE.checkEquals("ABC", null));
+        assertThrows(NullPointerException.class, () -> IOCase.SENSITIVE.checkEquals(null, "ABC"));
+        assertThrows(NullPointerException.class, () -> IOCase.SENSITIVE.checkEquals(null, null));
+    }
+
+
+    @Test
+    public void test_checkIndexOf_case() {
+        assertEquals(1,  IOCase.SENSITIVE.checkIndexOf("ABC", 0, "BC"));
+        assertEquals(-1, IOCase.SENSITIVE.checkIndexOf("ABC", 0, "Bc"));
+
+        assertEquals(1, IOCase.INSENSITIVE.checkIndexOf("ABC", 0, "BC"));
+        assertEquals(1, IOCase.INSENSITIVE.checkIndexOf("ABC", 0, "Bc"));
+
+        assertEquals(1, IOCase.SYSTEM.checkIndexOf("ABC", 0, "BC"));
+        assertEquals(WINDOWS ? 1 : -1, IOCase.SYSTEM.checkIndexOf("ABC", 0, "Bc"));
+    }
+
+    @Test
+    public void test_checkIndexOf_functionality() {
+
+        // start
+        assertEquals(0,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 0, "A"));
+        assertEquals(-1,  IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 1, "A"));
+        assertEquals(0,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 0, "AB"));
+        assertEquals(-1,  IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 1, "AB"));
+        assertEquals(0,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 0, "ABC"));
+        assertEquals(-1,  IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 1, "ABC"));
+
+        // middle
+        assertEquals(3,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 0, "D"));
+        assertEquals(3,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 3, "D"));
+        assertEquals(-1,  IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 4, "D"));
+        assertEquals(3,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 0, "DE"));
+        assertEquals(3,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 3, "DE"));
+        assertEquals(-1,  IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 4, "DE"));
+        assertEquals(3,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 0, "DEF"));
+        assertEquals(3,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 3, "DEF"));
+        assertEquals(-1,  IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 4, "DEF"));
+
+        // end
+        assertEquals(9,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 0, "J"));
+        assertEquals(9,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 8, "J"));
+        assertEquals(9,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 9, "J"));
+        assertEquals(8,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 0, "IJ"));
+        assertEquals(8,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 8, "IJ"));
+        assertEquals(-1,  IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 9, "IJ"));
+        assertEquals(7,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 6, "HIJ"));
+        assertEquals(7,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 7, "HIJ"));
+        assertEquals(-1,  IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 8, "HIJ"));
+
+        // not found
+        assertEquals(-1,   IOCase.SENSITIVE.checkIndexOf("ABCDEFGHIJ", 0, "DED"));
+
+        // too long
+        assertEquals(-1,   IOCase.SENSITIVE.checkIndexOf("DEF", 0, "ABCDEFGHIJ"));
+
+        assertThrows(NullPointerException.class, () -> IOCase.SENSITIVE.checkIndexOf("ABC", 0, null));
+        assertThrows(NullPointerException.class, () -> IOCase.SENSITIVE.checkIndexOf(null, 0, "ABC"));
+        assertThrows(NullPointerException.class, () -> IOCase.SENSITIVE.checkIndexOf(null, 0, null));
+    }
+
+    @Test
+    public void test_checkRegionMatches_case() {
+        assertTrue(IOCase.SENSITIVE.checkRegionMatches("ABC", 0, "AB"));
+        assertFalse(IOCase.SENSITIVE.checkRegionMatches("ABC", 0, "Ab"));
+
+        assertTrue(IOCase.INSENSITIVE.checkRegionMatches("ABC", 0, "AB"));
+        assertTrue(IOCase.INSENSITIVE.checkRegionMatches("ABC", 0, "Ab"));
+
+        assertTrue(IOCase.SYSTEM.checkRegionMatches("ABC", 0, "AB"));
+        assertEquals(WINDOWS, IOCase.SYSTEM.checkRegionMatches("ABC", 0, "Ab"));
+    }
+
+    @Test
+    public void test_checkRegionMatches_functionality() {
+        assertTrue(IOCase.SENSITIVE.checkRegionMatches("ABC", 0, ""));
+        assertTrue(IOCase.SENSITIVE.checkRegionMatches("ABC", 0, "A"));
+        assertTrue(IOCase.SENSITIVE.checkRegionMatches("ABC", 0, "AB"));
+        assertTrue(IOCase.SENSITIVE.checkRegionMatches("ABC", 0, "ABC"));
+        assertFalse(IOCase.SENSITIVE.checkRegionMatches("ABC", 0, "BC"));
+        assertFalse(IOCase.SENSITIVE.checkRegionMatches("ABC", 0, "C"));
+        assertFalse(IOCase.SENSITIVE.checkRegionMatches("ABC", 0, "ABCD"));
+        assertFalse(IOCase.SENSITIVE.checkRegionMatches("", 0, "ABC"));
+        assertTrue(IOCase.SENSITIVE.checkRegionMatches("", 0, ""));
+
+        assertTrue(IOCase.SENSITIVE.checkRegionMatches("ABC", 1, ""));
+        assertFalse(IOCase.SENSITIVE.checkRegionMatches("ABC", 1, "A"));
+        assertFalse(IOCase.SENSITIVE.checkRegionMatches("ABC", 1, "AB"));
+        assertFalse(IOCase.SENSITIVE.checkRegionMatches("ABC", 1, "ABC"));
+        assertTrue(IOCase.SENSITIVE.checkRegionMatches("ABC", 1, "BC"));
+        assertFalse(IOCase.SENSITIVE.checkRegionMatches("ABC", 1, "C"));
+        assertFalse(IOCase.SENSITIVE.checkRegionMatches("ABC", 1, "ABCD"));
+        assertFalse(IOCase.SENSITIVE.checkRegionMatches("", 1, "ABC"));
+        assertFalse(IOCase.SENSITIVE.checkRegionMatches("", 1, ""));
+
+        assertThrows(NullPointerException.class, () -> IOCase.SENSITIVE.checkRegionMatches("ABC", 0, null));
+        assertThrows(NullPointerException.class, () -> IOCase.SENSITIVE.checkRegionMatches(null, 0, "ABC"));
+        assertThrows(NullPointerException.class, () -> IOCase.SENSITIVE.checkRegionMatches(null, 0, null));
+        assertThrows(NullPointerException.class, () -> IOCase.SENSITIVE.checkRegionMatches("ABC", 1, null));
+        assertThrows(NullPointerException.class, () -> IOCase.SENSITIVE.checkRegionMatches(null, 1, "ABC"));
+        assertThrows(NullPointerException.class, () -> IOCase.SENSITIVE.checkRegionMatches(null, 1, null));
+    }
+
+    @Test
+    public void test_checkStartsWith_case() {
+        assertTrue(IOCase.SENSITIVE.checkStartsWith("ABC", "AB"));
+        assertFalse(IOCase.SENSITIVE.checkStartsWith("ABC", "Ab"));
+
+        assertTrue(IOCase.INSENSITIVE.checkStartsWith("ABC", "AB"));
+        assertTrue(IOCase.INSENSITIVE.checkStartsWith("ABC", "Ab"));
+
+        assertTrue(IOCase.SYSTEM.checkStartsWith("ABC", "AB"));
+        assertEquals(WINDOWS, IOCase.SYSTEM.checkStartsWith("ABC", "Ab"));
+    }
+
+    @Test
+    public void test_checkStartsWith_functionality() {
+        assertTrue(IOCase.SENSITIVE.checkStartsWith("ABC", ""));
+        assertTrue(IOCase.SENSITIVE.checkStartsWith("ABC", "A"));
+        assertTrue(IOCase.SENSITIVE.checkStartsWith("ABC", "AB"));
+        assertTrue(IOCase.SENSITIVE.checkStartsWith("ABC", "ABC"));
+        assertFalse(IOCase.SENSITIVE.checkStartsWith("ABC", "BC"));
+        assertFalse(IOCase.SENSITIVE.checkStartsWith("ABC", "C"));
+        assertFalse(IOCase.SENSITIVE.checkStartsWith("ABC", "ABCD"));
+        assertFalse(IOCase.SENSITIVE.checkStartsWith("", "ABC"));
+        assertTrue(IOCase.SENSITIVE.checkStartsWith("", ""));
+
+        assertFalse(IOCase.SENSITIVE.checkStartsWith("ABC", null));
+        assertFalse(IOCase.SENSITIVE.checkStartsWith(null, "ABC"));
+        assertFalse(IOCase.SENSITIVE.checkStartsWith(null, null));
+    }
+
+    @Test
+    public void test_forName() {
+        assertEquals(IOCase.SENSITIVE, IOCase.forName("Sensitive"));
+        assertEquals(IOCase.INSENSITIVE, IOCase.forName("Insensitive"));
+        assertEquals(IOCase.SYSTEM, IOCase.forName("System"));
+        assertThrows(IllegalArgumentException.class, () -> IOCase.forName("Blah"));
+        assertThrows(IllegalArgumentException.class, () -> IOCase.forName(null));
+    }
+
+    @Test
+    public void test_getName() {
+        assertEquals("Sensitive", IOCase.SENSITIVE.getName());
+        assertEquals("Insensitive", IOCase.INSENSITIVE.getName());
+        assertEquals("System", IOCase.SYSTEM.getName());
+    }
+
+    @Test
+    public void test_isCaseSensitive() {
+        assertTrue(IOCase.SENSITIVE.isCaseSensitive());
+        assertFalse(IOCase.INSENSITIVE.isCaseSensitive());
+        assertEquals(!WINDOWS, IOCase.SYSTEM.isCaseSensitive());
+    }
+
+    @Test
+    public void test_isCaseSensitive_static() {
+        assertTrue(IOCase.isCaseSensitive(IOCase.SENSITIVE));
+        assertFalse(IOCase.isCaseSensitive(IOCase.INSENSITIVE));
+        assertEquals(!WINDOWS, IOCase.isCaseSensitive(IOCase.SYSTEM));
+    }
+
+    @Test
+    public void test_serialization() throws Exception {
+        assertSame(IOCase.SENSITIVE, serialize(IOCase.SENSITIVE));
+        assertSame(IOCase.INSENSITIVE, serialize(IOCase.INSENSITIVE));
+        assertSame(IOCase.SYSTEM, serialize(IOCase.SYSTEM));
+    }
+
+    @Test
+    public void test_toString() {
+        assertEquals("Sensitive", IOCase.SENSITIVE.toString());
+        assertEquals("Insensitive", IOCase.INSENSITIVE.toString());
+        assertEquals("System", IOCase.SYSTEM.toString());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/IOExceptionListTest.java b/src/test/java/org/apache/commons/io/IOExceptionListTest.java
new file mode 100644
index 0000000..aad7de5
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/IOExceptionListTest.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.EOFException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOExceptionList}.
+ */
+public class IOExceptionListTest {
+
+    @Test
+    public void testCause() {
+        final EOFException cause = new EOFException();
+        final List<EOFException> list = Collections.singletonList(cause);
+        final IOExceptionList sqlExceptionList = new IOExceptionList(list);
+        assertEquals(cause, sqlExceptionList.getCause());
+        assertEquals(cause, sqlExceptionList.getCause(0));
+        assertEquals(list, sqlExceptionList.getCauseList());
+        assertEquals(list, sqlExceptionList.getCauseList(EOFException.class));
+        assertEquals(cause, sqlExceptionList.getCause(0, EOFException.class));
+        // No CCE:
+        final List<EOFException> causeList = sqlExceptionList.getCauseList();
+        assertEquals(list, causeList);
+    }
+
+    @Test
+    public void testCheckEmpty() throws IOExceptionList {
+        IOExceptionList.checkEmpty(null, "");
+        IOExceptionList.checkEmpty(null, null);
+        IOExceptionList.checkEmpty(Collections.emptyList(), "");
+        IOExceptionList.checkEmpty(Collections.emptyList(), null);
+        assertThrows(IOExceptionList.class, () -> IOExceptionList.checkEmpty(Collections.singletonList(new Exception()), ""));
+        assertThrows(IOExceptionList.class, () -> IOExceptionList.checkEmpty(Collections.singletonList(new Exception()), null));
+    }
+
+    @Test
+    public void testEmptyList() {
+        new IOExceptionList(Collections.emptyList());
+        new IOExceptionList("foo", Collections.emptyList());
+    }
+
+    @Test
+    public void testIterable() {
+        final EOFException cause = new EOFException();
+        final List<EOFException> list = Collections.singletonList(cause);
+        final IOExceptionList sqlExceptionList = new IOExceptionList("Hello", list);
+        //
+        assertEquals(list, sqlExceptionList.getCauseList());
+        // No CCE:
+        final List<EOFException> causeList = sqlExceptionList.getCauseList();
+        assertEquals(list, causeList);
+        //
+        final List<Throwable> list2 = new ArrayList<>();
+        sqlExceptionList.forEach(list2::add);
+        assertEquals(list2, causeList);
+    }
+
+    @Test
+    public void testMessageCause() {
+        final EOFException cause = new EOFException();
+        final List<EOFException> list = Collections.singletonList(cause);
+        final IOExceptionList sqlExceptionList = new IOExceptionList("Hello", list);
+        assertEquals("Hello", sqlExceptionList.getMessage());
+        //
+        assertEquals(cause, sqlExceptionList.getCause());
+        assertEquals(cause, sqlExceptionList.getCause(0));
+        assertEquals(list, sqlExceptionList.getCauseList());
+        assertEquals(list, sqlExceptionList.getCauseList(EOFException.class));
+        assertEquals(cause, sqlExceptionList.getCause(0, EOFException.class));
+        // No CCE:
+        final List<EOFException> causeList = sqlExceptionList.getCauseList();
+        assertEquals(list, causeList);
+    }
+
+    @Test
+    public void testNullCause() {
+        final IOExceptionList sqlExceptionList = new IOExceptionList(null);
+        assertNull(sqlExceptionList.getCause());
+        assertTrue(sqlExceptionList.getCauseList().isEmpty());
+    }
+
+    @Test
+    public void testNullMessageArg() {
+        assertNotNull(new IOExceptionList(null, Collections.emptyList()).getMessage());
+        assertNotNull(new IOExceptionList(null, null).getMessage());
+        assertEquals("A", new IOExceptionList("A", Collections.emptyList()).getMessage());
+        assertEquals("A", new IOExceptionList("A", null).getMessage());
+    }
+
+    @Test
+    public void testPrintStackTrace() {
+        final EOFException cause = new EOFException();
+        final List<EOFException> list = Collections.singletonList(cause);
+        final IOExceptionList sqlExceptionList = new IOExceptionList(list);
+        final StringWriter sw = new StringWriter();
+        final PrintWriter pw = new PrintWriter(sw);
+        sqlExceptionList.printStackTrace(pw);
+        final String st = sw.toString();
+        assertTrue(st.startsWith("org.apache.commons.io.IOExceptionList: 1 exception(s): [java.io.EOFException]"));
+        assertTrue(st.contains("Caused by: java.io.EOFException"));
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/IOExceptionWithCauseTest.java b/src/test/java/org/apache/commons/io/IOExceptionWithCauseTest.java
new file mode 100644
index 0000000..5f3e261
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/IOExceptionWithCauseTest.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests IOExceptionWithCause
+ */
+public class IOExceptionWithCauseTest {
+
+    /**
+     * Tests the {@link IOExceptionWithCause#IOExceptionWithCause(String,Throwable)} constructor.
+     */
+    @Test
+    public void testIOExceptionStringThrowable() {
+        final Throwable cause = new IllegalArgumentException("cause");
+        final IOException exception = new IOException("message", cause);
+        this.validate(exception, cause, "message");
+    }
+
+    /**
+     * Tests the {@link IOExceptionWithCause#IOExceptionWithCause(Throwable)} constructor.
+     */
+
+    @Test
+    public void testIOExceptionThrowable() {
+        final Throwable cause = new IllegalArgumentException("cause");
+        final IOException exception = new IOException(cause);
+        this.validate(exception, cause, "java.lang.IllegalArgumentException: cause");
+    }
+
+    void validate(final Throwable throwable, final Throwable expectedCause, final String expectedMessage) {
+        assertEquals(expectedMessage, throwable.getMessage());
+        assertEquals(expectedCause, throwable.getCause());
+        assertSame(expectedCause, throwable.getCause());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/IOIndexedExceptionTest.java b/src/test/java/org/apache/commons/io/IOIndexedExceptionTest.java
new file mode 100644
index 0000000..906f93c
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/IOIndexedExceptionTest.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.io.EOFException;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOIndexedException}.
+ *
+ * @since 2.7
+ */
+public class IOIndexedExceptionTest {
+
+    @Test
+    public void testEdge() {
+        final IOIndexedException exception = new IOIndexedException(-1, null);
+        assertEquals(-1, exception.getIndex());
+        assertNull(exception.getCause());
+        assertNotNull(exception.getMessage());
+    }
+
+    @Test
+    public void testPlain() {
+        final EOFException e = new EOFException("end");
+        final IOIndexedException exception = new IOIndexedException(0, e);
+        assertEquals(0, exception.getIndex());
+        assertEquals(e, exception.getCause());
+        assertNotNull(exception.getMessage());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/IOUtilsCopyTest.java b/src/test/java/org/apache/commons/io/IOUtilsCopyTest.java
new file mode 100644
index 0000000..75e00bd
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/IOUtilsCopyTest.java
@@ -0,0 +1,478 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Reader;
+import java.io.Writer;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+import org.apache.commons.io.file.TempFile;
+import org.apache.commons.io.input.NullInputStream;
+import org.apache.commons.io.input.NullReader;
+import org.apache.commons.io.output.ByteArrayOutputStream;
+import org.apache.commons.io.output.NullOutputStream;
+import org.apache.commons.io.output.NullWriter;
+import org.apache.commons.io.test.TestUtils;
+import org.apache.commons.io.test.ThrowOnCloseInputStream;
+import org.apache.commons.io.test.ThrowOnFlushAndCloseOutputStream;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test {@link IOUtils} copy methods.
+ */
+public class IOUtilsCopyTest {
+
+    /*
+     * NOTE this is not particularly beautiful code. A better way to check for
+     * flush and close status would be to implement "trojan horse" wrapper
+     * implementations of the various stream classes, which set a flag when
+     * relevant methods are called. (JT)
+     */
+
+    private static final int FILE_SIZE = 1024 * 4 + 1;
+
+    private final byte[] inData = TestUtils.generateTestData(FILE_SIZE);
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    @Test
+    public void testCopy_byteArrayOutputStreamToInputStream() throws Exception {
+        final java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+        out.write(inData);
+
+        final InputStream in = IOUtils.copy(out);
+
+        final byte[] inData2 = new byte[FILE_SIZE];
+        final int inSize = in.read(inData2);
+
+        assertEquals(0, in.available(), "Not all bytes were read");
+        assertEquals(inData.length, inSize, "Sizes differ");
+        assertArrayEquals(inData, inData2, "Content differs");
+    }
+
+    @Test
+    public void testCopy_byteArrayOutputStreamToInputStream_nullOutputStream() {
+        assertThrows(NullPointerException.class, () -> IOUtils.copy(null));
+    }
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    @Test
+    public void testCopy_inputStreamToOutputStream() throws Exception {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        final int count = IOUtils.copy(in, out);
+
+        assertEquals(0, in.available(), "Not all bytes were read");
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+        assertEquals(inData.length,count);
+    }
+
+    /**
+     * Test Copying file > 2GB  - see issue# IO-84
+     */
+    @Test
+    public void testCopy_inputStreamToOutputStream_IO84() throws Exception {
+        final long size = (long)Integer.MAX_VALUE + (long)1;
+        final InputStream  in  = new NullInputStream(size);
+        final OutputStream out = NullOutputStream.INSTANCE;
+
+        // Test copy() method
+        assertEquals(-1, IOUtils.copy(in, out));
+
+        // reset the input
+        in.close();
+
+        // Test copyLarge() method
+        assertEquals(size, IOUtils.copyLarge(in, out), "copyLarge()");
+    }
+
+    @Test
+    public void testCopy_inputStreamToOutputStream_nullIn() {
+        final OutputStream out = new ByteArrayOutputStream();
+        assertThrows(NullPointerException.class, () -> IOUtils.copy((InputStream) null, out));
+    }
+
+    @Test
+    public void testCopy_inputStreamToOutputStream_nullOut() {
+        final InputStream in = new ByteArrayInputStream(inData);
+        assertThrows(NullPointerException.class, () -> IOUtils.copy(in, (OutputStream) null));
+    }
+
+    @Test
+    public void testCopy_inputStreamToOutputStreamWithBufferSize() throws Exception {
+        testCopy_inputStreamToOutputStreamWithBufferSize(1);
+        testCopy_inputStreamToOutputStreamWithBufferSize(2);
+        testCopy_inputStreamToOutputStreamWithBufferSize(4);
+        testCopy_inputStreamToOutputStreamWithBufferSize(8);
+        testCopy_inputStreamToOutputStreamWithBufferSize(16);
+        testCopy_inputStreamToOutputStreamWithBufferSize(32);
+        testCopy_inputStreamToOutputStreamWithBufferSize(64);
+        testCopy_inputStreamToOutputStreamWithBufferSize(128);
+        testCopy_inputStreamToOutputStreamWithBufferSize(256);
+        testCopy_inputStreamToOutputStreamWithBufferSize(512);
+        testCopy_inputStreamToOutputStreamWithBufferSize(1024);
+        testCopy_inputStreamToOutputStreamWithBufferSize(2048);
+        testCopy_inputStreamToOutputStreamWithBufferSize(4096);
+        testCopy_inputStreamToOutputStreamWithBufferSize(8192);
+        testCopy_inputStreamToOutputStreamWithBufferSize(16384);
+    }
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    private void testCopy_inputStreamToOutputStreamWithBufferSize(final int bufferSize) throws Exception {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        final long count = IOUtils.copy(in, out, bufferSize);
+
+        assertEquals(0, in.available(), "Not all bytes were read");
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+        assertEquals(inData.length,count);
+    }
+
+    @SuppressWarnings({ "resource", "deprecation" }) // 'in' is deliberately not closed
+    @Test
+    public void testCopy_inputStreamToWriter() throws Exception {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.copy(in, writer); // deliberately testing deprecated method
+        out.off();
+        writer.flush();
+
+        assertEquals(0, in.available(), "Not all bytes were read");
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    @Test
+    public void testCopy_inputStreamToWriter_Encoding() throws Exception {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.copy(in, writer, "UTF8");
+        out.off();
+        writer.flush();
+
+        assertEquals(0, in.available(), "Not all bytes were read");
+        byte[] bytes = baout.toByteArray();
+        bytes = new String(bytes, StandardCharsets.UTF_8).getBytes(StandardCharsets.US_ASCII);
+        assertArrayEquals(inData, bytes, "Content differs");
+    }
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    @Test
+    public void testCopy_inputStreamToWriter_Encoding_nullEncoding() throws Exception {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.copy(in, writer, (String) null);
+        out.off();
+        writer.flush();
+
+        assertEquals(0, in.available(), "Not all bytes were read");
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testCopy_inputStreamToWriter_Encoding_nullIn() {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(out, StandardCharsets.US_ASCII);
+        assertThrows(NullPointerException.class, () -> IOUtils.copy(null, writer, "UTF8"));
+    }
+
+    @Test
+    public void testCopy_inputStreamToWriter_Encoding_nullOut() {
+        final InputStream in = new ByteArrayInputStream(inData);
+        assertThrows(NullPointerException.class, () -> IOUtils.copy(in, null, "UTF8"));
+    }
+
+    @SuppressWarnings("deprecation") // deliberately testing deprecated method
+    @Test
+    public void testCopy_inputStreamToWriter_nullIn() {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(out, StandardCharsets.US_ASCII);
+        assertThrows(NullPointerException.class, () -> IOUtils.copy((InputStream) null, writer));
+    }
+
+    @SuppressWarnings("deprecation") // deliberately testing deprecated method
+    @Test
+    public void testCopy_inputStreamToWriter_nullOut() {
+        final InputStream in = new ByteArrayInputStream(inData);
+        assertThrows(NullPointerException.class, () -> IOUtils.copy(in, (Writer) null)); // deliberately testing deprecated method
+    }
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    @Test
+    public void testCopy_readerToAppendable() throws Exception {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+        final Reader reader = new InputStreamReader(in, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        final long count = IOUtils.copy(reader, (Appendable) writer);
+        out.off();
+        writer.flush();
+        assertEquals(inData.length, count, "The number of characters returned by copy is wrong");
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testCopy_readerToAppendable_IO84() throws Exception {
+        final long size = (long) Integer.MAX_VALUE + (long) 1;
+        final Reader reader = new NullReader(size);
+        final NullWriter writer = new NullWriter();
+
+        // Test copy() method
+        assertEquals(size, IOUtils.copy(reader, (Appendable) writer));
+
+        // reset the input
+        reader.close();
+
+        // Test copyLarge() method
+        assertEquals(size, IOUtils.copyLarge(reader, writer), "copy()");
+    }
+
+    @Test
+    public void testCopy_readerToAppendable_nullIn() {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Appendable writer = new OutputStreamWriter(out, StandardCharsets.US_ASCII);
+        assertThrows(NullPointerException.class, () -> IOUtils.copy(null, writer));
+    }
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    @Test
+    public void testCopy_readerToAppendable_nullOut() {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+        final Reader reader = new InputStreamReader(in, StandardCharsets.US_ASCII);
+        assertThrows(NullPointerException.class, () -> IOUtils.copy(reader, (Appendable) null));
+    }
+
+    @SuppressWarnings({ "resource", "deprecation" }) // 'in' is deliberately not closed
+    @Test
+    public void testCopy_readerToOutputStream() throws Exception {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+        final Reader reader = new InputStreamReader(in, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        IOUtils.copy(reader, out); // deliberately testing deprecated method
+        //Note: this method *does* flush. It is equivalent to:
+        //  OutputStreamWriter _out = new OutputStreamWriter(fout);
+        //  IOUtils.copy( fin, _out, 4096 ); // copy( Reader, Writer, int );
+        //  _out.flush();
+        //  out = fout;
+
+        // Note: rely on the method to flush
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    @Test
+    public void testCopy_readerToOutputStream_Encoding() throws Exception {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+        final Reader reader = new InputStreamReader(in, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        IOUtils.copy(reader, out, "UTF16");
+        // note: this method *does* flush.
+        // note: we don't flush here; this IOUtils method does it for us
+
+        byte[] bytes = baout.toByteArray();
+        bytes = new String(bytes, StandardCharsets.UTF_16).getBytes(StandardCharsets.US_ASCII);
+        assertArrayEquals(inData, bytes, "Content differs");
+    }
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    @Test
+    public void testCopy_readerToOutputStream_Encoding_nullEncoding() throws Exception {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+        final Reader reader = new InputStreamReader(in, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        IOUtils.copy(reader, out, (String) null);
+        // note: this method *does* flush.
+        // note: we don't flush here; this IOUtils method does it for us
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testCopy_readerToOutputStream_Encoding_nullIn() {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        assertThrows(NullPointerException.class, () -> IOUtils.copy(null, out, "UTF16"));
+    }
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    @Test
+    public void testCopy_readerToOutputStream_Encoding_nullOut() {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+        final Reader reader = new InputStreamReader(in, StandardCharsets.US_ASCII);
+        assertThrows(NullPointerException.class, () -> IOUtils.copy(reader, null, "UTF16"));
+    }
+
+    @SuppressWarnings("deprecation")
+    @Test
+    public void testCopy_readerToOutputStream_nullIn() { // deliberately testing deprecated method
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        assertThrows(NullPointerException.class, () -> IOUtils.copy((Reader) null, out));
+    }
+
+    @SuppressWarnings({ "resource", "deprecation" }) // 'in' is deliberately not closed
+    @Test
+    public void testCopy_readerToOutputStream_nullOut() {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+        final Reader reader = new InputStreamReader(in, StandardCharsets.US_ASCII);
+        assertThrows(NullPointerException.class, () -> IOUtils.copy(reader, (OutputStream) null)); // deliberately testing deprecated method
+    }
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    @Test
+    public void testCopy_readerToWriter() throws Exception {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+        final Reader reader = new InputStreamReader(in, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        final int count = IOUtils.copy(reader, writer);
+        out.off();
+        writer.flush();
+        assertEquals(inData.length, count, "The number of characters returned by copy is wrong");
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    /**
+     * Tests Copying file > 2GB  - see issue# IO-84
+     */
+    @Test
+    public void testCopy_readerToWriter_IO84() throws Exception {
+        final long size = (long)Integer.MAX_VALUE + (long)1;
+        final Reader reader = new NullReader(size);
+        final Writer writer = new NullWriter();
+
+        // Test copy() method
+        assertEquals(-1, IOUtils.copy(reader, writer));
+
+        // reset the input
+        reader.close();
+
+        // Test copyLarge() method
+        assertEquals(size, IOUtils.copyLarge(reader, writer), "copyLarge()");
+    }
+
+    @Test
+    public void testCopy_readerToWriter_nullIn() {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final OutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(out, StandardCharsets.US_ASCII);
+        assertThrows(NullPointerException.class, () -> IOUtils.copy((Reader) null, writer));
+    }
+
+    @SuppressWarnings("resource") // 'in' is deliberately not closed
+    @Test
+    public void testCopy_readerToWriter_nullOut() {
+        InputStream in = new ByteArrayInputStream(inData);
+        in = new ThrowOnCloseInputStream(in);
+        final Reader reader = new InputStreamReader(in, StandardCharsets.US_ASCII);
+        assertThrows(NullPointerException.class, () -> IOUtils.copy(reader, (Writer) null));
+    }
+
+    @Test
+    public void testCopy_URLToFile() throws Exception {
+        final String name = "/org/apache/commons/io/abitmorethan16k.txt";
+        final URL in = getClass().getResource(name);
+        assertNotNull(in, name);
+
+        try (TempFile path = TempFile.create("testCopy_URLToFile", ".txt")) {
+            IOUtils.copy(in, path.toFile());
+            assertArrayEquals(Files.readAllBytes(Paths.get("src/test/resources" + name)), Files.readAllBytes(path.get()));
+        }
+    }
+
+    @Test
+    public void testCopy_URLToOutputStream() throws Exception {
+        final String name = "/org/apache/commons/io/abitmorethan16k.txt";
+        final URL in = getClass().getResource(name);
+        assertNotNull(in, name);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        IOUtils.copy(in, baout);
+
+        assertArrayEquals(Files.readAllBytes(Paths.get("src/test/resources" + name)), baout.toByteArray());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/IOUtilsTest.java b/src/test/java/org/apache/commons/io/IOUtilsTest.java
new file mode 100644
index 0000000..0586551
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/IOUtilsTest.java
@@ -0,0 +1,1725 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.CharArrayReader;
+import java.io.CharArrayWriter;
+import java.io.Closeable;
+import java.io.EOFException;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.Reader;
+import java.io.StringReader;
+import java.io.Writer;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.URI;
+import java.net.URL;
+import java.net.URLConnection;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.channels.Selector;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.function.IOConsumer;
+import org.apache.commons.io.input.BrokenInputStream;
+import org.apache.commons.io.input.CircularInputStream;
+import org.apache.commons.io.input.NullInputStream;
+import org.apache.commons.io.input.NullReader;
+import org.apache.commons.io.input.StringInputStream;
+import org.apache.commons.io.output.AppendableWriter;
+import org.apache.commons.io.output.BrokenOutputStream;
+import org.apache.commons.io.output.CountingOutputStream;
+import org.apache.commons.io.output.NullOutputStream;
+import org.apache.commons.io.output.NullWriter;
+import org.apache.commons.io.output.StringBuilderWriter;
+import org.apache.commons.io.test.TestUtils;
+import org.apache.commons.io.test.ThrowOnCloseReader;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * This is used to test {@link IOUtils} for correctness. The following checks are performed:
+ * <ul>
+ * <li>The return must not be null, must be the same type and equals() to the method's second arg</li>
+ * <li>All bytes must have been read from the source (available() == 0)</li>
+ * <li>The source and destination content must be identical (byte-wise comparison check)</li>
+ * <li>The output stream must not have been closed (a byte/char is written to test this, and subsequent size
+ * checked)</li>
+ * </ul>
+ * Due to interdependencies in IOUtils and IOUtilsTest, one bug may cause multiple tests to fail.
+ */
+@SuppressWarnings("deprecation") // deliberately testing deprecated code
+public class IOUtilsTest {
+
+    private static final String UTF_8 = StandardCharsets.UTF_8.name();
+
+    private static final int FILE_SIZE = 1024 * 4 + 1;
+
+    /** Determine if this is windows. */
+    private static final boolean WINDOWS = File.separatorChar == '\\';
+    /*
+     * Note: this is not particularly beautiful code. A better way to check for flush and close status would be to
+     * implement "trojan horse" wrapper implementations of the various stream classes, which set a flag when relevant
+     * methods are called. (JT)
+     */
+
+    @TempDir
+    public File temporaryFolder;
+
+    private char[] carr;
+
+    private byte[] iarr;
+
+    private File testFile;
+
+    /**
+     * Path constructed from {@code testFile}.
+     */
+    private Path testFilePath;
+
+    /** Assert that the contents of two byte arrays are the same. */
+    private void assertEqualContent(final byte[] b0, final byte[] b1) {
+        assertArrayEquals(b0, b1, "Content not equal according to java.util.Arrays#equals()");
+    }
+
+    @BeforeEach
+    public void setUp() {
+        try {
+            testFile = new File(temporaryFolder, "file2-test.txt");
+            testFilePath = testFile.toPath();
+
+            if (!testFile.getParentFile().exists()) {
+                throw new IOException("Cannot create file " + testFile + " as the parent directory does not exist");
+            }
+            final BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(testFilePath));
+            try {
+                TestUtils.generateTestData(output, FILE_SIZE);
+            } finally {
+                IOUtils.closeQuietly(output);
+            }
+        } catch (final IOException ioe) {
+            throw new RuntimeException(
+                "Can't run this test because the environment could not be built: " + ioe.getMessage());
+        }
+        // Create and init a byte array as input data
+        iarr = new byte[200];
+        Arrays.fill(iarr, (byte) -1);
+        for (int i = 0; i < 80; i++) {
+            iarr[i] = (byte) i;
+        }
+        carr = new char[200];
+        Arrays.fill(carr, (char) -1);
+        for (int i = 0; i < 80; i++) {
+            carr[i] = (char) i;
+        }
+    }
+
+    @Test
+    public void testAsBufferedInputStream() {
+        final InputStream is = new InputStream() {
+            @Override
+            public int read() throws IOException {
+                return 0;
+            }
+        };
+        final BufferedInputStream bis = IOUtils.buffer(is);
+        assertNotSame(is, bis);
+        assertSame(bis, IOUtils.buffer(bis));
+    }
+
+    @Test
+    public void testAsBufferedInputStreamWithBufferSize() {
+        final InputStream is = new InputStream() {
+            @Override
+            public int read() throws IOException {
+                return 0;
+            }
+        };
+        final BufferedInputStream bis = IOUtils.buffer(is, 2048);
+        assertNotSame(is, bis);
+        assertSame(bis, IOUtils.buffer(bis));
+        assertSame(bis, IOUtils.buffer(bis, 1024));
+    }
+
+    @Test
+    public void testAsBufferedNull() {
+        final String npeExpectedMessage = "Expected NullPointerException";
+        assertThrows(NullPointerException.class, ()->IOUtils.buffer((InputStream) null),
+                npeExpectedMessage );
+        assertThrows(NullPointerException.class, ()->IOUtils.buffer((OutputStream) null),
+                npeExpectedMessage);
+        assertThrows(NullPointerException.class, ()->IOUtils.buffer((Reader) null),
+                npeExpectedMessage);
+        assertThrows(NullPointerException.class, ()->IOUtils.buffer((Writer) null),
+                npeExpectedMessage);
+    }
+
+    @Test
+    public void testAsBufferedOutputStream() {
+        final OutputStream is = new OutputStream() {
+            @Override
+            public void write(final int b) throws IOException {
+            }
+        };
+        final BufferedOutputStream bis = IOUtils.buffer(is);
+        assertNotSame(is, bis);
+        assertSame(bis, IOUtils.buffer(bis));
+    }
+
+    @Test
+    public void testAsBufferedOutputStreamWithBufferSize() {
+        final OutputStream os = new OutputStream() {
+            @Override
+            public void write(final int b) throws IOException {
+            }
+        };
+        final BufferedOutputStream bos = IOUtils.buffer(os, 2048);
+        assertNotSame(os, bos);
+        assertSame(bos, IOUtils.buffer(bos));
+        assertSame(bos, IOUtils.buffer(bos, 1024));
+    }
+
+    @Test
+    public void testAsBufferedReader() {
+        final Reader is = new Reader() {
+            @Override
+            public void close() throws IOException {
+            }
+
+            @Override
+            public int read(final char[] cbuf, final int off, final int len) throws IOException {
+                return 0;
+            }
+        };
+        final BufferedReader bis = IOUtils.buffer(is);
+        assertNotSame(is, bis);
+        assertSame(bis, IOUtils.buffer(bis));
+    }
+
+    @Test
+    public void testAsBufferedReaderWithBufferSize() {
+        final Reader r = new Reader() {
+            @Override
+            public void close() throws IOException {
+            }
+
+            @Override
+            public int read(final char[] cbuf, final int off, final int len) throws IOException {
+                return 0;
+            }
+        };
+        final BufferedReader br = IOUtils.buffer(r, 2048);
+        assertNotSame(r, br);
+        assertSame(br, IOUtils.buffer(br));
+        assertSame(br, IOUtils.buffer(br, 1024));
+    }
+
+    @Test
+    public void testAsBufferedWriter() {
+        final Writer nullWriter = NullWriter.INSTANCE;
+        final BufferedWriter bis = IOUtils.buffer(nullWriter);
+        assertNotSame(nullWriter, bis);
+        assertSame(bis, IOUtils.buffer(bis));
+    }
+
+    @Test
+    public void testAsBufferedWriterWithBufferSize() {
+        final Writer nullWriter = NullWriter.INSTANCE;
+        final BufferedWriter bw = IOUtils.buffer(nullWriter, 2024);
+        assertNotSame(nullWriter, bw);
+        assertSame(bw, IOUtils.buffer(bw));
+        assertSame(bw, IOUtils.buffer(bw, 1024));
+    }
+
+    @Test
+    public void testAsWriterAppendable() throws IOException {
+        final Appendable a = new StringBuffer();
+        try (Writer w = IOUtils.writer(a)) {
+            assertNotSame(w, a);
+            assertEquals(AppendableWriter.class, w.getClass());
+            assertSame(w, IOUtils.writer(w));
+        }
+    }
+
+    @Test
+    public void testAsWriterNull() {
+        assertThrows(NullPointerException.class, () -> IOUtils.writer(null));
+    }
+
+    @Test
+    public void testAsWriterStringBuilder() throws IOException {
+        final Appendable a = new StringBuilder();
+        try (Writer w = IOUtils.writer(a)) {
+            assertNotSame(w, a);
+            assertEquals(StringBuilderWriter.class, w.getClass());
+            assertSame(w, IOUtils.writer(w));
+        }
+    }
+
+    @Test
+    public void testClose() {
+        assertDoesNotThrow(() -> IOUtils.close((Closeable) null));
+        assertDoesNotThrow(() -> IOUtils.close(new StringReader("s")));
+        assertThrows(IOException.class, () -> IOUtils.close(new ThrowOnCloseReader(new StringReader("s"))));
+    }
+
+    @Test
+    public void testCloseConsumer() {
+        final Closeable nullCloseable = null;
+        assertDoesNotThrow(() -> IOUtils.close(nullCloseable, null)); // null consumer
+        assertDoesNotThrow(() -> IOUtils.close(new StringReader("s"), null)); // null consumer
+        assertDoesNotThrow(() -> IOUtils.close(new ThrowOnCloseReader(new StringReader("s")), null)); // null consumer
+
+        final IOConsumer<IOException> nullConsumer = null; // null consumer doesn't throw
+        assertDoesNotThrow(() -> IOUtils.close(nullCloseable, nullConsumer));
+        assertDoesNotThrow(() -> IOUtils.close(new StringReader("s"), nullConsumer));
+        assertDoesNotThrow(() -> IOUtils.close(new ThrowOnCloseReader(new StringReader("s")), nullConsumer));
+
+        final IOConsumer<IOException> silentConsumer = IOConsumer.noop(); // noop consumer doesn't throw
+        assertDoesNotThrow(() -> IOUtils.close(nullCloseable, silentConsumer));
+        assertDoesNotThrow(() -> IOUtils.close(new StringReader("s"), silentConsumer));
+        assertDoesNotThrow(() -> IOUtils.close(new ThrowOnCloseReader(new StringReader("s")), silentConsumer));
+
+        final IOConsumer<IOException> noisyConsumer = i -> {
+            throw i;
+        }; // consumer passes on the throw
+        assertDoesNotThrow(() -> IOUtils.close(nullCloseable, noisyConsumer)); // no throw
+        assertDoesNotThrow(() -> IOUtils.close(new StringReader("s"), noisyConsumer)); // no throw
+        assertThrows(IOException.class,
+            () -> IOUtils.close(new ThrowOnCloseReader(new StringReader("s")), noisyConsumer)); // closeable throws
+    }
+
+    @Test
+    public void testCloseMulti() {
+        final Closeable nullCloseable = null;
+        final Closeable[] closeables = {null, null};
+        assertDoesNotThrow(() -> IOUtils.close(nullCloseable, nullCloseable));
+        assertDoesNotThrow(() -> IOUtils.close(closeables));
+        assertDoesNotThrow(() -> IOUtils.close((Closeable[]) null));
+        assertDoesNotThrow(() -> IOUtils.close(new StringReader("s"), nullCloseable));
+        assertThrows(IOException.class, () -> IOUtils.close(nullCloseable, new ThrowOnCloseReader(new StringReader("s"))));
+    }
+
+    @Test
+    public void testCloseQuietly_AllCloseableIOException() {
+        final Closeable closeable = BrokenInputStream.INSTANCE;
+        assertDoesNotThrow(() -> IOUtils.closeQuietly(closeable, null, closeable));
+        assertDoesNotThrow(() -> IOUtils.closeQuietly(Arrays.asList(closeable, null, closeable)));
+        assertDoesNotThrow(() -> IOUtils.closeQuietly(Stream.of(closeable, null, closeable)));
+        assertDoesNotThrow(() -> IOUtils.closeQuietly((Iterable<Closeable>) null));
+    }
+
+    @Test
+    public void testCloseQuietly_CloseableIOException() {
+        assertDoesNotThrow(() -> {
+            IOUtils.closeQuietly(BrokenInputStream.INSTANCE);
+        });
+        assertDoesNotThrow(() -> {
+            IOUtils.closeQuietly(BrokenOutputStream.INSTANCE);
+        });
+    }
+
+    @SuppressWarnings("squid:S2699") // Suppress "Add at least one assertion to this test case"
+    @Test
+    public void testCloseQuietly_Selector() {
+        Selector selector = null;
+        try {
+            selector = Selector.open();
+        } catch (final IOException ignore) {
+        } finally {
+            IOUtils.closeQuietly(selector);
+        }
+    }
+
+    @SuppressWarnings("squid:S2699") // Suppress "Add at least one assertion to this test case"
+    @Test
+    public void testCloseQuietly_SelectorIOException() {
+        final Selector selector = new SelectorAdapter() {
+            @Override
+            public void close() throws IOException {
+                throw new IOException();
+            }
+        };
+        IOUtils.closeQuietly(selector);
+    }
+
+    @SuppressWarnings("squid:S2699") // Suppress "Add at least one assertion to this test case"
+    @Test
+    public void testCloseQuietly_SelectorNull() {
+        final Selector selector = null;
+        IOUtils.closeQuietly(selector);
+    }
+
+    @SuppressWarnings("squid:S2699") // Suppress "Add at least one assertion to this test case"
+    @Test
+    public void testCloseQuietly_SelectorTwice() {
+        Selector selector = null;
+        try {
+            selector = Selector.open();
+        } catch (final IOException ignore) {
+        } finally {
+            IOUtils.closeQuietly(selector);
+            IOUtils.closeQuietly(selector);
+        }
+    }
+
+    @Test
+    public void testCloseQuietly_ServerSocket() {
+        assertDoesNotThrow(() -> IOUtils.closeQuietly((ServerSocket) null));
+        assertDoesNotThrow(() -> IOUtils.closeQuietly(new ServerSocket()));
+    }
+
+    @Test
+    public void testCloseQuietly_ServerSocketIOException() {
+        assertDoesNotThrow(() -> {
+            IOUtils.closeQuietly(new ServerSocket() {
+                @Override
+                public void close() throws IOException {
+                    throw new IOException();
+                }
+            });
+        });
+    }
+
+    @Test
+    public void testCloseQuietly_Socket() {
+        assertDoesNotThrow(() -> IOUtils.closeQuietly((Socket) null));
+        assertDoesNotThrow(() -> IOUtils.closeQuietly(new Socket()));
+    }
+
+    @Test
+    public void testCloseQuietly_SocketIOException() {
+        assertDoesNotThrow(() -> {
+            IOUtils.closeQuietly(new Socket() {
+                @Override
+                public synchronized void close() throws IOException {
+                    throw new IOException();
+                }
+            });
+        });
+    }
+
+    @Test
+    public void testCloseURLConnection() {
+        assertDoesNotThrow(() -> IOUtils.close((URLConnection) null));
+        assertDoesNotThrow(() -> IOUtils.close(new URL("https://www.apache.org/").openConnection()));
+        assertDoesNotThrow(() -> IOUtils.close(new URL("file:///").openConnection()));
+    }
+
+    @Test
+    public void testConstants() {
+        assertEquals('/', IOUtils.DIR_SEPARATOR_UNIX);
+        assertEquals('\\', IOUtils.DIR_SEPARATOR_WINDOWS);
+        assertEquals("\n", IOUtils.LINE_SEPARATOR_UNIX);
+        assertEquals("\r\n", IOUtils.LINE_SEPARATOR_WINDOWS);
+        if (WINDOWS) {
+            assertEquals('\\', IOUtils.DIR_SEPARATOR);
+            assertEquals("\r\n", IOUtils.LINE_SEPARATOR);
+        } else {
+            assertEquals('/', IOUtils.DIR_SEPARATOR);
+            assertEquals("\n", IOUtils.LINE_SEPARATOR);
+        }
+        assertEquals('\r', IOUtils.CR);
+        assertEquals('\n', IOUtils.LF);
+        assertEquals(-1, IOUtils.EOF);
+    }
+
+    @Test
+    public void testConsumeInputStream() throws Exception {
+        final long size = (long) Integer.MAX_VALUE + (long) 1;
+        final InputStream in = new NullInputStream(size);
+        final OutputStream out = NullOutputStream.INSTANCE;
+
+        // Test copy() method
+        assertEquals(-1, IOUtils.copy(in, out));
+
+        // reset the input
+        in.close();
+
+        // Test consume() method
+        assertEquals(size, IOUtils.consume(in), "consume()");
+    }
+
+    @Test
+    public void testConsumeReader() throws Exception {
+        final long size = (long) Integer.MAX_VALUE + (long) 1;
+        final Reader in = new NullReader(size);
+        final Writer out = NullWriter.INSTANCE;
+
+        // Test copy() method
+        assertEquals(-1, IOUtils.copy(in, out));
+
+        // reset the input
+        in.close();
+
+        // Test consume() method
+        assertEquals(size, IOUtils.consume(in), "consume()");
+    }
+
+    @Test
+    public void testContentEquals_InputStream_InputStream() throws Exception {
+        {
+            assertTrue(IOUtils.contentEquals((InputStream) null, null));
+        }
+        final byte[] dataEmpty = "".getBytes(StandardCharsets.UTF_8);
+        final byte[] dataAbc = "ABC".getBytes(StandardCharsets.UTF_8);
+        final byte[] dataAbcd = "ABCD".getBytes(StandardCharsets.UTF_8);
+        {
+            final ByteArrayInputStream input1 = new ByteArrayInputStream(dataEmpty);
+            assertFalse(IOUtils.contentEquals(input1, null));
+        }
+        {
+            final ByteArrayInputStream input1 = new ByteArrayInputStream(dataEmpty);
+            assertFalse(IOUtils.contentEquals(null, input1));
+        }
+        {
+            final ByteArrayInputStream input1 = new ByteArrayInputStream(dataEmpty);
+            assertTrue(IOUtils.contentEquals(input1, input1));
+        }
+        {
+            final ByteArrayInputStream input1 = new ByteArrayInputStream(dataAbc);
+            assertTrue(IOUtils.contentEquals(input1, input1));
+        }
+        assertTrue(IOUtils.contentEquals(new ByteArrayInputStream(dataEmpty), new ByteArrayInputStream(dataEmpty)));
+        assertTrue(IOUtils.contentEquals(new BufferedInputStream(new ByteArrayInputStream(dataEmpty)),
+            new BufferedInputStream(new ByteArrayInputStream(dataEmpty))));
+        assertTrue(IOUtils.contentEquals(new ByteArrayInputStream(dataAbc), new ByteArrayInputStream(dataAbc)));
+        assertFalse(IOUtils.contentEquals(new ByteArrayInputStream(dataAbcd), new ByteArrayInputStream(dataAbc)));
+        assertFalse(IOUtils.contentEquals(new ByteArrayInputStream(dataAbc), new ByteArrayInputStream(dataAbcd)));
+        assertFalse(IOUtils.contentEquals(new ByteArrayInputStream("apache".getBytes(StandardCharsets.UTF_8)),
+                new ByteArrayInputStream("apacha".getBytes(StandardCharsets.UTF_8))));
+        // Tests with larger inputs that DEFAULT_BUFFER_SIZE in case internal buffers are used.
+        final byte[] bytes2XDefaultA = new byte[IOUtils.DEFAULT_BUFFER_SIZE * 2];
+        final byte[] bytes2XDefaultB = new byte[IOUtils.DEFAULT_BUFFER_SIZE * 2];
+        final byte[] bytes2XDefaultA2 = new byte[IOUtils.DEFAULT_BUFFER_SIZE * 2];
+        Arrays.fill(bytes2XDefaultA, (byte) 'a');
+        Arrays.fill(bytes2XDefaultB, (byte) 'b');
+        Arrays.fill(bytes2XDefaultA2, (byte) 'a');
+        bytes2XDefaultA2[bytes2XDefaultA2.length - 1] = 'd';
+        assertFalse(IOUtils.contentEquals(new ByteArrayInputStream(bytes2XDefaultA),
+            new ByteArrayInputStream(bytes2XDefaultB)));
+        assertFalse(IOUtils.contentEquals(new ByteArrayInputStream(bytes2XDefaultA),
+            new ByteArrayInputStream(bytes2XDefaultA2)));
+        assertTrue(IOUtils.contentEquals(new ByteArrayInputStream(bytes2XDefaultA),
+            new ByteArrayInputStream(bytes2XDefaultA)));
+        // FileInputStream a bit more than 16 k.
+        try (
+            final FileInputStream input1 = new FileInputStream(
+                "src/test/resources/org/apache/commons/io/abitmorethan16k.txt");
+            final FileInputStream input2 = new FileInputStream(
+                "src/test/resources/org/apache/commons/io/abitmorethan16kcopy.txt")) {
+            assertTrue(IOUtils.contentEquals(input1, input1));
+        }
+    }
+
+    @Test
+    public void testContentEquals_Reader_Reader() throws Exception {
+        {
+            assertTrue(IOUtils.contentEquals((Reader) null, null));
+        }
+        {
+            final StringReader input1 = new StringReader("");
+            assertFalse(IOUtils.contentEquals(null, input1));
+        }
+        {
+            final StringReader input1 = new StringReader("");
+            assertFalse(IOUtils.contentEquals(input1, null));
+        }
+        {
+            final StringReader input1 = new StringReader("");
+            assertTrue(IOUtils.contentEquals(input1, input1));
+        }
+        {
+            final StringReader input1 = new StringReader("ABC");
+            assertTrue(IOUtils.contentEquals(input1, input1));
+        }
+        assertTrue(IOUtils.contentEquals(new StringReader(""), new StringReader("")));
+        assertTrue(
+            IOUtils.contentEquals(new BufferedReader(new StringReader("")), new BufferedReader(new StringReader(""))));
+        assertTrue(IOUtils.contentEquals(new StringReader("ABC"), new StringReader("ABC")));
+        assertFalse(IOUtils.contentEquals(new StringReader("ABCD"), new StringReader("ABC")));
+        assertFalse(IOUtils.contentEquals(new StringReader("ABC"), new StringReader("ABCD")));
+        assertFalse(IOUtils.contentEquals(new StringReader("apache"), new StringReader("apacha")));
+    }
+
+    @Test
+    public void testContentEqualsIgnoreEOL() throws Exception {
+        {
+            assertTrue(IOUtils.contentEqualsIgnoreEOL(null, null));
+        }
+        final char[] empty = {};
+        {
+            final Reader input1 = new CharArrayReader(empty);
+            assertFalse(IOUtils.contentEqualsIgnoreEOL(null, input1));
+        }
+        {
+            final Reader input1 = new CharArrayReader(empty);
+            assertFalse(IOUtils.contentEqualsIgnoreEOL(input1, null));
+        }
+        {
+            final Reader input1 = new CharArrayReader(empty);
+            assertTrue(IOUtils.contentEqualsIgnoreEOL(input1, input1));
+        }
+        {
+            final Reader input1 = new CharArrayReader("321\r\n".toCharArray());
+            assertTrue(IOUtils.contentEqualsIgnoreEOL(input1, input1));
+        }
+
+        testSingleEOL("", "", true);
+        testSingleEOL("", "\n", false);
+        testSingleEOL("", "\r", false);
+        testSingleEOL("", "\r\n", false);
+        testSingleEOL("", "\r\r", false);
+        testSingleEOL("", "\n\n", false);
+        testSingleEOL("1", "1", true);
+        testSingleEOL("1", "2", false);
+        testSingleEOL("123\rabc", "123\nabc", true);
+        testSingleEOL("321", "321\r\n", true);
+        testSingleEOL("321", "321\r\naabb", false);
+        testSingleEOL("321", "321\n", true);
+        testSingleEOL("321", "321\r", true);
+        testSingleEOL("321", "321\r\n", true);
+        testSingleEOL("321", "321\r\r", false);
+        testSingleEOL("321", "321\n\r", false);
+        testSingleEOL("321\n", "321", true);
+        testSingleEOL("321\n", "321\n\r", false);
+        testSingleEOL("321\n", "321\r\n", true);
+        testSingleEOL("321\r", "321\r\n", true);
+        testSingleEOL("321\r\n", "321\r\n\r", false);
+        testSingleEOL("123", "1234", false);
+        testSingleEOL("1235", "1234", false);
+    }
+
+    @Test
+    public void testCopy_ByteArray_OutputStream() throws Exception {
+        final File destination = TestUtils.newFile(temporaryFolder, "copy8.txt");
+        final byte[] in;
+        try (InputStream fin = Files.newInputStream(testFilePath)) {
+            // Create our byte[]. Rely on testInputStreamToByteArray() to make sure this is valid.
+            in = IOUtils.toByteArray(fin);
+        }
+
+        try (OutputStream fout = Files.newOutputStream(destination.toPath())) {
+            CopyUtils.copy(in, fout);
+
+            fout.flush();
+
+            TestUtils.checkFile(destination, testFile);
+            TestUtils.checkWrite(fout);
+        }
+        TestUtils.deleteFile(destination);
+    }
+
+    @Test
+    public void testCopy_ByteArray_Writer() throws Exception {
+        final File destination = TestUtils.newFile(temporaryFolder, "copy7.txt");
+        final byte[] in;
+        try (InputStream fin = Files.newInputStream(testFilePath)) {
+            // Create our byte[]. Rely on testInputStreamToByteArray() to make sure this is valid.
+            in = IOUtils.toByteArray(fin);
+        }
+
+        try (Writer fout = Files.newBufferedWriter(destination.toPath())) {
+            CopyUtils.copy(in, fout);
+            fout.flush();
+            TestUtils.checkFile(destination, testFile);
+            TestUtils.checkWrite(fout);
+        }
+        TestUtils.deleteFile(destination);
+    }
+
+    @Test
+    public void testCopy_String_Writer() throws Exception {
+        final File destination = TestUtils.newFile(temporaryFolder, "copy6.txt");
+        final String str;
+        try (Reader fin = Files.newBufferedReader(testFilePath)) {
+            // Create our String. Rely on testReaderToString() to make sure this is valid.
+            str = IOUtils.toString(fin);
+        }
+
+        try (Writer fout = Files.newBufferedWriter(destination.toPath())) {
+            CopyUtils.copy(str, fout);
+            fout.flush();
+
+            TestUtils.checkFile(destination, testFile);
+            TestUtils.checkWrite(fout);
+        }
+        TestUtils.deleteFile(destination);
+    }
+
+    @Test
+    public void testCopyLarge_CharExtraLength() throws IOException {
+        CharArrayReader is = null;
+        CharArrayWriter os = null;
+        try {
+            // Create streams
+            is = new CharArrayReader(carr);
+            os = new CharArrayWriter();
+
+            // Test our copy method
+            // for extra length, it reads till EOF
+            assertEquals(200, IOUtils.copyLarge(is, os, 0, 2000));
+            final char[] oarr = os.toCharArray();
+
+            // check that output length is correct
+            assertEquals(200, oarr.length);
+            // check that output data corresponds to input data
+            assertEquals(1, oarr[1]);
+            assertEquals(79, oarr[79]);
+            assertEquals((char) -1, oarr[80]);
+
+        } finally {
+            IOUtils.closeQuietly(is);
+            IOUtils.closeQuietly(os);
+        }
+    }
+
+    @Test
+    public void testCopyLarge_CharFullLength() throws IOException {
+        CharArrayReader is = null;
+        CharArrayWriter os = null;
+        try {
+            // Create streams
+            is = new CharArrayReader(carr);
+            os = new CharArrayWriter();
+
+            // Test our copy method
+            assertEquals(200, IOUtils.copyLarge(is, os, 0, -1));
+            final char[] oarr = os.toCharArray();
+
+            // check that output length is correct
+            assertEquals(200, oarr.length);
+            // check that output data corresponds to input data
+            assertEquals(1, oarr[1]);
+            assertEquals(79, oarr[79]);
+            assertEquals((char) -1, oarr[80]);
+
+        } finally {
+            IOUtils.closeQuietly(is);
+            IOUtils.closeQuietly(os);
+        }
+    }
+
+    @Test
+    public void testCopyLarge_CharNoSkip() throws IOException {
+        CharArrayReader is = null;
+        CharArrayWriter os = null;
+        try {
+            // Create streams
+            is = new CharArrayReader(carr);
+            os = new CharArrayWriter();
+
+            // Test our copy method
+            assertEquals(100, IOUtils.copyLarge(is, os, 0, 100));
+            final char[] oarr = os.toCharArray();
+
+            // check that output length is correct
+            assertEquals(100, oarr.length);
+            // check that output data corresponds to input data
+            assertEquals(1, oarr[1]);
+            assertEquals(79, oarr[79]);
+            assertEquals((char) -1, oarr[80]);
+
+        } finally {
+            IOUtils.closeQuietly(is);
+            IOUtils.closeQuietly(os);
+        }
+    }
+
+    @Test
+    public void testCopyLarge_CharSkip() throws IOException {
+        CharArrayReader is = null;
+        CharArrayWriter os = null;
+        try {
+            // Create streams
+            is = new CharArrayReader(carr);
+            os = new CharArrayWriter();
+
+            // Test our copy method
+            assertEquals(100, IOUtils.copyLarge(is, os, 10, 100));
+            final char[] oarr = os.toCharArray();
+
+            // check that output length is correct
+            assertEquals(100, oarr.length);
+            // check that output data corresponds to input data
+            assertEquals(11, oarr[1]);
+            assertEquals(79, oarr[69]);
+            assertEquals((char) -1, oarr[70]);
+
+        } finally {
+            IOUtils.closeQuietly(is);
+            IOUtils.closeQuietly(os);
+        }
+    }
+
+    @Test
+    public void testCopyLarge_CharSkipInvalid() {
+        try (CharArrayReader is = new CharArrayReader(carr); CharArrayWriter os = new CharArrayWriter()) {
+            assertThrows(EOFException.class, () -> IOUtils.copyLarge(is, os, 1000, 100));
+        }
+    }
+
+    @Test
+    public void testCopyLarge_ExtraLength() throws IOException {
+        try (ByteArrayInputStream is = new ByteArrayInputStream(iarr);
+            ByteArrayOutputStream os = new ByteArrayOutputStream()) {
+            // Create streams
+
+            // Test our copy method
+            // for extra length, it reads till EOF
+            assertEquals(200, IOUtils.copyLarge(is, os, 0, 2000));
+            final byte[] oarr = os.toByteArray();
+
+            // check that output length is correct
+            assertEquals(200, oarr.length);
+            // check that output data corresponds to input data
+            assertEquals(1, oarr[1]);
+            assertEquals(79, oarr[79]);
+            assertEquals(-1, oarr[80]);
+        }
+    }
+
+    @Test
+    public void testCopyLarge_FullLength() throws IOException {
+        try (ByteArrayInputStream is = new ByteArrayInputStream(iarr);
+            ByteArrayOutputStream os = new ByteArrayOutputStream()) {
+            // Test our copy method
+            assertEquals(200, IOUtils.copyLarge(is, os, 0, -1));
+            final byte[] oarr = os.toByteArray();
+
+            // check that output length is correct
+            assertEquals(200, oarr.length);
+            // check that output data corresponds to input data
+            assertEquals(1, oarr[1]);
+            assertEquals(79, oarr[79]);
+            assertEquals(-1, oarr[80]);
+        }
+    }
+
+    @Test
+    public void testCopyLarge_NoSkip() throws IOException {
+        try (ByteArrayInputStream is = new ByteArrayInputStream(iarr);
+            ByteArrayOutputStream os = new ByteArrayOutputStream()) {
+            // Test our copy method
+            assertEquals(100, IOUtils.copyLarge(is, os, 0, 100));
+            final byte[] oarr = os.toByteArray();
+
+            // check that output length is correct
+            assertEquals(100, oarr.length);
+            // check that output data corresponds to input data
+            assertEquals(1, oarr[1]);
+            assertEquals(79, oarr[79]);
+            assertEquals(-1, oarr[80]);
+        }
+    }
+
+    @Test
+    public void testCopyLarge_Skip() throws IOException {
+        try (ByteArrayInputStream is = new ByteArrayInputStream(iarr);
+            ByteArrayOutputStream os = new ByteArrayOutputStream()) {
+            // Test our copy method
+            assertEquals(100, IOUtils.copyLarge(is, os, 10, 100));
+            final byte[] oarr = os.toByteArray();
+
+            // check that output length is correct
+            assertEquals(100, oarr.length);
+            // check that output data corresponds to input data
+            assertEquals(11, oarr[1]);
+            assertEquals(79, oarr[69]);
+            assertEquals(-1, oarr[70]);
+        }
+    }
+
+    @Test
+    public void testCopyLarge_SkipInvalid() throws IOException {
+        try (ByteArrayInputStream is = new ByteArrayInputStream(iarr);
+            ByteArrayOutputStream os = new ByteArrayOutputStream()) {
+            // Test our copy method
+            assertThrows(EOFException.class, () -> IOUtils.copyLarge(is, os, 1000, 100));
+        }
+    }
+
+    @Test
+    public void testCopyLarge_SkipWithInvalidOffset() throws IOException {
+        ByteArrayInputStream is = null;
+        ByteArrayOutputStream os = null;
+        try {
+            // Create streams
+            is = new ByteArrayInputStream(iarr);
+            os = new ByteArrayOutputStream();
+
+            // Test our copy method
+            assertEquals(100, IOUtils.copyLarge(is, os, -10, 100));
+            final byte[] oarr = os.toByteArray();
+
+            // check that output length is correct
+            assertEquals(100, oarr.length);
+            // check that output data corresponds to input data
+            assertEquals(1, oarr[1]);
+            assertEquals(79, oarr[79]);
+            assertEquals(-1, oarr[80]);
+
+        } finally {
+            IOUtils.closeQuietly(is);
+            IOUtils.closeQuietly(os);
+        }
+    }
+
+    @Test
+    public void testRead_ReadableByteChannel() throws Exception {
+        final ByteBuffer buffer = ByteBuffer.allocate(FILE_SIZE);
+        final FileInputStream fileInputStream = new FileInputStream(testFile);
+        final FileChannel input = fileInputStream.getChannel();
+        try {
+            assertEquals(FILE_SIZE, IOUtils.read(input, buffer));
+            assertEquals(0, IOUtils.read(input, buffer));
+            assertEquals(0, buffer.remaining());
+            assertEquals(0, input.read(buffer));
+            buffer.clear();
+            assertThrows(EOFException.class, ()->IOUtils.readFully(input, buffer),
+                    "Should have failed with EOFException");
+        } finally {
+            IOUtils.closeQuietly(input, fileInputStream);
+        }
+    }
+
+    @Test
+    public void testReadFully_InputStream__ReturnByteArray() throws Exception {
+        final byte[] bytes = "abcd1234".getBytes(StandardCharsets.UTF_8);
+        final ByteArrayInputStream stream = new ByteArrayInputStream(bytes);
+
+        final byte[] result = IOUtils.readFully(stream, bytes.length);
+
+        IOUtils.closeQuietly(stream);
+
+        assertEqualContent(result, bytes);
+    }
+
+    @Test
+    public void testReadFully_InputStream_ByteArray() throws Exception {
+        final int size = 1027;
+        final byte[] buffer = new byte[size];
+        final InputStream input = new ByteArrayInputStream(new byte[size]);
+
+        assertThrows(IllegalArgumentException.class, ()-> IOUtils.readFully(input, buffer, 0, -1),
+                "Should have failed with IllegalArgumentException");
+
+        IOUtils.readFully(input, buffer, 0, 0);
+        IOUtils.readFully(input, buffer, 0, size - 1);
+        assertThrows(EOFException.class, ()-> IOUtils.readFully(input, buffer, 0, 2),
+                "Should have failed with EOFException");
+        IOUtils.closeQuietly(input);
+    }
+
+    @Test
+    public void testReadFully_InputStream_Offset() throws Exception {
+        final StringInputStream stream = new StringInputStream("abcd1234", StandardCharsets.UTF_8);
+        final byte[] buffer = "wx00000000".getBytes(StandardCharsets.UTF_8);
+        IOUtils.readFully(stream, buffer, 2, 8);
+        assertEquals("wxabcd1234", new String(buffer, 0, buffer.length, StandardCharsets.UTF_8));
+        IOUtils.closeQuietly(stream);
+    }
+
+    @Test
+    public void testReadFully_ReadableByteChannel() throws Exception {
+        final ByteBuffer buffer = ByteBuffer.allocate(FILE_SIZE);
+        final FileInputStream fileInputStream = new FileInputStream(testFile);
+        final FileChannel input = fileInputStream.getChannel();
+        try {
+            IOUtils.readFully(input, buffer);
+            assertEquals(FILE_SIZE, buffer.position());
+            assertEquals(0, buffer.remaining());
+            assertEquals(0, input.read(buffer));
+            IOUtils.readFully(input, buffer);
+            assertEquals(FILE_SIZE, buffer.position());
+            assertEquals(0, buffer.remaining());
+            assertEquals(0, input.read(buffer));
+            IOUtils.readFully(input, buffer);
+            buffer.clear();
+            assertThrows(EOFException.class, ()->IOUtils.readFully(input, buffer),
+                    "Should have failed with EOFxception");
+        } finally {
+            IOUtils.closeQuietly(input, fileInputStream);
+        }
+    }
+
+    @Test
+    public void testReadFully_Reader() throws Exception {
+        final int size = 1027;
+        final char[] buffer = new char[size];
+        final Reader input = new CharArrayReader(new char[size]);
+
+        IOUtils.readFully(input, buffer, 0, 0);
+        IOUtils.readFully(input, buffer, 0, size - 3);
+        assertThrows(IllegalArgumentException.class, ()->IOUtils.readFully(input, buffer, 0, -1),
+                "Should have failed with IllegalArgumentException" );
+        assertThrows(EOFException.class, ()->IOUtils.readFully(input, buffer, 0, 5),
+                "Should have failed with EOFException" );
+        IOUtils.closeQuietly(input);
+    }
+
+    @Test
+    public void testReadFully_Reader_Offset() throws Exception {
+        final Reader reader = new StringReader("abcd1234");
+        final char[] buffer = "wx00000000".toCharArray();
+        IOUtils.readFully(reader, buffer, 2, 8);
+        assertEquals("wxabcd1234", new String(buffer));
+        IOUtils.closeQuietly(reader);
+    }
+
+    @Test
+    public void testReadLines_InputStream() throws Exception {
+        final File file = TestUtils.newFile(temporaryFolder, "lines.txt");
+        InputStream in = null;
+        try {
+            final String[] data = {"hello", "world", "", "this is", "some text"};
+            TestUtils.createLineBasedFile(file, data);
+
+            in = Files.newInputStream(file.toPath());
+            final List<String> lines = IOUtils.readLines(in);
+            assertEquals(Arrays.asList(data), lines);
+            assertEquals(-1, in.read());
+        } finally {
+            IOUtils.closeQuietly(in);
+            TestUtils.deleteFile(file);
+        }
+    }
+
+    @Test
+    public void testReadLines_InputStream_String() throws Exception {
+        final File file = TestUtils.newFile(temporaryFolder, "lines.txt");
+        InputStream in = null;
+        try {
+            final String[] data = {"hello", "/u1234", "", "this is", "some text"};
+            TestUtils.createLineBasedFile(file, data);
+
+            in = Files.newInputStream(file.toPath());
+            final List<String> lines = IOUtils.readLines(in, UTF_8);
+            assertEquals(Arrays.asList(data), lines);
+            assertEquals(-1, in.read());
+        } finally {
+            IOUtils.closeQuietly(in);
+            TestUtils.deleteFile(file);
+        }
+    }
+
+    @Test
+    public void testReadLines_Reader() throws Exception {
+        final File file = TestUtils.newFile(temporaryFolder, "lines.txt");
+        Reader in = null;
+        try {
+            final String[] data = {"hello", "/u1234", "", "this is", "some text"};
+            TestUtils.createLineBasedFile(file, data);
+
+            in = new InputStreamReader(Files.newInputStream(file.toPath()));
+            final List<String> lines = IOUtils.readLines(in);
+            assertEquals(Arrays.asList(data), lines);
+            assertEquals(-1, in.read());
+        } finally {
+            IOUtils.closeQuietly(in);
+            TestUtils.deleteFile(file);
+        }
+    }
+
+    @Test
+    public void testResourceToByteArray_ExistingResourceAtRootPackage() throws Exception {
+        final long fileSize = TestResources.getFile("test-file-utf8.bin").length();
+        final byte[] bytes = IOUtils.resourceToByteArray("/org/apache/commons/io/test-file-utf8.bin");
+        assertNotNull(bytes);
+        assertEquals(fileSize, bytes.length);
+    }
+
+    @Test
+    public void testResourceToByteArray_ExistingResourceAtRootPackage_WithClassLoader() throws Exception {
+        final long fileSize = TestResources.getFile("test-file-utf8.bin").length();
+        final byte[] bytes = IOUtils.resourceToByteArray("org/apache/commons/io/test-file-utf8.bin",
+            ClassLoader.getSystemClassLoader());
+        assertNotNull(bytes);
+        assertEquals(fileSize, bytes.length);
+    }
+
+    @Test
+    public void testResourceToByteArray_ExistingResourceAtSubPackage() throws Exception {
+        final long fileSize = TestResources.getFile("FileUtilsTestDataCR.dat").length();
+        final byte[] bytes = IOUtils.resourceToByteArray("/org/apache/commons/io/FileUtilsTestDataCR.dat");
+        assertNotNull(bytes);
+        assertEquals(fileSize, bytes.length);
+    }
+
+    @Test
+    public void testResourceToByteArray_ExistingResourceAtSubPackage_WithClassLoader() throws Exception {
+        final long fileSize = TestResources.getFile("FileUtilsTestDataCR.dat").length();
+        final byte[] bytes = IOUtils.resourceToByteArray("org/apache/commons/io/FileUtilsTestDataCR.dat",
+            ClassLoader.getSystemClassLoader());
+        assertNotNull(bytes);
+        assertEquals(fileSize, bytes.length);
+    }
+
+    @Test
+    public void testResourceToByteArray_NonExistingResource() {
+        assertThrows(IOException.class, () -> IOUtils.resourceToByteArray("/non-existing-file.bin"));
+    }
+
+    @Test
+    public void testResourceToByteArray_NonExistingResource_WithClassLoader() {
+        assertThrows(IOException.class,
+            () -> IOUtils.resourceToByteArray("non-existing-file.bin", ClassLoader.getSystemClassLoader()));
+    }
+
+    @Test
+    public void testResourceToByteArray_Null() {
+        assertThrows(NullPointerException.class, () -> IOUtils.resourceToByteArray(null));
+    }
+
+    @Test
+    public void testResourceToByteArray_Null_WithClassLoader() {
+        assertThrows(NullPointerException.class,
+            () -> IOUtils.resourceToByteArray(null, ClassLoader.getSystemClassLoader()));
+    }
+
+    @Test
+    public void testResourceToString_ExistingResourceAtRootPackage() throws Exception {
+        final long fileSize = TestResources.getFile("test-file-simple-utf8.bin").length();
+        final String content = IOUtils.resourceToString("/org/apache/commons/io/test-file-simple-utf8.bin",
+            StandardCharsets.UTF_8);
+
+        assertNotNull(content);
+        assertEquals(fileSize, content.getBytes().length);
+    }
+
+    @Test
+    public void testResourceToString_ExistingResourceAtRootPackage_WithClassLoader() throws Exception {
+        final long fileSize = TestResources.getFile("test-file-simple-utf8.bin").length();
+        final String content = IOUtils.resourceToString("org/apache/commons/io/test-file-simple-utf8.bin",
+            StandardCharsets.UTF_8, ClassLoader.getSystemClassLoader());
+
+        assertNotNull(content);
+        assertEquals(fileSize, content.getBytes().length);
+    }
+
+    @Test
+    public void testResourceToString_ExistingResourceAtSubPackage() throws Exception {
+        final long fileSize = TestResources.getFile("FileUtilsTestDataCR.dat").length();
+        final String content = IOUtils.resourceToString("/org/apache/commons/io/FileUtilsTestDataCR.dat",
+            StandardCharsets.UTF_8);
+
+        assertNotNull(content);
+        assertEquals(fileSize, content.getBytes().length);
+    }
+
+    @Test
+    public void testResourceToString_ExistingResourceAtSubPackage_WithClassLoader() throws Exception {
+        final long fileSize = TestResources.getFile("FileUtilsTestDataCR.dat").length();
+        final String content = IOUtils.resourceToString("org/apache/commons/io/FileUtilsTestDataCR.dat",
+            StandardCharsets.UTF_8, ClassLoader.getSystemClassLoader());
+
+        assertNotNull(content);
+        assertEquals(fileSize, content.getBytes().length);
+    }
+
+    // Tests from IO-305
+
+    @Test
+    public void testResourceToString_NonExistingResource() {
+        assertThrows(IOException.class,
+            () -> IOUtils.resourceToString("/non-existing-file.bin", StandardCharsets.UTF_8));
+    }
+
+    @Test
+    public void testResourceToString_NonExistingResource_WithClassLoader() {
+        assertThrows(IOException.class, () -> IOUtils.resourceToString("non-existing-file.bin", StandardCharsets.UTF_8,
+            ClassLoader.getSystemClassLoader()));
+    }
+
+    @SuppressWarnings("squid:S2699") // Suppress "Add at least one assertion to this test case"
+    @Test
+    public void testResourceToString_NullCharset() throws Exception {
+        IOUtils.resourceToString("/org/apache/commons/io//test-file-utf8.bin", null);
+    }
+
+    @SuppressWarnings("squid:S2699") // Suppress "Add at least one assertion to this test case"
+    @Test
+    public void testResourceToString_NullCharset_WithClassLoader() throws Exception {
+        IOUtils.resourceToString("org/apache/commons/io/test-file-utf8.bin", null, ClassLoader.getSystemClassLoader());
+    }
+
+    @Test
+    public void testResourceToString_NullResource() {
+        assertThrows(NullPointerException.class, () -> IOUtils.resourceToString(null, StandardCharsets.UTF_8));
+    }
+
+    @Test
+    public void testResourceToString_NullResource_WithClassLoader() {
+        assertThrows(NullPointerException.class,
+            () -> IOUtils.resourceToString(null, StandardCharsets.UTF_8, ClassLoader.getSystemClassLoader()));
+    }
+
+    @Test
+    public void testResourceToURL_ExistingResourceAtRootPackage() throws Exception {
+        final URL url = IOUtils.resourceToURL("/org/apache/commons/io/test-file-utf8.bin");
+        assertNotNull(url);
+        assertTrue(url.getFile().endsWith("/test-file-utf8.bin"));
+    }
+
+    @Test
+    public void testResourceToURL_ExistingResourceAtRootPackage_WithClassLoader() throws Exception {
+        final URL url = IOUtils.resourceToURL("org/apache/commons/io/test-file-utf8.bin",
+            ClassLoader.getSystemClassLoader());
+        assertNotNull(url);
+        assertTrue(url.getFile().endsWith("/org/apache/commons/io/test-file-utf8.bin"));
+    }
+
+    @Test
+    public void testResourceToURL_ExistingResourceAtSubPackage() throws Exception {
+        final URL url = IOUtils.resourceToURL("/org/apache/commons/io/FileUtilsTestDataCR.dat");
+        assertNotNull(url);
+        assertTrue(url.getFile().endsWith("/org/apache/commons/io/FileUtilsTestDataCR.dat"));
+    }
+
+    @Test
+    public void testResourceToURL_ExistingResourceAtSubPackage_WithClassLoader() throws Exception {
+        final URL url = IOUtils.resourceToURL("org/apache/commons/io/FileUtilsTestDataCR.dat",
+            ClassLoader.getSystemClassLoader());
+
+        assertNotNull(url);
+        assertTrue(url.getFile().endsWith("/org/apache/commons/io/FileUtilsTestDataCR.dat"));
+    }
+
+    @Test
+    public void testResourceToURL_NonExistingResource() {
+        assertThrows(IOException.class, () -> IOUtils.resourceToURL("/non-existing-file.bin"));
+    }
+
+    @Test
+    public void testResourceToURL_NonExistingResource_WithClassLoader() {
+        assertThrows(IOException.class,
+            () -> IOUtils.resourceToURL("non-existing-file.bin", ClassLoader.getSystemClassLoader()));
+    }
+
+    @Test
+    public void testResourceToURL_Null() {
+        assertThrows(NullPointerException.class, () -> IOUtils.resourceToURL(null));
+    }
+
+    @Test
+    public void testResourceToURL_Null_WithClassLoader() {
+        assertThrows(NullPointerException.class, () -> IOUtils.resourceToURL(null, ClassLoader.getSystemClassLoader()));
+    }
+
+    public void testSingleEOL(final String s1, final String s2, final boolean ifEquals) throws IOException {
+        assertEquals(ifEquals, IOUtils.contentEqualsIgnoreEOL(
+                new CharArrayReader(s1.toCharArray()),
+                new CharArrayReader(s2.toCharArray())
+        ), "failed at :{" + s1 + "," + s2 + "}");
+        assertEquals(ifEquals, IOUtils.contentEqualsIgnoreEOL(
+                new CharArrayReader(s2.toCharArray()),
+                new CharArrayReader(s1.toCharArray())
+        ), "failed at :{" + s2 + "," + s1 + "}");
+        assertTrue(IOUtils.contentEqualsIgnoreEOL(
+                new CharArrayReader(s1.toCharArray()),
+                new CharArrayReader(s1.toCharArray())
+        ),"failed at :{" + s1 + "," + s1 + "}");
+        assertTrue(IOUtils.contentEqualsIgnoreEOL(
+                new CharArrayReader(s2.toCharArray()),
+                new CharArrayReader(s2.toCharArray())
+        ), "failed at :{" + s2 + "," + s2 + "}");
+    }
+
+    @Test
+    public void testSkip_FileReader() throws Exception {
+        try (Reader in = Files.newBufferedReader(testFilePath)) {
+            assertEquals(FILE_SIZE - 10, IOUtils.skip(in, FILE_SIZE - 10));
+            assertEquals(10, IOUtils.skip(in, 20));
+            assertEquals(0, IOUtils.skip(in, 10));
+        }
+    }
+
+    @Test
+    public void testSkip_InputStream() throws Exception {
+        try (InputStream in = Files.newInputStream(testFilePath)) {
+            assertEquals(FILE_SIZE - 10, IOUtils.skip(in, FILE_SIZE - 10));
+            assertEquals(10, IOUtils.skip(in, 20));
+            assertEquals(0, IOUtils.skip(in, 10));
+        }
+    }
+
+    @Test
+    public void testSkip_ReadableByteChannel() throws Exception {
+        final FileInputStream fileInputStream = new FileInputStream(testFile);
+        final FileChannel fileChannel = fileInputStream.getChannel();
+        try {
+            assertEquals(FILE_SIZE - 10, IOUtils.skip(fileChannel, FILE_SIZE - 10));
+            assertEquals(10, IOUtils.skip(fileChannel, 20));
+            assertEquals(0, IOUtils.skip(fileChannel, 10));
+        } finally {
+            IOUtils.closeQuietly(fileChannel, fileInputStream);
+        }
+    }
+
+    @Test
+    public void testSkipFully_InputStream() throws Exception {
+        final int size = 1027;
+
+        final InputStream input = new ByteArrayInputStream(new byte[size]);
+        assertThrows(IllegalArgumentException.class, ()->IOUtils.skipFully(input, -1),
+                "Should have failed with IllegalArgumentException" );
+
+        IOUtils.skipFully(input, 0);
+        IOUtils.skipFully(input, size - 1);
+        assertThrows(IOException.class, ()->  IOUtils.skipFully(input, 2),
+        "Should have failed with IOException" );
+        IOUtils.closeQuietly(input);
+    }
+
+    @Test
+    public void testSkipFully_ReadableByteChannel() throws Exception {
+        final FileInputStream fileInputStream = new FileInputStream(testFile);
+        final FileChannel fileChannel = fileInputStream.getChannel();
+        try {
+            assertThrows(IllegalArgumentException.class, ()->IOUtils.skipFully(fileChannel, -1),
+                    "Should have failed with IllegalArgumentException" );
+            IOUtils.skipFully(fileChannel, 0);
+            IOUtils.skipFully(fileChannel, FILE_SIZE - 1);
+            assertThrows(IOException.class, ()->IOUtils.skipFully(fileChannel, 2),
+                    "Should have failed with IOException" );
+        } finally {
+            IOUtils.closeQuietly(fileChannel, fileInputStream);
+        }
+    }
+
+    @Test
+    public void testSkipFully_Reader() throws Exception {
+        final int size = 1027;
+        final Reader input = new CharArrayReader(new char[size]);
+
+        IOUtils.skipFully(input, 0);
+        IOUtils.skipFully(input, size - 3);
+        assertThrows(IllegalArgumentException.class, ()->IOUtils.skipFully(input, -1),
+                "Should have failed with IllegalArgumentException" );
+        assertThrows(IOException.class, ()->IOUtils.skipFully(input, 5),
+                "Should have failed with IOException" );
+        IOUtils.closeQuietly(input);
+    }
+
+    @Test
+    public void testStringToOutputStream() throws Exception {
+        final File destination = TestUtils.newFile(temporaryFolder, "copy5.txt");
+        final String str;
+        try (Reader fin = Files.newBufferedReader(testFilePath)) {
+            // Create our String. Rely on testReaderToString() to make sure this is valid.
+            str = IOUtils.toString(fin);
+        }
+
+        try (OutputStream fout = Files.newOutputStream(destination.toPath())) {
+            CopyUtils.copy(str, fout);
+            // Note: this method *does* flush. It is equivalent to:
+            // OutputStreamWriter _out = new OutputStreamWriter(fout);
+            // CopyUtils.copy( str, _out, 4096 ); // copy( Reader, Writer, int );
+            // _out.flush();
+            // out = fout;
+            // note: we don't flush here; this IOUtils method does it for us
+
+            TestUtils.checkFile(destination, testFile);
+            TestUtils.checkWrite(fout);
+        }
+        TestUtils.deleteFile(destination);
+    }
+
+    @Test
+    public void testToBufferedInputStream_InputStream() throws Exception {
+        try (InputStream fin = Files.newInputStream(testFilePath)) {
+            final InputStream in = IOUtils.toBufferedInputStream(fin);
+            final byte[] out = IOUtils.toByteArray(in);
+            assertNotNull(out);
+            assertEquals(0, fin.available(), "Not all bytes were read");
+            assertEquals(FILE_SIZE, out.length, "Wrong output size");
+            TestUtils.assertEqualContent(out, testFile);
+        }
+    }
+
+    @Test
+    public void testToBufferedInputStreamWithBufferSize_InputStream() throws Exception {
+        try (InputStream fin = Files.newInputStream(testFilePath)) {
+            final InputStream in = IOUtils.toBufferedInputStream(fin, 2048);
+            final byte[] out = IOUtils.toByteArray(in);
+            assertNotNull(out);
+            assertEquals(0, fin.available(), "Not all bytes were read");
+            assertEquals(FILE_SIZE, out.length, "Wrong output size");
+            TestUtils.assertEqualContent(out, testFile);
+        }
+    }
+
+    @Test
+    public void testToByteArray_InputStream() throws Exception {
+        try (InputStream fin = Files.newInputStream(testFilePath)) {
+            final byte[] out = IOUtils.toByteArray(fin);
+            assertNotNull(out);
+            assertEquals(0, fin.available(), "Not all bytes were read");
+            assertEquals(FILE_SIZE, out.length, "Wrong output size");
+            TestUtils.assertEqualContent(out, testFile);
+        }
+    }
+
+    @Test
+    @Disabled("Disable by default as it uses too much memory and can cause builds to fail.")
+    public void testToByteArray_InputStream_LongerThanIntegerMaxValue() throws Exception {
+        final CircularInputStream cin = new CircularInputStream(IOUtils.byteArray(), Integer.MAX_VALUE + 1L);
+        assertThrows(IllegalArgumentException.class, () -> IOUtils.toByteArray(cin));
+    }
+
+    @Test
+    public void testToByteArray_InputStream_NegativeSize() throws Exception {
+
+        try (InputStream fin = Files.newInputStream(testFilePath)) {
+           final IllegalArgumentException exc = assertThrows(IllegalArgumentException.class,
+                   ()->IOUtils.toByteArray(fin, -1), "Should have failed with IllegalArgumentException" );
+            assertTrue(exc.getMessage().startsWith("Size must be equal or greater than zero"),
+                "Exception message does not start with \"Size must be equal or greater than zero\"");
+        }
+    }
+
+    @Test
+    public void testToByteArray_InputStream_Size() throws Exception {
+        try (InputStream fin = Files.newInputStream(testFilePath)) {
+            final byte[] out = IOUtils.toByteArray(fin, testFile.length());
+            assertNotNull(out);
+            assertEquals(0, fin.available(), "Not all bytes were read");
+            assertEquals(FILE_SIZE, out.length, "Wrong output size: out.length=" + out.length + "!=" + FILE_SIZE);
+            TestUtils.assertEqualContent(out, testFile);
+        }
+    }
+
+    @Test
+    public void testToByteArray_InputStream_SizeIllegal() throws Exception {
+
+        try (InputStream fin = Files.newInputStream(testFilePath)) {
+            final IOException exc = assertThrows(IOException.class,
+                    ()->IOUtils.toByteArray(fin, testFile.length() + 1), "Should have failed with IOException" );
+            assertTrue(exc.getMessage().startsWith("Unexpected read size"),
+                "Exception message does not start with \"Unexpected read size\"");
+        }
+    }
+
+    @Test
+    public void testToByteArray_InputStream_SizeLong() throws Exception {
+
+        try (InputStream fin = Files.newInputStream(testFilePath)) {
+            final IllegalArgumentException exc = assertThrows(IllegalArgumentException.class,
+                    ()-> IOUtils.toByteArray(fin, (long) Integer.MAX_VALUE + 1),
+                    "Should have failed with IllegalArgumentException" );
+            assertTrue(exc.getMessage().startsWith("Size cannot be greater than Integer max value"),
+                "Exception message does not start with \"Size cannot be greater than Integer max value\"");
+        }
+    }
+
+    @Test
+    public void testToByteArray_InputStream_SizeOne() throws Exception {
+
+        try (InputStream fin = Files.newInputStream(testFilePath)) {
+            final byte[] out = IOUtils.toByteArray(fin, 1);
+            assertNotNull(out, "Out cannot be null");
+            assertEquals(1, out.length, "Out length must be 1");
+        }
+    }
+
+    @Test
+    public void testToByteArray_InputStream_SizeZero() throws Exception {
+
+        try (InputStream fin =Files.newInputStream(testFilePath)) {
+            final byte[] out = IOUtils.toByteArray(fin, 0);
+            assertNotNull(out, "Out cannot be null");
+            assertEquals(0, out.length, "Out length must be 0");
+        }
+    }
+
+    @Test
+    public void testToByteArray_Reader() throws IOException {
+        final String charsetName = UTF_8;
+        final byte[] expecteds = charsetName.getBytes(charsetName);
+        byte[] actuals = IOUtils.toByteArray(new InputStreamReader(new ByteArrayInputStream(expecteds)));
+        assertArrayEquals(expecteds, actuals);
+        actuals = IOUtils.toByteArray(new InputStreamReader(new ByteArrayInputStream(expecteds)), charsetName);
+        assertArrayEquals(expecteds, actuals);
+    }
+
+    @Test
+    public void testToByteArray_String() throws Exception {
+        try (Reader fin = Files.newBufferedReader(testFilePath)) {
+            // Create our String. Rely on testReaderToString() to make sure this is valid.
+            final String str = IOUtils.toString(fin);
+
+            final byte[] out = IOUtils.toByteArray(str);
+            assertEqualContent(str.getBytes(), out);
+        }
+    }
+
+    @Test
+    public void testToByteArray_URI() throws Exception {
+        final URI url = testFile.toURI();
+        final byte[] actual = IOUtils.toByteArray(url);
+        assertEquals(FILE_SIZE, actual.length);
+    }
+
+    @Test
+    public void testToByteArray_URL() throws Exception {
+        final URL url = testFile.toURI().toURL();
+        final byte[] actual = IOUtils.toByteArray(url);
+        assertEquals(FILE_SIZE, actual.length);
+    }
+
+    @Test
+    public void testToByteArray_URLConnection() throws Exception {
+        final byte[] actual;
+        try (CloseableURLConnection urlConnection = CloseableURLConnection.open(testFile.toURI())) {
+            actual = IOUtils.toByteArray(urlConnection);
+        }
+        assertEquals(FILE_SIZE, actual.length);
+    }
+
+    @Test
+    public void testToCharArray_InputStream() throws Exception {
+        try (InputStream fin = Files.newInputStream(testFilePath)) {
+            final char[] out = IOUtils.toCharArray(fin);
+            assertNotNull(out);
+            assertEquals(0, fin.available(), "Not all chars were read");
+            assertEquals(FILE_SIZE, out.length, "Wrong output size");
+            TestUtils.assertEqualContent(out, testFile);
+        }
+    }
+
+    @Test
+    public void testToCharArray_InputStream_CharsetName() throws Exception {
+        try (InputStream fin = Files.newInputStream(testFilePath)) {
+            final char[] out = IOUtils.toCharArray(fin, UTF_8);
+            assertNotNull(out);
+            assertEquals(0, fin.available(), "Not all chars were read");
+            assertEquals(FILE_SIZE, out.length, "Wrong output size");
+            TestUtils.assertEqualContent(out, testFile);
+        }
+    }
+
+    @Test
+    public void testToCharArray_Reader() throws Exception {
+        try (Reader fr = Files.newBufferedReader(testFilePath)) {
+            final char[] out = IOUtils.toCharArray(fr);
+            assertNotNull(out);
+            assertEquals(FILE_SIZE, out.length, "Wrong output size");
+            TestUtils.assertEqualContent(out, testFile);
+        }
+    }
+
+    /**
+     * Test for {@link IOUtils#toInputStream(CharSequence)} and {@link IOUtils#toInputStream(CharSequence, String)}.
+     * Note, this test utilizes on {@link IOUtils#toByteArray(InputStream)} and so relies on
+     * {@link #testToByteArray_InputStream()} to ensure this method functions correctly.
+     *
+     * @throws Exception on error
+     */
+    @Test
+    public void testToInputStream_CharSequence() throws Exception {
+        final CharSequence csq = new StringBuilder("Abc123Xyz!");
+        InputStream inStream = IOUtils.toInputStream(csq); // deliberately testing deprecated method
+        byte[] bytes = IOUtils.toByteArray(inStream);
+        assertEqualContent(csq.toString().getBytes(), bytes);
+        inStream = IOUtils.toInputStream(csq, (String) null);
+        bytes = IOUtils.toByteArray(inStream);
+        assertEqualContent(csq.toString().getBytes(), bytes);
+        inStream = IOUtils.toInputStream(csq, UTF_8);
+        bytes = IOUtils.toByteArray(inStream);
+        assertEqualContent(csq.toString().getBytes(StandardCharsets.UTF_8), bytes);
+    }
+
+    /**
+     * Test for {@link IOUtils#toInputStream(String)} and {@link IOUtils#toInputStream(String, String)}. Note, this test
+     * utilizes on {@link IOUtils#toByteArray(InputStream)} and so relies on
+     * {@link #testToByteArray_InputStream()} to ensure this method functions correctly.
+     *
+     * @throws Exception on error
+     */
+    @Test
+    public void testToInputStream_String() throws Exception {
+        final String str = "Abc123Xyz!";
+        InputStream inStream = IOUtils.toInputStream(str);
+        byte[] bytes = IOUtils.toByteArray(inStream);
+        assertEqualContent(str.getBytes(), bytes);
+        inStream = IOUtils.toInputStream(str, (String) null);
+        bytes = IOUtils.toByteArray(inStream);
+        assertEqualContent(str.getBytes(), bytes);
+        inStream = IOUtils.toInputStream(str, UTF_8);
+        bytes = IOUtils.toByteArray(inStream);
+        assertEqualContent(str.getBytes(StandardCharsets.UTF_8), bytes);
+    }
+
+    @Test
+    public void testToString_ByteArray() throws Exception {
+        try (InputStream fin = Files.newInputStream(testFilePath)) {
+            final byte[] in = IOUtils.toByteArray(fin);
+            // Create our byte[]. Rely on testInputStreamToByteArray() to make sure this is valid.
+            final String str = IOUtils.toString(in);
+            assertEqualContent(in, str.getBytes());
+        }
+    }
+
+    @Test
+    public void testToString_InputStream() throws Exception {
+        try (InputStream fin = Files.newInputStream(testFilePath)) {
+            final String out = IOUtils.toString(fin);
+            assertNotNull(out);
+            assertEquals(0, fin.available(), "Not all bytes were read");
+            assertEquals(FILE_SIZE, out.length(), "Wrong output size");
+        }
+    }
+
+    @Test
+    public void testToString_Reader() throws Exception {
+        try (Reader fin = Files.newBufferedReader(testFilePath)) {
+            final String out = IOUtils.toString(fin);
+            assertNotNull(out);
+            assertEquals(FILE_SIZE, out.length(), "Wrong output size");
+        }
+    }
+
+    @Test
+    public void testToString_URI() throws Exception {
+        final URI url = testFile.toURI();
+        final String out = IOUtils.toString(url);
+        assertNotNull(out);
+        assertEquals(FILE_SIZE, out.length(), "Wrong output size");
+    }
+
+    private void testToString_URI(final String encoding) throws Exception {
+        final URI uri = testFile.toURI();
+        final String out = IOUtils.toString(uri, encoding);
+        assertNotNull(out);
+        assertEquals(FILE_SIZE, out.length(), "Wrong output size");
+    }
+
+    @Test
+    public void testToString_URI_CharsetName() throws Exception {
+        testToString_URI("US-ASCII");
+    }
+
+    @Test
+    public void testToString_URI_CharsetNameNull() throws Exception {
+        testToString_URI(null);
+    }
+
+    @Test
+    public void testToString_URL() throws Exception {
+        final URL url = testFile.toURI().toURL();
+        final String out = IOUtils.toString(url);
+        assertNotNull(out);
+        assertEquals(FILE_SIZE, out.length(), "Wrong output size");
+    }
+
+    private void testToString_URL(final String encoding) throws Exception {
+        final URL url = testFile.toURI().toURL();
+        final String out = IOUtils.toString(url, encoding);
+        assertNotNull(out);
+        assertEquals(FILE_SIZE, out.length(), "Wrong output size");
+    }
+
+    @Test
+    public void testToString_URL_CharsetName() throws Exception {
+        testToString_URL("US-ASCII");
+    }
+
+    @Test
+    public void testToString_URL_CharsetNameNull() throws Exception {
+        testToString_URL(null);
+    }
+
+    /**
+     * IO-764 IOUtils.write() throws NegativeArraySizeException while writing big strings.
+     * <pre>
+     * java.lang.OutOfMemoryError: Java heap space
+     *     at java.lang.StringCoding.encode(StringCoding.java:350)
+     *     at java.lang.String.getBytes(String.java:941)
+     *     at org.apache.commons.io.IOUtils.write(IOUtils.java:3367)
+     *     at org.apache.commons.io.IOUtilsTest.testBigString(IOUtilsTest.java:1659)
+     * </pre>
+     */
+    @Test
+    public void testWriteBigString() throws IOException {
+        // 3_000_000 is a size that we can allocate for the test string with Java 8 on the command line as:
+        // mvn clean test -Dtest=IOUtilsTest -DtestBigString=3000000
+        // 6_000_000 failed with the above
+        //
+        // TODO Can we mock the test string for this test to pretend to be larger?
+        // Mocking the length seems simple but how about the data?
+        final int repeat = Integer.getInteger("testBigString", 3_000_000);
+        final String data;
+        try {
+            data = StringUtils.repeat("\uD83D", repeat);
+        } catch (final OutOfMemoryError e) {
+            System.err.printf("Don't fail the test if we cannot build the fixture, just log, fixture size = %,d%n.", repeat);
+            e.printStackTrace();
+            return;
+        }
+        try (CountingOutputStream os = new CountingOutputStream(NullOutputStream.INSTANCE)) {
+            IOUtils.write(data, os, StandardCharsets.UTF_8);
+            assertEquals(repeat, os.getByteCount());
+        }
+    }
+
+    @Test
+    public void testWriteLittleString() throws IOException {
+        final String data = "\uD83D";
+        // White-box test to check that not closing the internal channel is not a problem.
+        for (int i = 0; i < 1_000_000; i++) {
+            try (CountingOutputStream os = new CountingOutputStream(NullOutputStream.INSTANCE)) {
+                IOUtils.write(data, os, StandardCharsets.UTF_8);
+                assertEquals(data.length(), os.getByteCount());
+            }
+        }
+    }
+
+    @Test
+    public void testByteArrayWithNegativeSize() {
+        assertThrows(NegativeArraySizeException.class, () -> IOUtils.byteArray(-1));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/IOUtilsWriteTest.java b/src/test/java/org/apache/commons/io/IOUtilsWriteTest.java
new file mode 100644
index 0000000..ded62ee
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/IOUtilsWriteTest.java
@@ -0,0 +1,708 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.io.output.ByteArrayOutputStream;
+import org.apache.commons.io.test.TestUtils;
+import org.apache.commons.io.test.ThrowOnFlushAndCloseOutputStream;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests IOUtils write methods.
+ *
+ * @see IOUtils
+ */
+@SuppressWarnings("deprecation") // includes tests for deprecated methods
+public class IOUtilsWriteTest {
+
+    private static final int FILE_SIZE = 1024 * 4 + 1;
+
+    private final byte[] inData = TestUtils.generateTestData(FILE_SIZE);
+
+    @Test
+    public void testWrite_byteArrayToOutputStream() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write(inData, out);
+        out.off();
+        out.flush();
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testWrite_byteArrayToOutputStream_nullData() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write((byte[]) null, out);
+        out.off();
+        out.flush();
+
+        assertEquals(0, baout.size(), "Sizes differ");
+    }
+
+    @Test
+    public void testWrite_byteArrayToOutputStream_nullStream() throws Exception {
+        assertThrows(NullPointerException.class, () -> IOUtils.write(inData, (OutputStream) null));
+    }
+
+    @Test
+    public void testWrite_byteArrayToWriter() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        @SuppressWarnings("resource") // deliberately not closed
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.write(inData, writer);
+        out.off();
+        writer.flush();
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testWrite_byteArrayToWriter_Encoding() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        @SuppressWarnings("resource") // deliberately not closed
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.write(inData, writer, "UTF8");
+        out.off();
+        writer.flush();
+
+        byte[] bytes = baout.toByteArray();
+        bytes = new String(bytes, StandardCharsets.UTF_8).getBytes(StandardCharsets.US_ASCII);
+        assertArrayEquals(inData, bytes, "Content differs");
+    }
+
+    @Test
+    public void testWrite_byteArrayToWriter_Encoding_nullData() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        @SuppressWarnings("resource") // deliberately not closed
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.write(null, writer, "UTF8");
+        out.off();
+        writer.flush();
+
+        assertEquals(0, baout.size(), "Sizes differ");
+    }
+
+    @Test
+    public void testWrite_byteArrayToWriter_Encoding_nullEncoding() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        @SuppressWarnings("resource") // deliberately not closed
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.write(inData, writer, (String) null);
+        out.off();
+        writer.flush();
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testWrite_byteArrayToWriter_Encoding_nullWriter() throws Exception {
+        assertThrows(NullPointerException.class, () -> IOUtils.write(inData, null, "UTF8"));
+    }
+
+    @Test
+    public void testWrite_byteArrayToWriter_nullData() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        @SuppressWarnings("resource") // deliberately not closed
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.write((byte[]) null, writer);
+        out.off();
+        writer.flush();
+
+        assertEquals(0, baout.size(), "Sizes differ");
+    }
+
+    @Test
+    public void testWrite_byteArrayToWriter_nullWriter() throws Exception {
+        assertThrows(NullPointerException.class, () -> IOUtils.write(inData, (Writer) null));
+    }
+
+    @Test
+    public void testWrite_charArrayToOutputStream() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write(str.toCharArray(), out);
+        out.off();
+        out.flush();
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testWrite_charArrayToOutputStream_Encoding() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write(str.toCharArray(), out, "UTF16");
+        out.off();
+        out.flush();
+
+        byte[] bytes = baout.toByteArray();
+        bytes = new String(bytes, StandardCharsets.UTF_16).getBytes(StandardCharsets.US_ASCII);
+        assertArrayEquals(inData, bytes, "Content differs");
+    }
+
+    @Test
+    public void testWrite_charArrayToOutputStream_Encoding_nullData() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write((char[]) null, out);
+        out.off();
+        out.flush();
+
+        assertEquals(0, baout.size(), "Sizes differ");
+    }
+
+    @Test
+    public void testWrite_charArrayToOutputStream_Encoding_nullStream() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+        assertThrows(NullPointerException.class, () -> IOUtils.write(str.toCharArray(), (OutputStream) null));
+    }
+
+    @Test
+    public void testWrite_charArrayToOutputStream_nullData() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write((char[]) null, out);
+        out.off();
+        out.flush();
+
+        assertEquals(0, baout.size(), "Sizes differ");
+    }
+
+    @Test
+    public void testWrite_charArrayToOutputStream_nullEncoding() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write(str.toCharArray(), out, (String) null);
+        out.off();
+        out.flush();
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testWrite_charArrayToOutputStream_nullStream() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+        assertThrows(NullPointerException.class, () -> IOUtils.write(str.toCharArray(), (OutputStream) null));
+    }
+
+    @Test
+    public void testWrite_charArrayToWriter() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        @SuppressWarnings("resource") // deliberately not closed
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.write(str.toCharArray(), writer);
+        out.off();
+        writer.flush();
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testWrite_charArrayToWriter_Encoding_nullData() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        @SuppressWarnings("resource") // deliberately not closed
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.write((char[]) null, writer);
+        out.off();
+        writer.flush();
+
+        assertEquals(0, baout.size(), "Sizes differ");
+    }
+
+    @Test
+    public void testWrite_charArrayToWriter_Encoding_nullStream() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+        assertThrows(NullPointerException.class, () -> IOUtils.write(str.toCharArray(), (Writer) null));
+    }
+
+    @Test
+    public void testWrite_charSequenceToOutputStream() throws Exception {
+        final CharSequence csq = new StringBuilder(new String(inData, StandardCharsets.US_ASCII));
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write(csq, out);
+        out.off();
+        out.flush();
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testWrite_charSequenceToOutputStream_Encoding() throws Exception {
+        final CharSequence csq = new StringBuilder(new String(inData, StandardCharsets.US_ASCII));
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write(csq, out, "UTF16");
+        out.off();
+        out.flush();
+
+        byte[] bytes = baout.toByteArray();
+        bytes = new String(bytes, StandardCharsets.UTF_16).getBytes(StandardCharsets.US_ASCII);
+        assertArrayEquals(inData, bytes, "Content differs");
+    }
+
+    @Test
+    public void testWrite_charSequenceToOutputStream_Encoding_nullData() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write((CharSequence) null, out);
+        out.off();
+        out.flush();
+
+        assertEquals(0, baout.size(), "Sizes differ");
+    }
+
+    @Test
+    public void testWrite_charSequenceToOutputStream_Encoding_nullStream() throws Exception {
+        final CharSequence csq = new StringBuilder(new String(inData, StandardCharsets.US_ASCII));
+        assertThrows(NullPointerException.class, () -> IOUtils.write(csq, (OutputStream) null));
+    }
+
+    @Test
+    public void testWrite_charSequenceToOutputStream_nullData() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write((CharSequence) null, out);
+        out.off();
+        out.flush();
+
+        assertEquals(0, baout.size(), "Sizes differ");
+    }
+
+    @Test
+    public void testWrite_charSequenceToOutputStream_nullEncoding() throws Exception {
+        final CharSequence csq = new StringBuilder(new String(inData, StandardCharsets.US_ASCII));
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write(csq, out, (String) null);
+        out.off();
+        out.flush();
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testWrite_charSequenceToOutputStream_nullStream() throws Exception {
+        final CharSequence csq = new StringBuilder(new String(inData, StandardCharsets.US_ASCII));
+        assertThrows(NullPointerException.class, () -> IOUtils.write(csq, (OutputStream) null));
+    }
+
+    @Test
+    public void testWrite_charSequenceToWriter() throws Exception {
+        final CharSequence csq = new StringBuilder(new String(inData, StandardCharsets.US_ASCII));
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        @SuppressWarnings("resource") // deliberately not closed
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.write(csq, writer);
+        out.off();
+        writer.flush();
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testWrite_charSequenceToWriter_Encoding_nullData() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        @SuppressWarnings("resource") // deliberately not closed
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.write((CharSequence) null, writer);
+        out.off();
+        writer.flush();
+
+        assertEquals(0, baout.size(), "Sizes differ");
+    }
+
+    @Test
+    public void testWrite_charSequenceToWriter_Encoding_nullStream() throws Exception {
+        final CharSequence csq = new StringBuilder(new String(inData, StandardCharsets.US_ASCII));
+        assertThrows(NullPointerException.class, () -> IOUtils.write(csq, (Writer) null));
+    }
+
+    @Test
+    public void testWrite_stringToOutputStream() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write(str, out);
+        out.off();
+        out.flush();
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testWrite_stringToOutputStream_Encoding() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write(str, out, "UTF16");
+        out.off();
+        out.flush();
+
+        byte[] bytes = baout.toByteArray();
+        bytes = new String(bytes, StandardCharsets.UTF_16).getBytes(StandardCharsets.US_ASCII);
+        assertArrayEquals(inData, bytes, "Content differs");
+    }
+
+    @Test
+    public void testWrite_stringToOutputStream_Encoding_nullData() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write((String) null, out);
+        out.off();
+        out.flush();
+
+        assertEquals(0, baout.size(), "Sizes differ");
+    }
+
+    @Test
+    public void testWrite_stringToOutputStream_Encoding_nullStream() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+        assertThrows(NullPointerException.class, () -> IOUtils.write(str, (OutputStream) null));
+    }
+
+    @Test
+    public void testWrite_stringToOutputStream_nullData() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write((String) null, out);
+        out.off();
+        out.flush();
+
+        assertEquals(0, baout.size(), "Sizes differ");
+    }
+
+    @Test
+    public void testWrite_stringToOutputStream_nullEncoding() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+
+        IOUtils.write(str, out, (String) null);
+        out.off();
+        out.flush();
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testWrite_stringToOutputStream_nullStream() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+        assertThrows(NullPointerException.class, () -> IOUtils.write(str, (OutputStream) null));
+    }
+
+    @Test
+    public void testWrite_stringToWriter() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        @SuppressWarnings("resource") // deliberately not closed
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.write(str, writer);
+        out.off();
+        writer.flush();
+
+        assertEquals(inData.length, baout.size(), "Sizes differ");
+        assertArrayEquals(inData, baout.toByteArray(), "Content differs");
+    }
+
+    @Test
+    public void testWrite_stringToWriter_Encoding_nullData() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        @SuppressWarnings("resource") // deliberately not closed
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.write((String) null, writer);
+        out.off();
+        writer.flush();
+
+        assertEquals(0, baout.size(), "Sizes differ");
+    }
+
+    @Test
+    public void testWrite_stringToWriter_Encoding_nullStream() throws Exception {
+        final String str = new String(inData, StandardCharsets.US_ASCII);
+        assertThrows(NullPointerException.class, () -> IOUtils.write(str, (Writer) null));
+    }
+
+    @Test
+    public void testWriteLines_OutputStream() throws Exception {
+        final Object[] data = {
+                "hello", new StringBuffer("world"), "", "this is", null, "some text"};
+        final List<Object> list = Arrays.asList(data);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        IOUtils.writeLines(list, "*", out);
+
+        out.off();
+        out.flush();
+
+        final String expected = "hello*world**this is**some text*";
+        final String actual = baout.toString();
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLines_OutputStream_Encoding() throws Exception {
+        final Object[] data = {
+                "hello\u8364", new StringBuffer("world"), "", "this is", null, "some text"};
+        final List<Object> list = Arrays.asList(data);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        IOUtils.writeLines(list, "*", out, StandardCharsets.UTF_8.name());
+
+        out.off();
+        out.flush();
+
+        final String expected = "hello\u8364*world**this is**some text*";
+        final String actual = baout.toString(StandardCharsets.UTF_8.name());
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLines_OutputStream_Encoding_nullData() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        IOUtils.writeLines(null, "*", out, "US-ASCII");
+        out.off();
+        out.flush();
+
+        assertEquals(0, baout.size(), "Sizes differ");
+    }
+
+    @Test
+    public void testWriteLines_OutputStream_Encoding_nullEncoding() throws Exception {
+        final Object[] data = {
+                "hello", new StringBuffer("world"), "", "this is", null, "some text"};
+        final List<Object> list = Arrays.asList(data);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        IOUtils.writeLines(list, "*", out, (String) null);
+
+        out.off();
+        out.flush();
+
+        final String expected = "hello*world**this is**some text*";
+        final String actual = baout.toString();
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLines_OutputStream_Encoding_nullSeparator() throws Exception {
+        final Object[] data = {"hello", "world"};
+        final List<Object> list = Arrays.asList(data);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        IOUtils.writeLines(list, null, out, "US-ASCII");
+        out.off();
+        out.flush();
+
+        final String expected = "hello" + System.lineSeparator() + "world" + System.lineSeparator();
+        final String actual = baout.toString();
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLines_OutputStream_Encoding_nullStream() throws Exception {
+        final Object[] data = {"hello", "world"};
+        final List<Object> list = Arrays.asList(data);
+        assertThrows(NullPointerException.class, () -> IOUtils.writeLines(list, "*", null, "US-ASCII"));
+    }
+
+    @Test
+    public void testWriteLines_OutputStream_nullData() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        IOUtils.writeLines(null, "*", out);
+        out.off();
+        out.flush();
+
+        assertEquals(0, baout.size(), "Sizes differ");
+    }
+
+    @Test
+    public void testWriteLines_OutputStream_nullSeparator() throws Exception {
+        final Object[] data = {"hello", "world"};
+        final List<Object> list = Arrays.asList(data);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, false, true);
+
+        IOUtils.writeLines(list, null, out);
+        out.off();
+        out.flush();
+
+        final String expected = "hello" + System.lineSeparator() + "world" + System.lineSeparator();
+        final String actual = baout.toString();
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLines_OutputStream_nullStream() throws Exception {
+        final Object[] data = {"hello", "world"};
+        final List<Object> list = Arrays.asList(data);
+        assertThrows(NullPointerException.class, () -> IOUtils.writeLines(list, "*", (OutputStream) null));
+    }
+
+    @Test
+    public void testWriteLines_Writer() throws Exception {
+        final Object[] data = {
+                "hello", new StringBuffer("world"), "", "this is", null, "some text"};
+        final List<Object> list = Arrays.asList(data);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        @SuppressWarnings("resource") // deliberately not closed
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.writeLines(list, "*", writer);
+
+        out.off();
+        writer.flush();
+
+        final String expected = "hello*world**this is**some text*";
+        final String actual = baout.toString();
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLines_Writer_nullData() throws Exception {
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        @SuppressWarnings("resource") // deliberately not closed
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.writeLines(null, "*", writer);
+        out.off();
+        writer.flush();
+
+        assertEquals(0, baout.size(), "Sizes differ");
+    }
+
+    @Test
+    public void testWriteLines_Writer_nullSeparator() throws Exception {
+        final Object[] data = {"hello", "world"};
+        final List<Object> list = Arrays.asList(data);
+
+        final ByteArrayOutputStream baout = new ByteArrayOutputStream();
+        @SuppressWarnings("resource") // deliberately not closed
+        final ThrowOnFlushAndCloseOutputStream out = new ThrowOnFlushAndCloseOutputStream(baout, true, true);
+        final Writer writer = new OutputStreamWriter(baout, StandardCharsets.US_ASCII);
+
+        IOUtils.writeLines(list, null, writer);
+        out.off();
+        writer.flush();
+
+        final String expected = "hello" + System.lineSeparator() + "world" + System.lineSeparator();
+        final String actual = baout.toString();
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testWriteLines_Writer_nullStream() throws Exception {
+        final Object[] data = {"hello", "world"};
+        final List<Object> list = Arrays.asList(data);
+        assertThrows(NullPointerException.class, () -> IOUtils.writeLines(list, "*", (Writer) null));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/LineIteratorTest.java b/src/test/java/org/apache/commons/io/LineIteratorTest.java
new file mode 100644
index 0000000..70fd90e
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/LineIteratorTest.java
@@ -0,0 +1,336 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.charset.UnsupportedCharsetException;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Tests {@link LineIterator}.
+ */
+public class LineIteratorTest {
+
+    private static final String UTF_8 = StandardCharsets.UTF_8.name();
+
+    @TempDir
+    public File temporaryFolder;
+
+    private void assertLines(final List<String> lines, final LineIterator iterator) {
+        try {
+            for (int i = 0; i < lines.size(); i++) {
+                final String line = iterator.nextLine();
+                assertEquals(lines.get(i), line, "nextLine() line " + i);
+            }
+            assertFalse(iterator.hasNext(), "No more expected");
+        } finally {
+            IOUtils.closeQuietly(iterator);
+        }
+    }
+
+    /**
+     * Creates a test file with a specified number of lines.
+     *
+     * @param file target file
+     * @param lineCount number of lines to create
+     *
+     * @throws IOException If an I/O error occurs
+     */
+    private List<String> createLinesFile(final File file, final int lineCount) throws IOException {
+        final List<String> lines = createStringLines(lineCount);
+        FileUtils.writeLines(file, lines);
+        return lines;
+    }
+
+    /**
+     * Creates a test file with a specified number of lines.
+     *
+     * @param file target file
+     * @param encoding the encoding to use while writing the lines
+     * @param lineCount number of lines to create
+     *
+     * @throws IOException If an I/O error occurs
+     */
+    private List<String> createLinesFile(final File file, final String encoding, final int lineCount) throws IOException {
+        final List<String> lines = createStringLines(lineCount);
+        FileUtils.writeLines(file, encoding, lines);
+        return lines;
+    }
+
+    /**
+     * Creates String data lines.
+     *
+     * @param lineCount number of lines to create
+     * @return a new lines list.
+     */
+    private List<String> createStringLines(final int lineCount) {
+        final List<String> lines = new ArrayList<>();
+        for (int i = 0; i < lineCount; i++) {
+            lines.add("LINE " + i);
+        }
+        return lines;
+    }
+
+    /**
+     * Utility method to create and test a file with a specified number of lines.
+     *
+     * @param lineCount the lines to create in the test file
+     *
+     * @throws IOException If an I/O error occurs while creating the file
+     */
+    private void doTestFileWithSpecifiedLines(final int lineCount) throws IOException {
+        final String encoding = UTF_8;
+
+        final String fileName = "LineIterator-" + lineCount + "-test.txt";
+        final File testFile = new File(temporaryFolder, fileName);
+        final List<String> lines = createLinesFile(testFile, encoding, lineCount);
+
+        try (LineIterator iterator = FileUtils.lineIterator(testFile, encoding)) {
+            assertThrows(UnsupportedOperationException.class, iterator::remove);
+
+            int idx = 0;
+            while (iterator.hasNext()) {
+                final String line = iterator.next();
+                assertEquals(lines.get(idx), line, "Comparing line " + idx);
+                assertTrue(idx < lines.size(), "Exceeded expected idx=" + idx + " size=" + lines.size());
+                idx++;
+            }
+            assertEquals(idx, lines.size(), "Line Count doesn't match");
+
+            // try calling next() after file processed
+            assertThrows(NoSuchElementException.class, iterator::next);
+            assertThrows(NoSuchElementException.class, iterator::nextLine);
+        }
+    }
+
+    @Test
+    public void testCloseEarly() throws Exception {
+        final String encoding = UTF_8;
+
+        final File testFile = new File(temporaryFolder, "LineIterator-closeEarly.txt");
+        createLinesFile(testFile, encoding, 3);
+
+        try (LineIterator iterator = FileUtils.lineIterator(testFile, encoding)) {
+            // get
+            assertNotNull("Line expected", iterator.next());
+            assertTrue(iterator.hasNext(), "More expected");
+
+            // close
+            iterator.close();
+            assertFalse(iterator.hasNext(), "No more expected");
+            assertThrows(NoSuchElementException.class, iterator::next);
+            assertThrows(NoSuchElementException.class, iterator::nextLine);
+            // try closing again
+            iterator.close();
+            assertThrows(NoSuchElementException.class, iterator::next);
+            assertThrows(NoSuchElementException.class, iterator::nextLine);
+        }
+    }
+
+    @Test
+    public void testConstructor() {
+        assertThrows(NullPointerException.class, () -> new LineIterator(null));
+    }
+
+    private void testFiltering(final List<String> lines, final Reader reader) throws IOException {
+        try (LineIterator iterator = new LineIterator(reader) {
+            @Override
+            protected boolean isValidLine(final String line) {
+                final char c = line.charAt(line.length() - 1);
+                return (c - 48) % 3 != 1;
+            }
+        }) {
+            assertThrows(UnsupportedOperationException.class, iterator::remove);
+
+            int idx = 0;
+            int actualLines = 0;
+            while (iterator.hasNext()) {
+                final String line = iterator.next();
+                actualLines++;
+                assertEquals(lines.get(idx), line, "Comparing line " + idx);
+                assertTrue(idx < lines.size(), "Exceeded expected idx=" + idx + " size=" + lines.size());
+                idx++;
+                if (idx % 3 == 1) {
+                    idx++;
+                }
+            }
+            assertEquals(9, lines.size(), "Line Count doesn't match");
+            assertEquals(9, idx, "Line Count doesn't match");
+            assertEquals(6, actualLines, "Line Count doesn't match");
+
+            // try calling next() after file processed
+            assertThrows(NoSuchElementException.class, iterator::next);
+            assertThrows(NoSuchElementException.class, iterator::nextLine);
+        }
+    }
+
+    @Test
+    public void testFilteringBufferedReader() throws Exception {
+        final String encoding = UTF_8;
+
+        final String fileName = "LineIterator-Filter-test.txt";
+        final File testFile = new File(temporaryFolder, fileName);
+        final List<String> lines = createLinesFile(testFile, encoding, 9);
+
+        final Reader reader = new BufferedReader(Files.newBufferedReader(testFile.toPath()));
+        this.testFiltering(lines, reader);
+    }
+
+    @Test
+    public void testFilteringFileReader() throws Exception {
+        final String encoding = UTF_8;
+
+        final String fileName = "LineIterator-Filter-test.txt";
+        final File testFile = new File(temporaryFolder, fileName);
+        final List<String> lines = createLinesFile(testFile, encoding, 9);
+
+        final Reader reader = Files.newBufferedReader(testFile.toPath());
+        this.testFiltering(lines, reader);
+    }
+
+    @Test
+    public void testInvalidEncoding() throws Exception {
+        final String encoding = "XXXXXXXX";
+
+        final File testFile = new File(temporaryFolder, "LineIterator-invalidEncoding.txt");
+        createLinesFile(testFile, UTF_8, 3);
+
+        assertThrows(UnsupportedCharsetException.class, () -> FileUtils.lineIterator(testFile, encoding));
+    }
+
+    @Test
+    public void testMissingFile() throws Exception {
+        final File testFile = new File(temporaryFolder, "dummy-missing-file.txt");
+        assertThrows(NoSuchFileException.class, () -> FileUtils.lineIterator(testFile, UTF_8));
+    }
+
+    @Test
+    public void testNextLineOnlyDefaultEncoding() throws Exception {
+        final File testFile = new File(temporaryFolder, "LineIterator-nextOnly.txt");
+        final List<String> lines = createLinesFile(testFile, 3);
+
+        final LineIterator iterator = FileUtils.lineIterator(testFile);
+        assertLines(lines, iterator);
+    }
+
+    @Test
+    public void testNextLineOnlyNullEncoding() throws Exception {
+        final String encoding = null;
+
+        final File testFile = new File(temporaryFolder, "LineIterator-nextOnly.txt");
+        final List<String> lines = createLinesFile(testFile, encoding, 3);
+
+        final LineIterator iterator = FileUtils.lineIterator(testFile, encoding);
+        assertLines(lines, iterator);
+    }
+
+    @Test
+    public void testNextLineOnlyUtf8Encoding() throws Exception {
+        final String encoding = UTF_8;
+
+        final File testFile = new File(temporaryFolder, "LineIterator-nextOnly.txt");
+        final List<String> lines = createLinesFile(testFile, encoding, 3);
+
+        final LineIterator iterator = FileUtils.lineIterator(testFile, encoding);
+        assertLines(lines, iterator);
+    }
+
+    @Test
+    public void testNextOnly() throws Exception {
+        final String encoding = null;
+
+        final File testFile = new File(temporaryFolder, "LineIterator-nextOnly.txt");
+        final List<String> lines = createLinesFile(testFile, encoding, 3);
+
+        try (LineIterator iterator = FileUtils.lineIterator(testFile, encoding)) {
+            for (int i = 0; i < lines.size(); i++) {
+                final String line = iterator.next();
+                assertEquals(lines.get(i), line, "next() line " + i);
+            }
+            assertFalse(iterator.hasNext(), "No more expected");
+        }
+    }
+
+    @Test
+    public void testNextWithException() throws Exception {
+        final Reader reader = new BufferedReader(new StringReader("")) {
+            @Override
+            public String readLine() throws IOException {
+                throw new IOException("hasNext");
+            }
+        };
+        try (LineIterator li = new LineIterator(reader)) {
+            assertThrows(IllegalStateException.class, li::hasNext);
+        }
+    }
+
+    @Test
+    public void testOneLines() throws Exception {
+        doTestFileWithSpecifiedLines(1);
+    }
+
+    @Test
+    public void testThreeLines() throws Exception {
+        doTestFileWithSpecifiedLines(3);
+    }
+
+    @Test
+    public void testTwoLines() throws Exception {
+        doTestFileWithSpecifiedLines(2);
+    }
+
+    @Test
+    public void testValidEncoding() throws Exception {
+        final String encoding = UTF_8;
+
+        final File testFile = new File(temporaryFolder, "LineIterator-validEncoding.txt");
+        createLinesFile(testFile, encoding, 3);
+
+        try (LineIterator iterator = FileUtils.lineIterator(testFile, encoding)) {
+            int count = 0;
+            while (iterator.hasNext()) {
+                assertNotNull(iterator.next());
+                count++;
+            }
+            assertEquals(3, count);
+        }
+    }
+
+    @Test
+    public void testZeroLines() throws Exception {
+        doTestFileWithSpecifiedLines(0);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/SelectorAdapter.java b/src/test/java/org/apache/commons/io/SelectorAdapter.java
new file mode 100644
index 0000000..2008908
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/SelectorAdapter.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.IOException;
+import java.nio.channels.SelectionKey;
+import java.nio.channels.Selector;
+import java.nio.channels.spi.SelectorProvider;
+import java.util.Set;
+
+/**
+ * Extends {@link Selector} with no-ops for testing.
+ *
+ */
+public class SelectorAdapter extends Selector {
+
+    @Override
+    public void close() throws IOException {
+    }
+
+    @Override
+    public boolean isOpen() {
+        return false;
+    }
+
+    @Override
+    public Set<SelectionKey> keys() {
+        return null;
+    }
+
+    @Override
+    public SelectorProvider provider() {
+        return null;
+    }
+
+    @Override
+    public int select() throws IOException {
+        return 0;
+    }
+
+    @Override
+    public int select(final long timeout) throws IOException {
+        return 0;
+    }
+
+    @Override
+    public Set<SelectionKey> selectedKeys() {
+        return null;
+    }
+
+    @Override
+    public int selectNow() throws IOException {
+        return 0;
+    }
+
+    @Override
+    public Selector wakeup() {
+        return null;
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/StandardLineSeparatorTest.java b/src/test/java/org/apache/commons/io/StandardLineSeparatorTest.java
new file mode 100644
index 0000000..f37562d
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/StandardLineSeparatorTest.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io;
+
+import static org.apache.commons.io.StandardLineSeparator.CR;
+import static org.apache.commons.io.StandardLineSeparator.CRLF;
+import static org.apache.commons.io.StandardLineSeparator.LF;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link StandardLineSeparator}.
+ */
+public class StandardLineSeparatorTest {
+
+    @Test
+    public void testCR() {
+        assertEquals("\r", CR.getString());
+    }
+
+    @Test
+    public void testCR_getBytes() {
+        assertArrayEquals("\r".getBytes(StandardCharsets.ISO_8859_1), CR.getBytes(StandardCharsets.ISO_8859_1));
+    }
+
+    @Test
+    public void testCRLF() {
+        assertEquals("\r\n", CRLF.getString());
+    }
+
+    @Test
+    public void testCRLF_getBytes() {
+        assertArrayEquals("\r\n".getBytes(StandardCharsets.ISO_8859_1), CRLF.getBytes(StandardCharsets.ISO_8859_1));
+    }
+
+    @Test
+    public void testLF() {
+        assertEquals("\n", LF.getString());
+    }
+
+    @Test
+    public void testLF_getBytes() {
+        assertArrayEquals("\n".getBytes(StandardCharsets.ISO_8859_1), LF.getBytes(StandardCharsets.ISO_8859_1));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/TaggedIOExceptionTest.java b/src/test/java/org/apache/commons/io/TaggedIOExceptionTest.java
new file mode 100644
index 0000000..a009d43
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/TaggedIOExceptionTest.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link TaggedIOException}.
+ */
+public class TaggedIOExceptionTest {
+
+    @Test
+    public void testTaggedIOException() {
+        final Serializable tag = UUID.randomUUID();
+        final IOException exception = new IOException("Test exception");
+        final TaggedIOException tagged = new TaggedIOException(exception, tag);
+        assertTrue(TaggedIOException.isTaggedWith(tagged, tag));
+        assertFalse(TaggedIOException.isTaggedWith(tagged, UUID.randomUUID()));
+        assertEquals(exception, tagged.getCause());
+        assertEquals(exception.getMessage(), tagged.getMessage());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/TestResources.java b/src/test/java/org/apache/commons/io/TestResources.java
new file mode 100644
index 0000000..7753d91
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/TestResources.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import java.io.File;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/**
+ * Provides access to this package's test resources.
+ */
+public class TestResources {
+
+    private static final String ROOT = "/org/apache/commons/io/";
+
+    public static File getFile(final String fileName) throws URISyntaxException {
+        return new File(getURI(fileName));
+    }
+
+    public static InputStream getInputStream(final String fileName) {
+        return TestResources.class.getResourceAsStream(ROOT + fileName);
+    }
+
+    public static Path getPath(final String fileName) throws URISyntaxException {
+        return Paths.get(getURI(fileName));
+    }
+
+    public static URI getURI(final String fileName) throws URISyntaxException {
+        return getURL(fileName).toURI();
+    }
+
+    public static URL getURL(final String fileName) {
+        return TestResources.class.getResource(ROOT + fileName);
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/ThreadMonitorTest.java b/src/test/java/org/apache/commons/io/ThreadMonitorTest.java
new file mode 100644
index 0000000..064a43f
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/ThreadMonitorTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.time.Duration;
+
+import org.apache.commons.io.test.TestUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link ThreadMonitor}.
+ */
+public class ThreadMonitorTest {
+
+    /**
+     * Test task completed before timeout.
+     */
+    @Test
+    public void testCompletedWithoutTimeout() {
+        try {
+            final Thread monitor = ThreadMonitor.start(Duration.ofMillis(400));
+            TestUtils.sleep(1);
+            ThreadMonitor.stop(monitor);
+        } catch (final InterruptedException e) {
+            fail("Timed Out", e);
+        }
+    }
+
+    /**
+     * Test No timeout.
+     */
+    @Test
+    public void testNoTimeoutMinus1() {
+        // timeout = -1
+        try {
+            final Thread monitor = ThreadMonitor.start(Duration.ofMillis(-1));
+            assertNull(monitor, "Timeout -1, Monitor should be null");
+            TestUtils.sleep(100);
+            ThreadMonitor.stop(monitor);
+        } catch (final Exception e) {
+            fail("Timeout -1, threw " + e, e);
+        }
+    }
+
+    /**
+     * Test No timeout.
+     */
+    @Test
+    public void testNoTimeoutZero() {
+        // timeout = 0
+        try {
+            final Thread monitor = ThreadMonitor.start(Duration.ZERO);
+            assertNull(monitor, "Timeout 0, Monitor should be null");
+            TestUtils.sleep(100);
+            ThreadMonitor.stop(monitor);
+        } catch (final Exception e) {
+            fail("Timeout 0, threw " + e, e);
+        }
+    }
+
+    /**
+     * Test timeout.
+     */
+    @Test
+    public void testTimeout() {
+        assertThrows(InterruptedException.class, () -> {
+            final Thread monitor = ThreadMonitor.start(Duration.ofMillis(100));
+            TestUtils.sleep(400);
+            ThreadMonitor.stop(monitor);
+        });
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/UncheckedIOExceptionsTest.java b/src/test/java/org/apache/commons/io/UncheckedIOExceptionsTest.java
new file mode 100644
index 0000000..7eaf23f
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/UncheckedIOExceptionsTest.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+
+import org.apache.commons.io.function.Uncheck;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link Uncheck}.
+ */
+public class UncheckedIOExceptionsTest {
+
+    /**
+     * Tests {@link UncheckedIOExceptions#create(Object)}.
+     */
+    @Test
+    public void testCreate() {
+        final Object message = "test";
+        try {
+            throw UncheckedIOExceptions.create(message);
+        } catch (final UncheckedIOException e) {
+            assertEquals(message, e.getMessage());
+            assertEquals(message, e.getCause().getMessage());
+        }
+    }
+
+    /**
+     * Tests {@link UncheckedIOExceptions#wrap(IOException, Object)}.
+     */
+    @Test
+    public void testWrap() {
+        final Object message1 = "test1";
+        final Object message2 = "test2";
+        try {
+            throw UncheckedIOExceptions.wrap(new IOException(message2.toString()), message1);
+        } catch (final UncheckedIOException e) {
+            assertEquals(message1, e.getMessage());
+            assertEquals(message2, e.getCause().getMessage());
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/UncheckedIOTest.java b/src/test/java/org/apache/commons/io/UncheckedIOTest.java
new file mode 100644
index 0000000..38aae53
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/UncheckedIOTest.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayInputStream;
+
+import org.apache.commons.io.function.IOBiFunction;
+import org.apache.commons.io.function.IOConsumer;
+import org.apache.commons.io.function.IOFunction;
+import org.apache.commons.io.function.IORunnable;
+import org.apache.commons.io.function.IOSupplier;
+import org.apache.commons.io.function.IOTriFunction;
+import org.apache.commons.io.function.Uncheck;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link Uncheck}.
+ */
+public class UncheckedIOTest {
+
+    private static final byte[] BYTES = {'a', 'b'};
+
+    private ByteArrayInputStream newInputStream() {
+        return new ByteArrayInputStream(BYTES);
+    }
+
+    /**
+     * Tests {@link Uncheck#accept(IOConsumer, Object)}.
+     */
+    @Test
+    public void testAccept() {
+        final ByteArrayInputStream stream = newInputStream();
+        Uncheck.accept(n -> stream.skip(n), 1);
+        assertEquals('b', Uncheck.get(stream::read).intValue());
+    }
+
+    /**
+     * Tests {@link Uncheck#apply(IOFunction, Object)}.
+     */
+    @Test
+    public void testApply1() {
+        final ByteArrayInputStream stream = newInputStream();
+        assertEquals(1, Uncheck.apply(n -> stream.skip(n), 1).intValue());
+        assertEquals('b', Uncheck.get(stream::read).intValue());
+    }
+
+    /**
+     * Tests {@link Uncheck#apply(IOBiFunction, Object, Object)}.
+     */
+    @Test
+    public void testApply2() {
+        final ByteArrayInputStream stream = newInputStream();
+        final byte[] buf = new byte[BYTES.length];
+        assertEquals(1, Uncheck.apply((o, l) -> stream.read(buf, o, l), 0, 1).intValue());
+        assertEquals('a', buf[0]);
+    }
+
+    /**
+     * Tests {@link Uncheck#apply(IOTriFunction, Object, Object, Object)}.
+     */
+    @Test
+    public void testApply3() {
+        final ByteArrayInputStream stream = newInputStream();
+        final byte[] buf = new byte[BYTES.length];
+        assertEquals(1, Uncheck.apply((b, o, l) -> stream.read(b, o, l), buf, 0, 1).intValue());
+        assertEquals('a', buf[0]);
+    }
+
+    /**
+     * Tests {@link Uncheck#get(IOSupplier)}.
+     */
+    @Test
+    public void testGet() {
+        assertEquals('a', Uncheck.get(() -> newInputStream().read()).intValue());
+    }
+
+    /**
+     * Tests {@link Uncheck#run(IORunnable)}.
+     */
+    @Test
+    public void testRun() {
+        final ByteArrayInputStream stream = newInputStream();
+        Uncheck.run(() -> stream.skip(1));
+        assertEquals('b', Uncheck.get(stream::read).intValue());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/charset/CharsetDecodersTest.java b/src/test/java/org/apache/commons/io/charset/CharsetDecodersTest.java
new file mode 100644
index 0000000..ec7c1d6
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/charset/CharsetDecodersTest.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.charset;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link CharsetDecoders}.
+ */
+public class CharsetDecodersTest {
+
+    @Test
+    public void testToCharsetDecoders_default() {
+        final CharsetDecoder charsetEncoder = CharsetDecoders.toCharsetDecoder(Charset.defaultCharset().newDecoder());
+        assertNotNull(charsetEncoder);
+        assertEquals(Charset.defaultCharset(), charsetEncoder.charset());
+    }
+
+    @Test
+    public void testToCharsetDecoders_ISO_8859_1() {
+        final CharsetDecoder charsetEncoder = CharsetDecoders.toCharsetDecoder(StandardCharsets.ISO_8859_1.newDecoder());
+        assertNotNull(charsetEncoder);
+        assertEquals(StandardCharsets.ISO_8859_1, charsetEncoder.charset());
+    }
+
+    @Test
+    public void testToCharsetDecoders_null() {
+        final CharsetDecoder charsetEncoder = CharsetDecoders.toCharsetDecoder(null);
+        assertNotNull(charsetEncoder);
+        assertEquals(Charset.defaultCharset(), charsetEncoder.charset());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/charset/CharsetEncodersTest.java b/src/test/java/org/apache/commons/io/charset/CharsetEncodersTest.java
new file mode 100644
index 0000000..7288ba5
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/charset/CharsetEncodersTest.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.charset;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetEncoder;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link CharsetEncoders}.
+ */
+public class CharsetEncodersTest {
+
+    @Test
+    public void testToCharsetEncoders_default() {
+        final CharsetEncoder charsetEncoder = CharsetEncoders.toCharsetEncoder(Charset.defaultCharset().newEncoder());
+        assertNotNull(charsetEncoder);
+        assertEquals(Charset.defaultCharset(), charsetEncoder.charset());
+    }
+
+    @Test
+    public void testToCharsetEncoders_ISO_8859_1() {
+        final CharsetEncoder charsetEncoder = CharsetEncoders.toCharsetEncoder(StandardCharsets.ISO_8859_1.newEncoder());
+        assertNotNull(charsetEncoder);
+        assertEquals(StandardCharsets.ISO_8859_1, charsetEncoder.charset());
+    }
+
+    @Test
+    public void testToCharsetEncoders_null() {
+        final CharsetEncoder charsetEncoder = CharsetEncoders.toCharsetEncoder(null);
+        assertNotNull(charsetEncoder);
+        assertEquals(Charset.defaultCharset(), charsetEncoder.charset());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/comparator/ComparatorAbstractTest.java b/src/test/java/org/apache/commons/io/comparator/ComparatorAbstractTest.java
new file mode 100644
index 0000000..5bcca94
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/comparator/ComparatorAbstractTest.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Base Test case for Comparator implementations.
+ */
+public abstract class ComparatorAbstractTest {
+
+    @TempDir
+    public File dir;
+
+    /** comparator instance */
+    protected AbstractFileComparator comparator;
+
+    /** reverse comparator instance */
+    protected Comparator<File> reverse;
+
+    /** File which compares equal to  "equalFile2" */
+    protected File equalFile1;
+
+    /** File which compares equal to  "equalFile1" */
+    protected File equalFile2;
+
+    /** File which is less than the "moreFile" */
+    protected File lessFile;
+
+    /** File which is more than the "lessFile" */
+    protected File moreFile;
+
+    /**
+     * Test the comparator.
+     */
+    @Test
+    public void testComparator() {
+        assertEquals(0, comparator.compare(equalFile1, equalFile2), "equal");
+        assertTrue(comparator.compare(lessFile, moreFile) < 0, "less");
+        assertTrue(comparator.compare(moreFile, lessFile) > 0, "more");
+    }
+
+    /**
+     * Test the comparator reversed.
+     */
+    @Test
+    public void testReverseComparator() {
+        assertEquals(0, reverse.compare(equalFile1, equalFile2), "equal");
+        assertTrue(reverse.compare(moreFile, lessFile) < 0, "less");
+        assertTrue(reverse.compare(lessFile, moreFile) > 0, "more");
+    }
+
+    /**
+     * Test the comparator array sort.
+     */
+    @Test
+    public void testSortArray() {
+        final File[] files = new File[3];
+        files[0] = equalFile1;
+        files[1] = moreFile;
+        files[2] = lessFile;
+        comparator.sort(files);
+        assertSame(lessFile, files[0], "equal");
+        assertSame(equalFile1, files[1], "less");
+        assertSame(moreFile, files[2], "more");
+    }
+
+    /**
+     * Test comparator array sort is null safe.
+     */
+    @Test
+    public void testSortArrayNull() {
+        assertNull(comparator.sort((File[])null));
+    }
+
+    /**
+     * Test the comparator array sort.
+     */
+    @Test
+    public void testSortList() {
+        final List<File> files = new ArrayList<>();
+        files.add(equalFile1);
+        files.add(moreFile);
+        files.add(lessFile);
+        comparator.sort(files);
+        assertSame(lessFile, files.get(0), "equal");
+        assertSame(equalFile1, files.get(1), "less");
+        assertSame(moreFile, files.get(2), "more");
+    }
+
+    /**
+     * Test comparator list sort is null safe.
+     */
+    @Test
+    public void testSortListNull() {
+        assertNull(comparator.sort((List<File>)null));
+    }
+
+    /**
+     * Test comparator toString.
+     */
+    @Test
+    public void testToString() {
+        assertNotNull(comparator.toString(), "comparator");
+        assertTrue(reverse.toString().startsWith("ReverseFileComparator["), "reverse");
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/comparator/CompositeFileComparatorTest.java b/src/test/java/org/apache/commons/io/comparator/CompositeFileComparatorTest.java
new file mode 100644
index 0000000..8585ec4
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/comparator/CompositeFileComparatorTest.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+import org.apache.commons.io.test.TestUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test case for {@link CompositeFileComparator}.
+ */
+public class CompositeFileComparatorTest extends ComparatorAbstractTest {
+
+    /**
+     * Test Constructor with null array
+     */
+    @Test
+    public void constructorArray_Null() {
+        final Comparator<File> c = new CompositeFileComparator((Comparator<File>[])null);
+        assertEquals(0, c.compare(lessFile, moreFile), "less,more");
+        assertEquals(0, c.compare(moreFile, lessFile), "more,less");
+        assertEquals("CompositeFileComparator{}", c.toString(), "toString");
+    }
+
+    /**
+     * Test Constructor with null Iterable
+     */
+    @Test
+    public void constructorIterable_Null() {
+        final Comparator<File> c = new CompositeFileComparator((Iterable<Comparator<File>>)null);
+        assertEquals(0, c.compare(lessFile, moreFile), "less,more");
+        assertEquals(0, c.compare(moreFile, lessFile), "more,less");
+        assertEquals("CompositeFileComparator{}", c.toString(), "toString");
+    }
+
+    /**
+     * Test Constructor with null Iterable
+     */
+    @Test
+    public void constructorIterable_order() {
+        final List<Comparator<File>> list = new ArrayList<>();
+        list.add(SizeFileComparator.SIZE_COMPARATOR);
+        list.add(ExtensionFileComparator.EXTENSION_COMPARATOR);
+        final Comparator<File> c = new CompositeFileComparator(list);
+
+        assertEquals(0, c.compare(equalFile1, equalFile2), "equal");
+        assertTrue(c.compare(lessFile, moreFile) < 0, "less");
+        assertTrue(c.compare(moreFile, lessFile) > 0, "more");
+    }
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        comparator = new CompositeFileComparator(SizeFileComparator.SIZE_COMPARATOR, ExtensionFileComparator.EXTENSION_COMPARATOR);
+        reverse = new ReverseFileComparator(comparator);
+        lessFile   = new File(dir, "xyz.txt");
+        equalFile1 = new File(dir, "foo.txt");
+        equalFile2 = new File(dir, "bar.txt");
+        moreFile   = new File(dir, "foo.xyz");
+        if (!lessFile.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + lessFile
+                    + " as the parent directory does not exist");
+        }
+
+        try (BufferedOutputStream output3 =
+                new BufferedOutputStream(Files.newOutputStream(lessFile.toPath()))) {
+            TestUtils.generateTestData(output3, 32);
+        }
+        if (!equalFile1.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + equalFile1
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output2 =
+                new BufferedOutputStream(Files.newOutputStream(equalFile1.toPath()))) {
+            TestUtils.generateTestData(output2, 48);
+        }
+        if (!equalFile2.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + equalFile2
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output1 =
+                new BufferedOutputStream(Files.newOutputStream(equalFile2.toPath()))) {
+            TestUtils.generateTestData(output1, 48);
+        }
+        if (!moreFile.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + moreFile
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output =
+                new BufferedOutputStream(Files.newOutputStream(moreFile.toPath()))) {
+            TestUtils.generateTestData(output, 48);
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/comparator/DefaultFileComparatorTest.java b/src/test/java/org/apache/commons/io/comparator/DefaultFileComparatorTest.java
new file mode 100644
index 0000000..d85f9de
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/comparator/DefaultFileComparatorTest.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import java.io.File;
+
+import org.junit.jupiter.api.BeforeEach;
+
+/**
+ * Test case for {@link DefaultFileComparator}.
+ */
+public class DefaultFileComparatorTest extends ComparatorAbstractTest {
+
+    @BeforeEach
+    public void setUp() {
+        comparator = (AbstractFileComparator) DefaultFileComparator.DEFAULT_COMPARATOR;
+        reverse = DefaultFileComparator.DEFAULT_REVERSE;
+        equalFile1 = new File("foo");
+        equalFile2 = new File("foo");
+        lessFile   = new File("abc");
+        moreFile   = new File("xyz");
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/comparator/DirectoryFileComparatorTest.java b/src/test/java/org/apache/commons/io/comparator/DirectoryFileComparatorTest.java
new file mode 100644
index 0000000..82d3f19
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/comparator/DirectoryFileComparatorTest.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import java.io.File;
+
+import org.apache.commons.io.FileUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test case for {@link DirectoryFileComparator}.
+ */
+public class DirectoryFileComparatorTest extends ComparatorAbstractTest {
+
+    @BeforeEach
+    public void setUp() {
+        comparator = (AbstractFileComparator) DirectoryFileComparator.DIRECTORY_COMPARATOR;
+        reverse = DirectoryFileComparator.DIRECTORY_REVERSE;
+        final File currentDir = FileUtils.current();
+        equalFile1 = new File(currentDir, "src");
+        equalFile2 = new File(currentDir, "src/site/xdoc");
+        lessFile   = new File(currentDir, "src");
+        moreFile   = new File(currentDir, "pom.xml");
+    }
+
+    /**
+     * Test the comparator array sort.
+     */
+    @Override
+    @Test
+    public void testSortArray() {
+        // skip sort test
+    }
+
+    /**
+     * Test the comparator array sort.
+     */
+    @Override
+    public void testSortList() {
+        // skip sort test
+    }
+}
+
diff --git a/src/test/java/org/apache/commons/io/comparator/ExtensionFileComparatorTest.java b/src/test/java/org/apache/commons/io/comparator/ExtensionFileComparatorTest.java
new file mode 100644
index 0000000..86e32f1
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/comparator/ExtensionFileComparatorTest.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.util.Comparator;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test case for {@link ExtensionFileComparator}.
+ */
+public class ExtensionFileComparatorTest extends ComparatorAbstractTest {
+
+
+    @BeforeEach
+    public void setUp() {
+        comparator = (AbstractFileComparator) ExtensionFileComparator.EXTENSION_COMPARATOR;
+        reverse = ExtensionFileComparator.EXTENSION_REVERSE;
+        equalFile1 = new File("abc.foo");
+        equalFile2 = new File("def.foo");
+        lessFile   = new File("abc.abc");
+        moreFile   = new File("abc.xyz");
+    }
+
+    /** Test case sensitivity */
+    @Test
+    public void testCaseSensitivity() {
+        final File file3 = new File("abc.FOO");
+        final Comparator<File> sensitive = new ExtensionFileComparator(null); /* test null as well */
+        assertEquals(0, sensitive.compare(equalFile1, equalFile2), "sensitive file1 & file2 = 0");
+        assertTrue(sensitive.compare(equalFile1, file3) > 0, "sensitive file1 & file3 > 0");
+        assertTrue(sensitive.compare(equalFile1, lessFile) > 0, "sensitive file1 & less  > 0");
+
+        final Comparator<File> insensitive = ExtensionFileComparator.EXTENSION_INSENSITIVE_COMPARATOR;
+        assertEquals(0, insensitive.compare(equalFile1, equalFile2), "insensitive file1 & file2 = 0");
+        assertEquals(0, insensitive.compare(equalFile1, file3), "insensitive file1 & file3 = 0");
+        assertTrue(insensitive.compare(equalFile1, lessFile) > 0, "insensitive file1 & file4 > 0");
+        assertTrue(insensitive.compare(file3, lessFile) > 0, "insensitive file3 & less  > 0");
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/comparator/LastModifiedFileComparatorTest.java b/src/test/java/org/apache/commons/io/comparator/LastModifiedFileComparatorTest.java
new file mode 100644
index 0000000..5ce2e5d
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/comparator/LastModifiedFileComparatorTest.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.test.TestUtils;
+import org.junit.jupiter.api.BeforeEach;
+
+/**
+ * Test case for {@link LastModifiedFileComparator}.
+ */
+public class LastModifiedFileComparatorTest extends ComparatorAbstractTest {
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        comparator = (AbstractFileComparator) LastModifiedFileComparator.LASTMODIFIED_COMPARATOR;
+        reverse = LastModifiedFileComparator.LASTMODIFIED_REVERSE;
+        final File olderFile = new File(dir, "older.txt");
+        if (!olderFile.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + olderFile + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output2 = new BufferedOutputStream(Files.newOutputStream(olderFile.toPath()))) {
+            TestUtils.generateTestData(output2, 0);
+        }
+
+        final File equalFile = new File(dir, "equal.txt");
+        if (!equalFile.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + equalFile + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output1 = new BufferedOutputStream(Files.newOutputStream(equalFile.toPath()))) {
+            TestUtils.generateTestData(output1, 0);
+        }
+        do {
+            TestUtils.sleepQuietly(300);
+            equalFile.setLastModified(System.currentTimeMillis());
+        } while (FileUtils.lastModified(olderFile) == FileUtils.lastModified(equalFile));
+
+        final File newerFile = new File(dir, "newer.txt");
+        if (!newerFile.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + newerFile + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(newerFile.toPath()))) {
+            TestUtils.generateTestData(output, 0);
+        }
+        do {
+            TestUtils.sleepQuietly(300);
+            newerFile.setLastModified(System.currentTimeMillis());
+        } while (FileUtils.lastModified(equalFile) == FileUtils.lastModified(newerFile));
+        equalFile1 = equalFile;
+        equalFile2 = equalFile;
+        lessFile = olderFile;
+        moreFile = newerFile;
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/comparator/NameFileComparatorTest.java b/src/test/java/org/apache/commons/io/comparator/NameFileComparatorTest.java
new file mode 100644
index 0000000..1d37095
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/comparator/NameFileComparatorTest.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.util.Comparator;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test case for {@link NameFileComparator}.
+ */
+public class NameFileComparatorTest extends ComparatorAbstractTest {
+
+    @BeforeEach
+    public void setUp() {
+        comparator = (AbstractFileComparator) NameFileComparator.NAME_INSENSITIVE_COMPARATOR;
+        reverse = NameFileComparator.NAME_REVERSE;
+        equalFile1 = new File("a/foo.txt");
+        equalFile2 = new File("b/foo.txt");
+        lessFile   = new File("c/ABC.txt");
+        moreFile   = new File("d/XYZ.txt");
+    }
+
+    /** Test case sensitivity */
+    @Test
+    public void testCaseSensitivity() {
+        final File file3 = new File("a/FOO.txt");
+        final Comparator<File> sensitive = new NameFileComparator(null); /* test null as well */
+        assertEquals(0, sensitive.compare(equalFile1, equalFile2), "sensitive file1 & file2 = 0");
+        assertTrue(sensitive.compare(equalFile1, file3) > 0, "sensitive file1 & file3 > 0");
+        assertTrue(sensitive.compare(equalFile1, lessFile) > 0, "sensitive file1 & less  > 0");
+
+        final Comparator<File> insensitive = NameFileComparator.NAME_INSENSITIVE_COMPARATOR;
+        assertEquals(0, insensitive.compare(equalFile1, equalFile2), "insensitive file1 & file2 = 0");
+        assertEquals(0, insensitive.compare(equalFile1, file3), "insensitive file1 & file3 = 0");
+        assertTrue(insensitive.compare(equalFile1, lessFile) > 0, "insensitive file1 & file4 > 0");
+        assertTrue(insensitive.compare(file3, lessFile) > 0, "insensitive file3 & less  > 0");
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/comparator/PathFileComparatorTest.java b/src/test/java/org/apache/commons/io/comparator/PathFileComparatorTest.java
new file mode 100644
index 0000000..e2eec44
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/comparator/PathFileComparatorTest.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.util.Comparator;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test case for {@link PathFileComparator}.
+ */
+public class PathFileComparatorTest extends ComparatorAbstractTest {
+
+
+    @BeforeEach
+    public void setUp() {
+        comparator = (AbstractFileComparator) PathFileComparator.PATH_COMPARATOR;
+        reverse = PathFileComparator.PATH_REVERSE;
+        equalFile1 = new File("foo/file.txt");
+        equalFile2 = new File("foo/file.txt");
+        lessFile   = new File("abc/file.txt");
+        moreFile   = new File("xyz/file.txt");
+    }
+
+    /** Test case sensitivity */
+    @Test
+    public void testCaseSensitivity() {
+        final File file3 = new File("FOO/file.txt");
+        final Comparator<File> sensitive = new PathFileComparator(null); /* test null as well */
+        assertEquals(0, sensitive.compare(equalFile1, equalFile2), "sensitive file1 & file2 = 0");
+        assertTrue(sensitive.compare(equalFile1, file3) > 0, "sensitive file1 & file3 > 0");
+        assertTrue(sensitive.compare(equalFile1, lessFile) > 0, "sensitive file1 & less  > 0");
+
+        final Comparator<File> insensitive = PathFileComparator.PATH_INSENSITIVE_COMPARATOR;
+        assertEquals(0, insensitive.compare(equalFile1, equalFile2), "insensitive file1 & file2 = 0");
+        assertEquals(0, insensitive.compare(equalFile1, file3), "insensitive file1 & file3 = 0");
+        assertTrue(insensitive.compare(equalFile1, lessFile) > 0, "insensitive file1 & file4 > 0");
+        assertTrue(insensitive.compare(file3, lessFile) > 0, "insensitive file3 & less  > 0");
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/comparator/SizeFileComparatorTest.java b/src/test/java/org/apache/commons/io/comparator/SizeFileComparatorTest.java
new file mode 100644
index 0000000..58ca05d
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/comparator/SizeFileComparatorTest.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.comparator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.test.TestUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test case for {@link SizeFileComparator}.
+ */
+public class SizeFileComparatorTest extends ComparatorAbstractTest {
+
+    private File smallerDir;
+    private File largerDir;
+    private File smallerFile;
+    private File largerFile;
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        comparator = (AbstractFileComparator) SizeFileComparator.SIZE_COMPARATOR;
+        reverse = SizeFileComparator.SIZE_REVERSE;
+        smallerDir = new File(dir, "smallerdir");
+        largerDir = new File(dir, "largerdir");
+        smallerFile = new File(smallerDir, "smaller.txt");
+        final File equalFile = new File(dir, "equal.txt");
+        largerFile = new File(largerDir, "larger.txt");
+        smallerDir.mkdir();
+        largerDir.mkdir();
+        if (!smallerFile.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + smallerFile
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output2 =
+                new BufferedOutputStream(Files.newOutputStream(smallerFile.toPath()))) {
+            TestUtils.generateTestData(output2, 32);
+        }
+        if (!equalFile.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + equalFile
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output1 =
+                new BufferedOutputStream(Files.newOutputStream(equalFile.toPath()))) {
+            TestUtils.generateTestData(output1, 48);
+        }
+        if (!largerFile.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + largerFile
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output =
+                new BufferedOutputStream(Files.newOutputStream(largerFile.toPath()))) {
+            TestUtils.generateTestData(output, 64);
+        }
+        equalFile1 = equalFile;
+        equalFile2 = equalFile;
+        lessFile   = smallerFile;
+        moreFile   = largerFile;
+    }
+
+    /**
+     * Test a file which doesn't exist.
+     */
+    @Test
+    public void testCompareDirectorySizes() {
+        assertEquals(0, comparator.compare(smallerDir, largerDir), "sumDirectoryContents=false");
+        assertEquals(-1, SizeFileComparator.SIZE_SUMDIR_COMPARATOR.compare(smallerDir, largerDir), "less");
+        assertEquals(1, SizeFileComparator.SIZE_SUMDIR_REVERSE.compare(smallerDir, largerDir), "less");
+    }
+
+    /**
+     * Test a file which doesn't exist.
+     */
+    @Test
+    public void testNonexistantFile() {
+        final File nonexistantFile = new File(FileUtils.current(), "nonexistant.txt");
+        assertFalse(nonexistantFile.exists());
+        assertTrue(comparator.compare(nonexistantFile, moreFile) < 0, "less");
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/file/AbstractTempDirTest.java b/src/test/java/org/apache/commons/io/file/AbstractTempDirTest.java
new file mode 100644
index 0000000..a5ed616
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/AbstractTempDirTest.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Provides services for test subclasses.
+ */
+public abstract class AbstractTempDirTest {
+
+    /**
+     * A temporary directory managed by JUnit.
+     */
+    @TempDir
+    public Path managedTempDirPath;
+
+    /**
+     * A temporary directory managed by each test so we can optionally fiddle with its permissions independently.
+     */
+    public Path tempDirPath;
+
+    /**
+     * A File version of this test's Path object.
+     */
+    public File tempDirFile;
+
+    @BeforeEach
+    public void beforeEachCreateTempDirs() throws IOException {
+        tempDirPath = Files.createTempDirectory(managedTempDirPath, getClass().getSimpleName());
+        tempDirFile = tempDirPath.toFile();
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/file/AccumulatorPathVisitorTest.java b/src/test/java/org/apache/commons/io/file/AccumulatorPathVisitorTest.java
new file mode 100644
index 0000000..a3a9ece
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/AccumulatorPathVisitorTest.java
@@ -0,0 +1,261 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.apache.commons.io.file.CounterAssertions.assertCounts;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.ThreadUtils;
+import org.apache.commons.io.filefilter.AndFileFilter;
+import org.apache.commons.io.filefilter.DirectoryFileFilter;
+import org.apache.commons.io.filefilter.EmptyFileFilter;
+import org.apache.commons.io.filefilter.PathVisitorFileFilter;
+import org.apache.commons.io.filefilter.TrueFileFilter;
+import org.apache.commons.io.function.IOBiFunction;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests both {@link AccumulatorPathVisitor} and {@link PathVisitorFileFilter}.
+ */
+public class AccumulatorPathVisitorTest {
+
+    static Stream<Arguments> testParameters() {
+        // @formatter:off
+        return Stream.of(
+            Arguments.of((Supplier<AccumulatorPathVisitor>) AccumulatorPathVisitor::withLongCounters),
+            Arguments.of((Supplier<AccumulatorPathVisitor>) AccumulatorPathVisitor::withBigIntegerCounters),
+            Arguments.of((Supplier<AccumulatorPathVisitor>) () ->
+                AccumulatorPathVisitor.withBigIntegerCounters(TrueFileFilter.INSTANCE, TrueFileFilter.INSTANCE)));
+        // @formatter:on
+    }
+
+    static Stream<Arguments> testParametersIgnoreFailures() {
+        // @formatter:off
+        return Stream.of(
+            Arguments.of((Supplier<AccumulatorPathVisitor>) () -> new AccumulatorPathVisitor(
+                Counters.bigIntegerPathCounters(),
+                CountingPathVisitor.defaultDirFilter(),
+                CountingPathVisitor.defaultFileFilter(),
+                IOBiFunction.noop())));
+        // @formatter:on
+    }
+
+    @TempDir
+    Path tempDirPath;
+
+    /**
+     * Tests the 0-argument constructor.
+     */
+    @Test
+    public void test0ArgConstructor() throws IOException {
+        final AccumulatorPathVisitor accPathVisitor = new AccumulatorPathVisitor();
+        final PathVisitorFileFilter countingFileFilter = new PathVisitorFileFilter(accPathVisitor);
+        Files.walkFileTree(tempDirPath, new AndFileFilter(countingFileFilter, DirectoryFileFilter.INSTANCE, EmptyFileFilter.EMPTY));
+        assertCounts(0, 0, 0, accPathVisitor.getPathCounters());
+        assertEquals(1, accPathVisitor.getDirList().size());
+        assertTrue(accPathVisitor.getFileList().isEmpty());
+        assertEquals(accPathVisitor, accPathVisitor);
+        assertEquals(accPathVisitor.hashCode(), accPathVisitor.hashCode());
+    }
+
+    /**
+     * Tests an empty folder.
+     */
+    @ParameterizedTest
+    @MethodSource("testParameters")
+    public void testEmptyFolder(final Supplier<AccumulatorPathVisitor> supplier) throws IOException {
+        final AccumulatorPathVisitor accPathVisitor = supplier.get();
+        final PathVisitorFileFilter countingFileFilter = new PathVisitorFileFilter(accPathVisitor);
+        Files.walkFileTree(tempDirPath, new AndFileFilter(countingFileFilter, DirectoryFileFilter.INSTANCE, EmptyFileFilter.EMPTY));
+        assertCounts(1, 0, 0, accPathVisitor.getPathCounters());
+        assertEquals(1, accPathVisitor.getDirList().size());
+        assertTrue(accPathVisitor.getFileList().isEmpty());
+        assertEquals(accPathVisitor, accPathVisitor);
+        assertEquals(accPathVisitor.hashCode(), accPathVisitor.hashCode());
+    }
+
+    /**
+     * Tests a directory with one file of size 0.
+     */
+    @ParameterizedTest
+    @MethodSource("testParameters")
+    public void testFolders1FileSize0(final Supplier<AccumulatorPathVisitor> supplier) throws IOException {
+        final AccumulatorPathVisitor accPathVisitor = supplier.get();
+        final PathVisitorFileFilter countingFileFilter = new PathVisitorFileFilter(accPathVisitor);
+        Files.walkFileTree(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-0"), countingFileFilter);
+        assertCounts(1, 1, 0, accPathVisitor.getPathCounters());
+        assertEquals(1, accPathVisitor.getDirList().size());
+        assertEquals(1, accPathVisitor.getFileList().size());
+        assertEquals(accPathVisitor, accPathVisitor);
+        assertEquals(accPathVisitor.hashCode(), accPathVisitor.hashCode());
+    }
+
+    /**
+     * Tests a directory with one file of size 1.
+     */
+    @ParameterizedTest
+    @MethodSource("testParameters")
+    public void testFolders1FileSize1(final Supplier<AccumulatorPathVisitor> supplier) throws IOException {
+        final AccumulatorPathVisitor accPathVisitor = supplier.get();
+        final PathVisitorFileFilter countingFileFilter = new PathVisitorFileFilter(accPathVisitor);
+        Files.walkFileTree(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1"), countingFileFilter);
+        assertCounts(1, 1, 1, accPathVisitor.getPathCounters());
+        assertEquals(1, accPathVisitor.getDirList().size());
+        assertEquals(1, accPathVisitor.getFileList().size());
+        assertEquals(accPathVisitor, accPathVisitor);
+        assertEquals(accPathVisitor.hashCode(), accPathVisitor.hashCode());
+    }
+
+    /**
+     * Tests a directory with two subdirectories, each containing one file of size 1.
+     */
+    @ParameterizedTest
+    @MethodSource("testParameters")
+    public void testFolders2FileSize2(final Supplier<AccumulatorPathVisitor> supplier) throws IOException {
+        final AccumulatorPathVisitor accPathVisitor = supplier.get();
+        final PathVisitorFileFilter countingFileFilter = new PathVisitorFileFilter(accPathVisitor);
+        Files.walkFileTree(Paths.get("src/test/resources/org/apache/commons/io/dirs-2-file-size-2"), countingFileFilter);
+        assertCounts(3, 2, 2, accPathVisitor.getPathCounters());
+        assertEquals(3, accPathVisitor.getDirList().size());
+        assertEquals(2, accPathVisitor.getFileList().size());
+        assertEquals(accPathVisitor, accPathVisitor);
+        assertEquals(accPathVisitor.hashCode(), accPathVisitor.hashCode());
+    }
+
+    /**
+     * Tests IO-755 with a directory with 100 files, and delete all of them midway through the visit.
+     *
+     * Random failure like:
+     *
+     * <pre>
+     * ...?...
+     * </pre>
+     */
+    @ParameterizedTest
+    @MethodSource("testParametersIgnoreFailures")
+    public void testFolderWhileDeletingAsync(final Supplier<AccumulatorPathVisitor> supplier) throws IOException, InterruptedException {
+        final int count = 10_000;
+        final List<Path> files = new ArrayList<>(count);
+        // Create "count" file fixtures
+        for (int i = 1; i <= count; i++) {
+            final Path tempFile = Files.createTempFile(tempDirPath, "test", ".txt");
+            assertTrue(Files.exists(tempFile));
+            files.add(tempFile);
+        }
+        final AccumulatorPathVisitor accPathVisitor = supplier.get();
+        final PathVisitorFileFilter countingFileFilter = new PathVisitorFileFilter(accPathVisitor) {
+            @Override
+            public FileVisitResult visitFile(final Path path, final BasicFileAttributes attributes) throws IOException {
+                // Slow down the walking a bit to try and cause conflicts with the deletion thread
+                try {
+                    ThreadUtils.sleep(Duration.ofMillis(10));
+                } catch (final InterruptedException ignore) {
+                    // e.printStackTrace();
+                }
+                return super.visitFile(path, attributes);
+            }
+        };
+        final ExecutorService executor = Executors.newSingleThreadExecutor();
+        final AtomicBoolean deleted = new AtomicBoolean();
+        try {
+            executor.execute(() -> {
+                for (final Path file : files) {
+                    try {
+                        // File deletion is slow compared to tree walking, so we go as fast as we can here
+                        Files.delete(file);
+                    } catch (final IOException ignored) {
+                        // e.printStackTrace();
+                    }
+                }
+                deleted.set(true);
+            });
+            Files.walkFileTree(tempDirPath, countingFileFilter);
+        } finally {
+            if (!deleted.get()) {
+                ThreadUtils.sleep(Duration.ofMillis(1000));
+            }
+            if (!deleted.get()) {
+                executor.awaitTermination(5, TimeUnit.SECONDS);
+            }
+            executor.shutdownNow();
+        }
+        assertEquals(accPathVisitor, accPathVisitor);
+        assertEquals(accPathVisitor.hashCode(), accPathVisitor.hashCode());
+    }
+
+    /**
+     * Tests IO-755 with a directory with 100 files, and delete all of them midway through the visit.
+     */
+    @ParameterizedTest
+    @MethodSource("testParametersIgnoreFailures")
+    public void testFolderWhileDeletingSync(final Supplier<AccumulatorPathVisitor> supplier) throws IOException {
+        final int count = 100;
+        final int marker = count / 2;
+        final Set<Path> files = new LinkedHashSet<>(count);
+        for (int i = 1; i <= count; i++) {
+            final Path tempFile = Files.createTempFile(tempDirPath, "test", ".txt");
+            assertTrue(Files.exists(tempFile));
+            files.add(tempFile);
+        }
+        final AccumulatorPathVisitor accPathVisitor = supplier.get();
+        final AtomicInteger visitCount = new AtomicInteger();
+        final PathVisitorFileFilter countingFileFilter = new PathVisitorFileFilter(accPathVisitor) {
+            @Override
+            public FileVisitResult visitFile(final Path path, final BasicFileAttributes attributes) throws IOException {
+                if (visitCount.incrementAndGet() == marker) {
+                    // Now that we've visited half the files, delete them all
+                    for (final Path file : files) {
+                        Files.delete(file);
+                    }
+                }
+                return super.visitFile(path, attributes);
+            }
+        };
+        Files.walkFileTree(tempDirPath, countingFileFilter);
+        assertCounts(1, marker - 1, 0, accPathVisitor.getPathCounters());
+        assertEquals(1, accPathVisitor.getDirList().size());
+        assertEquals(marker - 1, accPathVisitor.getFileList().size());
+        assertEquals(accPathVisitor, accPathVisitor);
+        assertEquals(accPathVisitor.hashCode(), accPathVisitor.hashCode());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/file/CleaningPathVisitorTest.java b/src/test/java/org/apache/commons/io/file/CleaningPathVisitorTest.java
new file mode 100644
index 0000000..569bf67
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/CleaningPathVisitorTest.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.apache.commons.io.file.CounterAssertions.assertCounts;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import org.apache.commons.io.file.Counters.PathCounters;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests {@link DeletingPathVisitor}.
+ */
+public class CleaningPathVisitorTest extends TestArguments {
+
+    @TempDir
+    private Path tempDir;
+
+    private void applyCleanEmptyDirectory(final CleaningPathVisitor visitor) throws IOException {
+        Files.walkFileTree(tempDir, visitor);
+        assertCounts(1, 0, 0, visitor);
+    }
+
+    /**
+     * Tests an empty folder.
+     */
+    @ParameterizedTest
+    @MethodSource("cleaningPathVisitors")
+    public void testCleanEmptyDirectory(final CleaningPathVisitor visitor) throws IOException {
+        applyCleanEmptyDirectory(visitor);
+    }
+
+    /**
+     * Tests an empty folder.
+     */
+    @ParameterizedTest
+    @MethodSource("pathCounters")
+    public void testCleanEmptyDirectoryNullCtorArg(final PathCounters pathCounters) throws IOException {
+        applyCleanEmptyDirectory(new CleaningPathVisitor(pathCounters, (String[]) null));
+    }
+
+    /**
+     * Tests a directory with one file of size 0.
+     */
+    @ParameterizedTest
+    @MethodSource("cleaningPathVisitors")
+    public void testCleanFolders1FileSize0(final CleaningPathVisitor visitor) throws IOException {
+        PathUtils.copyDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-0"), tempDir);
+        final CleaningPathVisitor visitFileTree = PathUtils.visitFileTree(visitor, tempDir);
+        assertCounts(1, 1, 0, visitFileTree);
+        assertSame(visitor, visitFileTree);
+        //
+        assertNotEquals(visitFileTree, CleaningPathVisitor.withLongCounters());
+        assertNotEquals(visitFileTree.hashCode(), CleaningPathVisitor.withLongCounters().hashCode());
+        assertEquals(visitFileTree, visitFileTree);
+        assertEquals(visitFileTree.hashCode(), visitFileTree.hashCode());
+    }
+
+    /**
+     * Tests a directory with one file of size 1.
+     */
+    @ParameterizedTest
+    @MethodSource("cleaningPathVisitors")
+    public void testCleanFolders1FileSize1(final CleaningPathVisitor visitor) throws IOException {
+        PathUtils.copyDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1"), tempDir);
+        final CleaningPathVisitor visitFileTree = PathUtils.visitFileTree(visitor, tempDir);
+        assertCounts(1, 1, 1, visitFileTree);
+        assertSame(visitor, visitFileTree);
+        //
+        assertNotEquals(visitFileTree, CleaningPathVisitor.withLongCounters());
+        assertNotEquals(visitFileTree.hashCode(), CleaningPathVisitor.withLongCounters().hashCode());
+        assertEquals(visitFileTree, visitFileTree);
+        assertEquals(visitFileTree.hashCode(), visitFileTree.hashCode());
+    }
+
+    /**
+     * Tests a directory with one file of size 1 but skip that file.
+     */
+    @ParameterizedTest
+    @MethodSource("pathCounters")
+    public void testCleanFolders1FileSize1Skip(final PathCounters pathCounters) throws IOException {
+        PathUtils.copyDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1"), tempDir);
+        final String skipFileName = "file-size-1.bin";
+        final CountingPathVisitor visitor = new CleaningPathVisitor(pathCounters, skipFileName);
+        final CountingPathVisitor visitFileTree = PathUtils.visitFileTree(visitor, tempDir);
+        assertCounts(1, 1, 1, visitFileTree);
+        assertSame(visitor, visitFileTree);
+        final Path skippedFile = tempDir.resolve(skipFileName);
+        Assertions.assertTrue(Files.exists(skippedFile));
+        Files.delete(skippedFile);
+        //
+        assertNotEquals(visitFileTree, CleaningPathVisitor.withLongCounters());
+        assertNotEquals(visitFileTree.hashCode(), CleaningPathVisitor.withLongCounters().hashCode());
+        assertEquals(visitFileTree, visitFileTree);
+        assertEquals(visitFileTree.hashCode(), visitFileTree.hashCode());
+    }
+
+    /**
+     * Tests a directory with two subdirectories, each containing one file of size 1.
+     */
+    @ParameterizedTest
+    @MethodSource("cleaningPathVisitors")
+    public void testCleanFolders2FileSize2(final CleaningPathVisitor visitor) throws IOException {
+        PathUtils.copyDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-2-file-size-2"), tempDir);
+        final CleaningPathVisitor visitFileTree = PathUtils.visitFileTree(visitor, tempDir);
+        assertCounts(3, 2, 2, visitFileTree);
+        assertSame(visitor, visitFileTree);
+        //
+        assertNotEquals(visitFileTree, CleaningPathVisitor.withLongCounters());
+        assertNotEquals(visitFileTree.hashCode(), CleaningPathVisitor.withLongCounters().hashCode());
+        assertEquals(visitFileTree, visitFileTree);
+        assertEquals(visitFileTree.hashCode(), visitFileTree.hashCode());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/file/CopyDirectoryVisitorTest.java b/src/test/java/org/apache/commons/io/file/CopyDirectoryVisitorTest.java
new file mode 100644
index 0000000..fb0fe15
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/CopyDirectoryVisitorTest.java
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.apache.commons.io.file.CounterAssertions.assertCounts;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+import java.io.IOException;
+import java.nio.file.CopyOption;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.function.Supplier;
+
+import org.apache.commons.io.file.Counters.PathCounters;
+import org.apache.commons.io.filefilter.TrueFileFilter;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests {@link CountingPathVisitor}.
+ */
+public class CopyDirectoryVisitorTest extends TestArguments {
+
+    private static final CopyOption[] EXPECTED_COPY_OPTIONS = {StandardCopyOption.REPLACE_EXISTING};
+
+    @TempDir
+    private Path targetDir;
+
+    /**
+     * Tests an empty folder.
+     */
+    @ParameterizedTest
+    @MethodSource("pathCounters")
+    public void testCopyDirectoryEmptyFolder(final PathCounters pathCounters) throws IOException {
+        try (TempDirectory sourceDir = TempDirectory.create(getClass().getSimpleName())) {
+            final Supplier<CopyDirectoryVisitor> supplier = () -> new CopyDirectoryVisitor(pathCounters, sourceDir, targetDir, EXPECTED_COPY_OPTIONS);
+            final CopyDirectoryVisitor visitFileTree = PathUtils.visitFileTree(supplier.get(), sourceDir.get());
+            assertCounts(1, 0, 0, visitFileTree);
+            assertArrayEquals(EXPECTED_COPY_OPTIONS, visitFileTree.getCopyOptions());
+            assertEquals(sourceDir.get(), ((PathWrapper) visitFileTree.getSourceDirectory()).get());
+            assertEquals(sourceDir, visitFileTree.getSourceDirectory());
+            assertEquals(targetDir, visitFileTree.getTargetDirectory());
+            assertEquals(targetDir, visitFileTree.getTargetDirectory());
+            //
+            assertEquals(visitFileTree, supplier.get());
+            assertEquals(visitFileTree.hashCode(), supplier.get().hashCode());
+            assertEquals(visitFileTree, visitFileTree);
+            assertEquals(visitFileTree.hashCode(), visitFileTree.hashCode());
+            assertNotEquals(visitFileTree, "not");
+            assertNotEquals(visitFileTree, CountingPathVisitor.withLongCounters());
+        }
+    }
+
+    /**
+     * Tests an empty folder.
+     */
+    @ParameterizedTest
+    @MethodSource("pathCounters")
+    public void testCopyDirectoryEmptyFolderFilters(final PathCounters pathCounters) throws IOException {
+        try (TempDirectory sourceDir = TempDirectory.create(getClass().getSimpleName())) {
+            final Supplier<CopyDirectoryVisitor> supplier = () -> new CopyDirectoryVisitor(pathCounters, TrueFileFilter.INSTANCE, TrueFileFilter.INSTANCE,
+                sourceDir, targetDir, EXPECTED_COPY_OPTIONS);
+            final CopyDirectoryVisitor visitFileTree = PathUtils.visitFileTree(supplier.get(), sourceDir.get());
+            assertCounts(1, 0, 0, visitFileTree);
+            assertArrayEquals(EXPECTED_COPY_OPTIONS, visitFileTree.getCopyOptions());
+            assertEquals(sourceDir, visitFileTree.getSourceDirectory());
+            assertEquals(targetDir, visitFileTree.getTargetDirectory());
+            //
+            assertEquals(visitFileTree, supplier.get());
+            assertEquals(visitFileTree.hashCode(), supplier.get().hashCode());
+            assertEquals(visitFileTree, visitFileTree);
+            assertEquals(visitFileTree.hashCode(), visitFileTree.hashCode());
+        }
+    }
+
+    /**
+     * Tests a directory with one file of size 0.
+     */
+    @ParameterizedTest
+    @MethodSource("pathCounters")
+    public void testCopyDirectoryFolders1FileSize0(final PathCounters pathCounters) throws IOException {
+        final Path sourceDir = Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-0");
+        final Supplier<CopyDirectoryVisitor> supplier = () -> new CopyDirectoryVisitor(pathCounters, sourceDir, targetDir, EXPECTED_COPY_OPTIONS);
+        final CopyDirectoryVisitor visitFileTree = PathUtils.visitFileTree(supplier.get(), sourceDir);
+        assertCounts(1, 1, 0, visitFileTree);
+        assertArrayEquals(EXPECTED_COPY_OPTIONS, visitFileTree.getCopyOptions());
+        assertEquals(sourceDir, visitFileTree.getSourceDirectory());
+        assertEquals(targetDir, visitFileTree.getTargetDirectory());
+        //
+        assertEquals(visitFileTree, supplier.get());
+        assertEquals(visitFileTree.hashCode(), supplier.get().hashCode());
+        assertEquals(visitFileTree, visitFileTree);
+        assertEquals(visitFileTree.hashCode(), visitFileTree.hashCode());
+    }
+
+    /**
+     * Tests a directory with one file of size 1.
+     */
+    @ParameterizedTest
+    @MethodSource("pathCounters")
+    public void testCopyDirectoryFolders1FileSize1(final PathCounters pathCounters) throws IOException {
+        final Path sourceDir = Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1");
+        final CopyDirectoryVisitor visitFileTree = PathUtils.visitFileTree(new CopyDirectoryVisitor(pathCounters, sourceDir, targetDir, EXPECTED_COPY_OPTIONS),
+            sourceDir);
+        assertCounts(1, 1, 1, visitFileTree);
+        assertArrayEquals(EXPECTED_COPY_OPTIONS, visitFileTree.getCopyOptions());
+        assertEquals(sourceDir, visitFileTree.getSourceDirectory());
+        assertEquals(targetDir, visitFileTree.getTargetDirectory());
+    }
+
+    /**
+     * Tests a directory with two subdirectories, each containing one file of size 1.
+     */
+    @ParameterizedTest
+    @MethodSource("pathCounters")
+    public void testCopyDirectoryFolders2FileSize2(final PathCounters pathCounters) throws IOException {
+        final Path sourceDir = Paths.get("src/test/resources/org/apache/commons/io/dirs-2-file-size-2");
+        final CopyDirectoryVisitor visitFileTree = PathUtils.visitFileTree(new CopyDirectoryVisitor(pathCounters, sourceDir, targetDir, EXPECTED_COPY_OPTIONS),
+            sourceDir);
+        assertCounts(3, 2, 2, visitFileTree);
+        assertArrayEquals(EXPECTED_COPY_OPTIONS, visitFileTree.getCopyOptions());
+        assertEquals(sourceDir, visitFileTree.getSourceDirectory());
+        assertEquals(targetDir, visitFileTree.getTargetDirectory());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/file/CounterAssertions.java b/src/test/java/org/apache/commons/io/file/CounterAssertions.java
new file mode 100644
index 0000000..8a0332b
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/CounterAssertions.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.math.BigInteger;
+
+import org.apache.commons.io.file.Counters.Counter;
+import org.apache.commons.io.file.Counters.PathCounters;
+
+public class CounterAssertions {
+
+    static void assertCounter(final long expected, final Counter actual, final String message) {
+        assertEquals(expected, actual.get(), message);
+        assertEquals(Long.valueOf(expected), actual.getLong(), message);
+        assertEquals(BigInteger.valueOf(expected), actual.getBigInteger(), message);
+    }
+
+    static void assertCounts(final long expectedDirCount, final long expectedFileCount, final long expectedByteCount,
+        final CountingPathVisitor actualVisitor) {
+        assertCounts(expectedDirCount, expectedFileCount, expectedByteCount, actualVisitor.getPathCounters());
+    }
+
+    static void assertCounts(final long expectedDirCount, final long expectedFileCount, final long expectedByteCount,
+        final PathCounters actualPathCounters) {
+        assertCounter(expectedDirCount, actualPathCounters.getDirectoryCounter(), "getDirectoryCounter");
+        assertCounter(expectedFileCount, actualPathCounters.getFileCounter(), "getFileCounter");
+        assertCounter(expectedByteCount, actualPathCounters.getByteCounter(), "getByteCounter");
+    }
+
+    public static void assertZeroCounters(final PathCounters pathCounters) {
+        assertCounts(0, 0, 0, pathCounters);
+        assertEquals(Counters.longPathCounters(), pathCounters);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/file/CountersEqualsAndHashCodeTest.java b/src/test/java/org/apache/commons/io/file/CountersEqualsAndHashCodeTest.java
new file mode 100644
index 0000000..8901c9e
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/CountersEqualsAndHashCodeTest.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import org.apache.commons.io.file.Counters.Counter;
+import org.apache.commons.io.file.Counters.PathCounters;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class CountersEqualsAndHashCodeTest {
+
+    @Test
+    public void testBigIntegerCounterEquals() {
+        testEquals(Counters.bigIntegerCounter(), Counters.bigIntegerCounter());
+    }
+
+    @Test
+    public void testBigIntegerHashCode() {
+        testHashCodes(Counters.bigIntegerCounter(), Counters.bigIntegerCounter());
+    }
+
+    private void testEquals(final Counter counter1, final Counter counter2) {
+        Assertions.assertEquals(counter1, counter2);
+        counter1.increment();
+        Assertions.assertNotEquals(counter1, counter2);
+        counter2.increment();
+        Assertions.assertEquals(counter1, counter2);
+    }
+
+    private void testEqualsByteCounters(final PathCounters counter1, final PathCounters counter2) {
+        Assertions.assertEquals(counter1, counter2);
+        counter1.getByteCounter().increment();
+        Assertions.assertNotEquals(counter1, counter2);
+        counter2.getByteCounter().increment();
+        Assertions.assertEquals(counter1, counter2);
+    }
+
+    private void testEqualsDirectoryCounters(final PathCounters counter1, final PathCounters counter2) {
+        Assertions.assertEquals(counter1, counter2);
+        counter1.getDirectoryCounter().increment();
+        Assertions.assertNotEquals(counter1, counter2);
+        counter2.getDirectoryCounter().increment();
+        Assertions.assertEquals(counter1, counter2);
+    }
+
+    private void testEqualsFileCounters(final PathCounters counter1, final PathCounters counter2) {
+        Assertions.assertEquals(counter1, counter2);
+        counter1.getFileCounter().increment();
+        Assertions.assertNotEquals(counter1, counter2);
+        counter2.getFileCounter().increment();
+        Assertions.assertEquals(counter1, counter2);
+    }
+
+    private void testHashCodeFileCounters(final PathCounters counter1, final PathCounters counter2) {
+        Assertions.assertEquals(counter1.hashCode(), counter2.hashCode());
+        counter1.getFileCounter().increment();
+        Assertions.assertNotEquals(counter1.hashCode(), counter2.hashCode());
+        counter2.getFileCounter().increment();
+        Assertions.assertEquals(counter1.hashCode(), counter2.hashCode());
+    }
+
+    private void testHashCodes(final Counter counter1, final Counter counter2) {
+        Assertions.assertEquals(counter1.hashCode(), counter2.hashCode());
+        counter1.increment();
+        Assertions.assertNotEquals(counter1.hashCode(), counter2.hashCode());
+        counter2.increment();
+        Assertions.assertEquals(counter1.hashCode(), counter2.hashCode());
+    }
+
+    @Test
+    public void testLongCounterEquals() {
+        testEquals(Counters.longCounter(), Counters.longCounter());
+    }
+
+    @Test
+    public void testLongCounterHashCodes() {
+        testHashCodes(Counters.longCounter(), Counters.longCounter());
+    }
+
+    @Test
+    public void testLongCounterMixEquals() {
+        testEquals(Counters.longCounter(), Counters.bigIntegerCounter());
+        testEquals(Counters.bigIntegerCounter(), Counters.longCounter());
+    }
+
+    @Test
+    public void testLongPathCountersEqualsByteCounters() {
+        testEqualsByteCounters(Counters.longPathCounters(), Counters.longPathCounters());
+    }
+
+    @Test
+    public void testLongPathCountersEqualsDirectoryCounters() {
+        testEqualsDirectoryCounters(Counters.longPathCounters(), Counters.longPathCounters());
+    }
+
+    @Test
+    public void testLongPathCountersEqualsFileCounters() {
+        testEqualsFileCounters(Counters.longPathCounters(), Counters.longPathCounters());
+    }
+
+    @Test
+    public void testLongPathCountersHashCodeFileCounters() {
+        testHashCodeFileCounters(Counters.longPathCounters(), Counters.longPathCounters());
+    }
+
+    @Test
+    public void testMix() {
+        testHashCodeFileCounters(Counters.longPathCounters(), Counters.bigIntegerPathCounters());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/file/CountersTest.java b/src/test/java/org/apache/commons/io/file/CountersTest.java
new file mode 100644
index 0000000..d386b69
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/CountersTest.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.apache.commons.io.file.CounterAssertions.assertCounter;
+import static org.apache.commons.io.file.CounterAssertions.assertCounts;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.apache.commons.io.file.Counters.Counter;
+import org.apache.commons.io.file.Counters.PathCounters;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+public class CountersTest extends TestArguments {
+
+    @ParameterizedTest
+    @MethodSource("numberCounters")
+    public void testInitialValue(final Counter counter) {
+        assertCounter(0, counter, "");
+    }
+
+    @ParameterizedTest
+    @MethodSource("pathCounters")
+    public void testInitialValues(final PathCounters pathCounter) {
+        // Does not blow up
+        assertCounts(0, 0, 0, pathCounter);
+    }
+
+    @ParameterizedTest
+    @MethodSource("pathCounters")
+    public void testResetCounter(final PathCounters pathCounter) {
+        final Counter byteCounter = pathCounter.getByteCounter();
+        final long old = byteCounter.get();
+        byteCounter.add(1);
+        assertEquals(old + 1, byteCounter.get());
+        byteCounter.reset();
+        assertEquals(0, byteCounter.get());
+    }
+
+    @ParameterizedTest
+    @MethodSource("pathCounters")
+    public void testResetPathCounter(final PathCounters pathCounter) {
+        final Counter byteCounter = pathCounter.getByteCounter();
+        final long old = byteCounter.get();
+        byteCounter.add(1);
+        assertEquals(old + 1, byteCounter.get());
+        pathCounter.reset();
+        assertEquals(0, byteCounter.get());
+    }
+
+    @ParameterizedTest
+    @MethodSource("numberCounters")
+    public void testToString(final Counter counter) {
+        // Does not blow up
+        counter.toString();
+    }
+
+    @ParameterizedTest
+    @MethodSource("pathCounters")
+    public void testToString(final PathCounters pathCounter) {
+        // Does not blow up
+        pathCounter.toString();
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/file/CountingPathVisitorTest.java b/src/test/java/org/apache/commons/io/file/CountingPathVisitorTest.java
new file mode 100644
index 0000000..973bb0b
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/CountingPathVisitorTest.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.apache.commons.io.file.CounterAssertions.assertCounts;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests {@link CountingPathVisitor}.
+ */
+public class CountingPathVisitorTest extends TestArguments {
+
+    private void checkZeroCounts(final CountingPathVisitor visitor) {
+        Assertions.assertEquals(CountingPathVisitor.withLongCounters(), visitor);
+        Assertions.assertEquals(CountingPathVisitor.withBigIntegerCounters(), visitor);
+    }
+
+    /**
+     * Tests an empty folder.
+     */
+    @ParameterizedTest
+    @MethodSource("countingPathVisitors")
+    public void testCountEmptyFolder(final CountingPathVisitor visitor) throws IOException {
+        checkZeroCounts(visitor);
+        try (TempDirectory tempDir = TempDirectory.create(getClass().getCanonicalName())) {
+            assertCounts(1, 0, 0, PathUtils.visitFileTree(visitor, tempDir.get()));
+        }
+    }
+
+    /**
+     * Tests a directory with one file of size 0.
+     */
+    @ParameterizedTest
+    @MethodSource("countingPathVisitors")
+    public void testCountFolders1FileSize0(final CountingPathVisitor visitor) throws IOException {
+        checkZeroCounts(visitor);
+        assertCounts(1, 1, 0, PathUtils.visitFileTree(visitor,
+                "src/test/resources/org/apache/commons/io/dirs-1-file-size-0"));
+    }
+
+    /**
+     * Tests a directory with one file of size 1.
+     */
+    @ParameterizedTest
+    @MethodSource("countingPathVisitors")
+    public void testCountFolders1FileSize1(final CountingPathVisitor visitor) throws IOException {
+        checkZeroCounts(visitor);
+        assertCounts(1, 1, 1, PathUtils.visitFileTree(visitor,
+                "src/test/resources/org/apache/commons/io/dirs-1-file-size-1"));
+    }
+
+    /**
+     * Tests a directory with two subdirectories, each containing one file of size 1.
+     */
+    @ParameterizedTest
+    @MethodSource("countingPathVisitors")
+    public void testCountFolders2FileSize2(final CountingPathVisitor visitor) throws IOException {
+        checkZeroCounts(visitor);
+        assertCounts(3, 2, 2, PathUtils.visitFileTree(visitor,
+                "src/test/resources/org/apache/commons/io/dirs-2-file-size-2"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("countingPathVisitors")
+    void testToString(final CountingPathVisitor visitor) {
+        // Make sure it does not blow up
+        visitor.toString();
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/file/DeletablePath.java b/src/test/java/org/apache/commons/io/file/DeletablePath.java
new file mode 100644
index 0000000..28aa20b
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/DeletablePath.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.nio.file.Path;
+
+import org.apache.commons.io.file.Counters.PathCounters;
+
+/**
+ * A Path that deletes its delegate on close.
+ *
+ * @since 2.12.0
+ */
+public class DeletablePath extends PathWrapper implements Closeable {
+
+    /**
+     * Constructs a new instance wrapping the given delegate.
+     *
+     * @param path The delegate.
+     */
+    public DeletablePath(final Path path) {
+        super(path);
+    }
+
+
+    @Override
+    public void close() throws IOException {
+        delete();
+    }
+
+    /**
+     * Deletes the delegate path.
+     *
+     * @return The visitor used to delete the given directory.
+     * @throws IOException if an I/O error is thrown by a visitor method or if an I/O error occurs.
+     */
+    public PathCounters delete() throws IOException {
+        return delete((DeleteOption[]) null);
+    }
+
+    /**
+     * Deletes the delegate path.
+     * @param deleteOptions How to handle deletion.
+     *
+     * @return The visitor used to delete the given directory.
+     * @throws IOException if an I/O error is thrown by a visitor method or if an I/O error occurs.
+     */
+    public PathCounters delete(final DeleteOption... deleteOptions) throws IOException {
+        return PathUtils.delete(get(), deleteOptions);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/file/DeletingPathVisitorTest.java b/src/test/java/org/apache/commons/io/file/DeletingPathVisitorTest.java
new file mode 100644
index 0000000..800c272
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/DeletingPathVisitorTest.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.apache.commons.io.file.CounterAssertions.assertCounts;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import org.apache.commons.io.file.Counters.PathCounters;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests {@link DeletingPathVisitor}.
+ */
+public class DeletingPathVisitorTest extends TestArguments {
+
+    @TempDir
+    private Path tempDir;
+
+    private void applyDeleteEmptyDirectory(final DeletingPathVisitor visitor) throws IOException {
+        Files.walkFileTree(tempDir, visitor);
+        assertCounts(1, 0, 0, visitor);
+    }
+
+    /**
+     * Tests an empty folder.
+     */
+    @ParameterizedTest
+    @MethodSource("deletingPathVisitors")
+    public void testDeleteEmptyDirectory(final DeletingPathVisitor visitor) throws IOException {
+        applyDeleteEmptyDirectory(visitor);
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDir);
+    }
+
+    /**
+     * Tests an empty folder.
+     */
+    @ParameterizedTest
+    @MethodSource("pathCounters")
+    public void testDeleteEmptyDirectoryNullCtorArg(final PathCounters pathCounters) throws IOException {
+        applyDeleteEmptyDirectory(new DeletingPathVisitor(pathCounters, (String[]) null));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDir);
+    }
+
+    /**
+     * Tests a directory with one file of size 0.
+     */
+    @ParameterizedTest
+    @MethodSource("deletingPathVisitors")
+    public void testDeleteFolders1FileSize0(final DeletingPathVisitor visitor) throws IOException {
+        PathUtils.copyDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-0"), tempDir);
+        assertCounts(1, 1, 0, PathUtils.visitFileTree(visitor, tempDir));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDir);
+    }
+
+    /**
+     * Tests a directory with one file of size 1.
+     */
+    @ParameterizedTest
+    @MethodSource("deletingPathVisitors")
+    public void testDeleteFolders1FileSize1(final DeletingPathVisitor visitor) throws IOException {
+        PathUtils.copyDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1"), tempDir);
+        assertCounts(1, 1, 1, PathUtils.visitFileTree(visitor, tempDir));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDir);
+    }
+
+    /**
+     * Tests a directory with one file of size 1 but skip that file.
+     */
+    @ParameterizedTest
+    @MethodSource("pathCounters")
+    public void testDeleteFolders1FileSize1Skip(final PathCounters pathCounters) throws IOException {
+        PathUtils.copyDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1"), tempDir);
+        final String skipFileName = "file-size-1.bin";
+        final CountingPathVisitor visitor = new DeletingPathVisitor(pathCounters, skipFileName);
+        assertCounts(1, 1, 1, PathUtils.visitFileTree(visitor, tempDir));
+        final Path skippedFile = tempDir.resolve(skipFileName);
+        Assertions.assertTrue(Files.exists(skippedFile));
+        Files.delete(skippedFile);
+    }
+
+    /**
+     * Tests a directory with two subdirectories, each containing one file of size 1.
+     */
+    @ParameterizedTest
+    @MethodSource("deletingPathVisitors")
+    public void testDeleteFolders2FileSize2(final DeletingPathVisitor visitor) throws IOException {
+        PathUtils.copyDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-2-file-size-2"), tempDir);
+        assertCounts(3, 2, 2, PathUtils.visitFileTree(visitor, tempDir));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDir);
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/file/DirectoryStreamFilterTest.java b/src/test/java/org/apache/commons/io/file/DirectoryStreamFilterTest.java
new file mode 100644
index 0000000..60e5442
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/DirectoryStreamFilterTest.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Iterator;
+
+import org.apache.commons.io.filefilter.NameFileFilter;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link DirectoryStreamFilter}.
+ */
+public class DirectoryStreamFilterTest {
+
+    private static final String PATH_FIXTURE = "NOTICE.txt";
+
+    @Test
+    public void testFilterByName() throws Exception {
+        final PathFilter pathFilter = new NameFileFilter(PATH_FIXTURE);
+        final DirectoryStreamFilter streamFilter = new DirectoryStreamFilter(pathFilter);
+        assertEquals(pathFilter, streamFilter.getPathFilter());
+        try (DirectoryStream<Path> stream = Files.newDirectoryStream(PathUtils.current(), streamFilter)) {
+            final Iterator<Path> iterator = stream.iterator();
+            final Path path = iterator.next();
+            assertEquals(PATH_FIXTURE, path.getFileName().toString());
+            assertFalse(iterator.hasNext());
+        }
+    }
+
+    @Test
+    public void testFilterByNameNot() throws Exception {
+        final PathFilter pathFilter = new NameFileFilter(PATH_FIXTURE).negate();
+        final DirectoryStreamFilter streamFilter = new DirectoryStreamFilter(pathFilter);
+        assertEquals(pathFilter, streamFilter.getPathFilter());
+        try (DirectoryStream<Path> stream = Files.newDirectoryStream(PathUtils.current(), streamFilter)) {
+            stream.forEach(path -> assertNotEquals(PATH_FIXTURE, path.getFileName().toString()));
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/file/FilesUncheckTest.java b/src/test/java/org/apache/commons/io/file/FilesUncheckTest.java
new file mode 100644
index 0000000..1882353
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/FilesUncheckTest.java
@@ -0,0 +1,453 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.file;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryStream;
+import java.nio.file.FileVisitOption;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileAttribute;
+import java.nio.file.attribute.FileTime;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.UserPrincipal;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.function.Uncheck;
+import org.apache.commons.io.input.NullInputStream;
+import org.apache.commons.io.output.NullOutputStream;
+import org.apache.commons.lang3.ArrayUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link FilesUncheck}.
+ *
+ * These tests are simple and just makes sure we do can make the call without catching IOException.
+ */
+public class FilesUncheckTest {
+
+    private static final FileAttribute<?>[] EMPTY_FILE_ATTRIBUTES_ARRAY = {};
+
+    private static final Path FILE_PATH_A = Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1/file-size-1.bin");
+
+    private static final Path FILE_PATH_EMPTY = Paths.get("src/test/resources/org/apache/commons/io/test-file-empty.bin");
+
+    private static final Path NEW_DIR_PATH = Paths.get("target/newdir");
+
+    private static final Path NEW_FILE_PATH = Paths.get("target/file.txt");
+
+    private static final Path NEW_FILE_PATH_LINK = Paths.get("target/to_another_file.txt");
+
+    private static final String PREFIX = "prefix";
+
+    private static final String SUFFIX = "suffix";
+
+    private static final Path TARGET_PATH = Paths.get("target");
+
+    @BeforeEach
+    @AfterEach
+    public void deleteFixtures() throws IOException {
+        Files.deleteIfExists(NEW_FILE_PATH);
+        Files.deleteIfExists(NEW_DIR_PATH);
+        Files.deleteIfExists(NEW_FILE_PATH_LINK);
+    }
+
+    @Test
+    public void testCopyInputStreamPathCopyOptionArray() {
+        assertEquals(0, FilesUncheck.copy(NullInputStream.INSTANCE, NEW_FILE_PATH, PathUtils.EMPTY_COPY_OPTIONS));
+    }
+
+    @Test
+    public void testCopyPathOutputStream() {
+        assertEquals(0, FilesUncheck.copy(FILE_PATH_EMPTY, NullOutputStream.INSTANCE));
+    }
+
+    @Test
+    public void testCopyPathPathCopyOptionArray() {
+        assertEquals(NEW_FILE_PATH, FilesUncheck.copy(FILE_PATH_EMPTY, NEW_FILE_PATH, PathUtils.EMPTY_COPY_OPTIONS));
+    }
+
+    @Test
+    public void testCreateDirectories() {
+        assertEquals(TARGET_PATH, FilesUncheck.createDirectories(TARGET_PATH, EMPTY_FILE_ATTRIBUTES_ARRAY));
+    }
+
+    @Test
+    public void testCreateDirectory() {
+        assertEquals(NEW_DIR_PATH, FilesUncheck.createDirectory(NEW_DIR_PATH, EMPTY_FILE_ATTRIBUTES_ARRAY));
+    }
+
+    @Test
+    public void testCreateFile() {
+        assertEquals(NEW_FILE_PATH, FilesUncheck.createFile(NEW_FILE_PATH, EMPTY_FILE_ATTRIBUTES_ARRAY));
+    }
+
+    @Test
+    public void testCreateLink() {
+        assertEquals(NEW_FILE_PATH_LINK, FilesUncheck.createLink(NEW_FILE_PATH_LINK, FILE_PATH_EMPTY));
+    }
+
+    @Test
+    public void testCreateSymbolicLink() {
+        // May cause: Caused by: java.nio.file.FileSystemException: A required privilege is not held by the client.
+        assertEquals(NEW_FILE_PATH_LINK, FilesUncheck.createSymbolicLink(NEW_FILE_PATH_LINK, FILE_PATH_EMPTY));
+    }
+
+    @Test
+    public void testCreateTempDirectoryPathStringFileAttributeOfQArray() {
+        assertEquals(TARGET_PATH, FilesUncheck.createTempDirectory(TARGET_PATH, PREFIX, EMPTY_FILE_ATTRIBUTES_ARRAY).getParent());
+    }
+
+    @Test
+    public void testCreateTempDirectoryStringFileAttributeOfQArray() {
+        assertEquals(PathUtils.getTempDirectory(), FilesUncheck.createTempDirectory(PREFIX, EMPTY_FILE_ATTRIBUTES_ARRAY).getParent());
+    }
+
+    @Test
+    public void testCreateTempFilePathStringStringFileAttributeOfQArray() {
+        assertEquals(TARGET_PATH, FilesUncheck.createTempFile(TARGET_PATH, PREFIX, SUFFIX, EMPTY_FILE_ATTRIBUTES_ARRAY).getParent());
+    }
+
+    @Test
+    public void testCreateTempFileStringStringFileAttributeOfQArray() {
+        assertEquals(PathUtils.getTempDirectory(), FilesUncheck.createTempFile(PREFIX, SUFFIX, EMPTY_FILE_ATTRIBUTES_ARRAY).getParent());
+    }
+
+    @Test
+    public void testDelete() {
+        assertThrows(UncheckedIOException.class, () -> FilesUncheck.delete(NEW_FILE_PATH));
+    }
+
+    @Test
+    public void testDeleteIfExists() {
+        assertFalse(FilesUncheck.deleteIfExists(NEW_FILE_PATH));
+    }
+
+    @Test
+    public void testGetAttribute() {
+        assertEquals(0L, FilesUncheck.getAttribute(FILE_PATH_EMPTY, "basic:size", LinkOption.NOFOLLOW_LINKS));
+    }
+
+    @Test
+    public void testGetFileStore() {
+        assertNotNull(FilesUncheck.getFileStore(FILE_PATH_EMPTY));
+    }
+
+    @Test
+    public void testGetLastModifiedTime() {
+        assertTrue(0 < FilesUncheck.getLastModifiedTime(FILE_PATH_EMPTY, LinkOption.NOFOLLOW_LINKS).toMillis());
+    }
+
+    @Test
+    public void testGetOwner() {
+        assertNotNull(FilesUncheck.getOwner(FILE_PATH_EMPTY, LinkOption.NOFOLLOW_LINKS));
+    }
+
+    @Test
+    public void testGetPosixFilePermissions() {
+        assumeTrue(PathUtils.isPosix(FILE_PATH_EMPTY, LinkOption.NOFOLLOW_LINKS));
+        assertNotNull(FilesUncheck.getPosixFilePermissions(FILE_PATH_EMPTY, LinkOption.NOFOLLOW_LINKS));
+    }
+
+    @Test
+    public void testIsHidden() {
+        assertFalse(FilesUncheck.isHidden(FILE_PATH_EMPTY));
+    }
+
+    @Test
+    public void testIsSameFile() {
+        assertTrue(FilesUncheck.isSameFile(FILE_PATH_EMPTY, FILE_PATH_EMPTY));
+    }
+
+    @Test
+    public void testLinesPath() {
+        assertEquals(0, FilesUncheck.lines(FILE_PATH_EMPTY).count());
+    }
+
+    @Test
+    public void testLinesPathCharset() {
+        assertEquals(0, FilesUncheck.lines(FILE_PATH_EMPTY, StandardCharsets.UTF_8).count());
+    }
+
+    @Test
+    public void testList() {
+        assertEquals(1, FilesUncheck.list(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-0")).count());
+    }
+
+    @Test
+    public void testMove() {
+        final Path tempFile1 = FilesUncheck.createTempFile(PREFIX, SUFFIX);
+        final Path tempFile2 = FilesUncheck.createTempFile(PREFIX, SUFFIX);
+        assertEquals(tempFile2, FilesUncheck.move(tempFile1, tempFile2, StandardCopyOption.REPLACE_EXISTING));
+        FilesUncheck.delete(tempFile2);
+    }
+
+    @Test
+    public void testNewBufferedReaderPath() {
+        Uncheck.run(() -> {
+            try (BufferedReader reader = FilesUncheck.newBufferedReader(FILE_PATH_EMPTY)) {
+                IOUtils.consume(reader);
+            }
+        });
+    }
+
+    @Test
+    public void testNewBufferedReaderPathCharset() {
+        Uncheck.run(() -> {
+            try (BufferedReader reader = FilesUncheck.newBufferedReader(FILE_PATH_EMPTY, StandardCharsets.UTF_8)) {
+                IOUtils.consume(reader);
+            }
+        });
+    }
+
+    @Test
+    public void testNewBufferedWriterPathCharsetOpenOptionArray() {
+        final Path tempPath = FilesUncheck.createTempFile(PREFIX, SUFFIX);
+        Uncheck.run(() -> {
+            try (BufferedWriter writer = FilesUncheck.newBufferedWriter(tempPath, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING)) {
+                writer.append("test");
+            }
+        });
+        assertEquals("test", FilesUncheck.readAllLines(tempPath, StandardCharsets.UTF_8).get(0));
+    }
+
+    @Test
+    public void testNewBufferedWriterPathOpenOptionArray() {
+        final Path tempPath = FilesUncheck.createTempFile(PREFIX, SUFFIX);
+        Uncheck.run(() -> {
+            try (BufferedWriter writer = FilesUncheck.newBufferedWriter(tempPath, StandardOpenOption.TRUNCATE_EXISTING)) {
+                writer.append("test");
+            }
+        });
+        assertEquals("test", FilesUncheck.readAllLines(tempPath).get(0));
+    }
+
+    @Test
+    public void testNewByteChannelPathOpenOptionArray() {
+        assertEquals(0, Uncheck.get(() -> {
+            try (SeekableByteChannel c = FilesUncheck.newByteChannel(FILE_PATH_EMPTY, StandardOpenOption.READ)) {
+                return c.size();
+            }
+        }));
+    }
+
+    @Test
+    public void testNewByteChannelPathSetOfQextendsOpenOptionFileAttributeOfQArray() {
+        final Set<OpenOption> options = new HashSet<>();
+        options.add(StandardOpenOption.READ);
+        assertEquals(0, Uncheck.get(() -> {
+            try (SeekableByteChannel c = FilesUncheck.newByteChannel(FILE_PATH_EMPTY, options, EMPTY_FILE_ATTRIBUTES_ARRAY)) {
+                return c.size();
+            }
+        }));
+    }
+
+    @Test
+    public void testNewDirectoryStreamPath() {
+        Uncheck.run(() -> {
+            try (final DirectoryStream<Path> directoryStream = FilesUncheck.newDirectoryStream(TARGET_PATH)) {
+                directoryStream.forEach(e -> assertEquals(TARGET_PATH, e.getParent()));
+            }
+        });
+    }
+
+    @Test
+    public void testNewDirectoryStreamPathFilterOfQsuperPath() {
+        Uncheck.run(() -> {
+            try (final DirectoryStream<Path> directoryStream = FilesUncheck.newDirectoryStream(TARGET_PATH, e -> true)) {
+                directoryStream.forEach(e -> assertEquals(TARGET_PATH, e.getParent()));
+            }
+        });
+    }
+
+    @Test
+    public void testNewDirectoryStreamPathString() {
+        Uncheck.run(() -> {
+            try (final DirectoryStream<Path> directoryStream = FilesUncheck.newDirectoryStream(TARGET_PATH, "*.xml")) {
+                directoryStream.forEach(e -> assertEquals(TARGET_PATH, e.getParent()));
+            }
+        });
+    }
+
+    @Test
+    public void testNewInputStream() {
+        assertEquals(0, Uncheck.get(() -> {
+            try (InputStream in = FilesUncheck.newInputStream(FILE_PATH_EMPTY, StandardOpenOption.READ)) {
+                return in.available();
+            }
+        }));
+    }
+
+    @Test
+    public void testNewOutputStream() {
+        final Path tempPath = FilesUncheck.createTempFile(PREFIX, SUFFIX);
+        Uncheck.run(() -> {
+            try (OutputStream stream = FilesUncheck.newOutputStream(tempPath, StandardOpenOption.TRUNCATE_EXISTING)) {
+                stream.write("test".getBytes());
+            }
+        });
+        assertEquals("test", FilesUncheck.readAllLines(tempPath).get(0));
+    }
+
+    @Test
+    public void testProbeContentType() {
+        // Empty file:
+        String probeContentType = FilesUncheck.probeContentType(FILE_PATH_EMPTY);
+        // Empirical: probeContentType is null on Windows
+        // Empirical: probeContentType is "text/plain" on Ubuntu
+        // Empirical: probeContentType is ? on macOS
+        //
+        // BOM file:
+        probeContentType = FilesUncheck.probeContentType(Paths.get("src/test/resources/org/apache/commons/io/testfileBOM.xml"));
+        // Empirical: probeContentType is "text/plain" on Windows
+        // Empirical: probeContentType is "application/plain" on Ubuntu
+        // Empirical: probeContentType is ? on macOS
+    }
+
+    @Test
+    public void testReadAllBytes() {
+        assertArrayEquals(ArrayUtils.EMPTY_BYTE_ARRAY, FilesUncheck.readAllBytes(FILE_PATH_EMPTY));
+        assertArrayEquals(new byte[] {'a'}, FilesUncheck.readAllBytes(FILE_PATH_A));
+    }
+
+    @Test
+    public void testReadAllLinesPath() {
+        assertEquals(Collections.emptyList(), FilesUncheck.readAllLines(FILE_PATH_EMPTY));
+        assertEquals(Arrays.asList("a"), FilesUncheck.readAllLines(FILE_PATH_A));
+    }
+
+    @Test
+    public void testReadAllLinesPathCharset() {
+        assertEquals(Collections.emptyList(), FilesUncheck.readAllLines(FILE_PATH_EMPTY, StandardCharsets.UTF_8));
+        assertEquals(Arrays.asList("a"), FilesUncheck.readAllLines(FILE_PATH_A, StandardCharsets.UTF_8));
+    }
+
+    @Test
+    public void testReadAttributesPathClassOfALinkOptionArray() {
+        assertNotNull(FilesUncheck.readAttributes(FILE_PATH_EMPTY, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS));
+    }
+
+    @Test
+    public void testReadAttributesPathStringLinkOptionArray() {
+        assertNotNull(FilesUncheck.readAttributes(FILE_PATH_EMPTY, "basic:lastModifiedTime", LinkOption.NOFOLLOW_LINKS));
+    }
+
+    @Test
+    public void testReadSymbolicLink() {
+        assertThrows(UncheckedIOException.class, () -> FilesUncheck.readSymbolicLink(NEW_FILE_PATH_LINK));
+    }
+
+    @Test
+    public void testSetAttribute() {
+        final FileTime ft = FilesUncheck.getLastModifiedTime(FILE_PATH_EMPTY);
+        assertEquals(FILE_PATH_EMPTY, FilesUncheck.setAttribute(FILE_PATH_EMPTY, "basic:lastModifiedTime", ft, LinkOption.NOFOLLOW_LINKS));
+    }
+
+    @Test
+    public void testSetLastModifiedTime() {
+        final FileTime ft = FilesUncheck.getLastModifiedTime(FILE_PATH_EMPTY);
+        assertEquals(FILE_PATH_EMPTY, FilesUncheck.setLastModifiedTime(FILE_PATH_EMPTY, ft));
+    }
+
+    @Test
+    public void testSetOwner() {
+        final UserPrincipal owner = FilesUncheck.getOwner(FILE_PATH_EMPTY, LinkOption.NOFOLLOW_LINKS);
+        assertEquals(FILE_PATH_EMPTY, FilesUncheck.setOwner(FILE_PATH_EMPTY, owner));
+    }
+
+    @Test
+    public void testSetPosixFilePermissions() {
+        assumeTrue(PathUtils.isPosix(FILE_PATH_EMPTY, LinkOption.NOFOLLOW_LINKS));
+        final Set<PosixFilePermission> posixFilePermissions = FilesUncheck.getPosixFilePermissions(FILE_PATH_EMPTY, LinkOption.NOFOLLOW_LINKS);
+        assertEquals(FILE_PATH_EMPTY, FilesUncheck.setPosixFilePermissions(FILE_PATH_EMPTY, posixFilePermissions));
+    }
+
+    @Test
+    public void testSize() {
+        assertEquals(0, FilesUncheck.size(FILE_PATH_EMPTY));
+        assertEquals(1, FilesUncheck.size(FILE_PATH_A));
+    }
+
+    @Test
+    public void testWalkFileTreePathFileVisitorOfQsuperPath() {
+        assertEquals(TARGET_PATH, FilesUncheck.walkFileTree(TARGET_PATH, NoopPathVisitor.INSTANCE));
+    }
+
+    @Test
+    public void testWalkFileTreePathSetOfFileVisitOptionIntFileVisitorOfQsuperPath() {
+        assertEquals(TARGET_PATH, FilesUncheck.walkFileTree(TARGET_PATH, new HashSet<>(), 1, NoopPathVisitor.INSTANCE));
+    }
+
+    @Test
+    public void testWalkPathFileVisitOptionArray() {
+        assertTrue(0 < FilesUncheck.walk(TARGET_PATH, FileVisitOption.FOLLOW_LINKS).count());
+    }
+
+    @Test
+    public void testWalkPathIntFileVisitOptionArray() {
+        assertEquals(1, FilesUncheck.walk(TARGET_PATH, 0, FileVisitOption.FOLLOW_LINKS).count());
+    }
+
+    @Test
+    public void testWritePathByteArrayOpenOptionArray() {
+        final Path tempFile = FilesUncheck.createTempFile(PREFIX, SUFFIX);
+        assertEquals(tempFile, FilesUncheck.write(tempFile, "test".getBytes(), StandardOpenOption.TRUNCATE_EXISTING));
+        FilesUncheck.delete(tempFile);
+    }
+
+    @Test
+    public void testWritePathIterableOfQextendsCharSequenceCharsetOpenOptionArray() {
+        final Path tempFile = FilesUncheck.createTempFile(PREFIX, SUFFIX);
+        assertEquals(tempFile, FilesUncheck.write(tempFile, Arrays.asList("test"), StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING));
+        FilesUncheck.delete(tempFile);
+    }
+
+    @Test
+    public void testWritePathIterableOfQextendsCharSequenceOpenOptionArray() {
+        final Path tempFile = FilesUncheck.createTempFile(PREFIX, SUFFIX);
+        assertEquals(tempFile, FilesUncheck.write(tempFile, Arrays.asList("test"), StandardOpenOption.TRUNCATE_EXISTING));
+        FilesUncheck.delete(tempFile);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/file/PathUtilsCleanDirectoryTest.java b/src/test/java/org/apache/commons/io/file/PathUtilsCleanDirectoryTest.java
new file mode 100644
index 0000000..d8bccb1
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/PathUtilsCleanDirectoryTest.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.apache.commons.io.file.CounterAssertions.assertCounts;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Tests {@link DeletingPathVisitor}.
+ */
+public class PathUtilsCleanDirectoryTest {
+
+    @TempDir
+    private Path tempDir;
+
+    /**
+     * Tests a directory with one file of size 0.
+     */
+    @Test
+    public void testCleanDirectory1FileSize0() throws IOException {
+        PathUtils.copyDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-0"), tempDir);
+        assertCounts(1, 1, 0, PathUtils.cleanDirectory(tempDir));
+    }
+
+    /**
+     * Tests a directory with one file of size 1.
+     */
+    @Test
+    public void testCleanDirectory1FileSize1() throws IOException {
+        PathUtils.copyDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1"), tempDir);
+        assertCounts(1, 1, 1, PathUtils.cleanDirectory(tempDir));
+    }
+
+    /**
+     * Tests a directory with two subdirectories, each containing one file of size 1.
+     */
+    @Test
+    public void testCleanDirectory2FileSize2() throws IOException {
+        PathUtils.copyDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-2-file-size-2"), tempDir);
+        assertCounts(3, 2, 2, PathUtils.cleanDirectory(tempDir));
+    }
+
+    /**
+     * Tests an empty folder.
+     */
+    @Test
+    public void testCleanEmptyDirectory() throws IOException {
+        assertCounts(1, 0, 0, PathUtils.cleanDirectory(tempDir));
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/file/PathUtilsContentEqualsTest.java b/src/test/java/org/apache/commons/io/file/PathUtilsContentEqualsTest.java
new file mode 100644
index 0000000..23347d6
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/PathUtilsContentEqualsTest.java
@@ -0,0 +1,196 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class PathUtilsContentEqualsTest {
+
+    @TempDir
+    public File temporaryFolder;
+
+    private String getName() {
+        return this.getClass().getSimpleName();
+    }
+
+    @Test
+    public void testDirectoryAndFileContentEquals() throws Exception {
+        // Non-existent files
+        final Path path1 = new File(temporaryFolder, getName()).toPath();
+        final Path path2 = new File(temporaryFolder, getName() + "2").toPath();
+        assertTrue(PathUtils.directoryAndFileContentEquals(null, null));
+        assertFalse(PathUtils.directoryAndFileContentEquals(null, path1));
+        assertFalse(PathUtils.directoryAndFileContentEquals(path1, null));
+        // both don't exist
+        assertTrue(PathUtils.directoryAndFileContentEquals(path1, path1));
+        assertTrue(PathUtils.directoryAndFileContentEquals(path1, path2));
+        assertTrue(PathUtils.directoryAndFileContentEquals(path2, path2));
+        assertTrue(PathUtils.directoryAndFileContentEquals(path2, path1));
+        // Tree equals true tests
+        {
+            // Trees of files only that contain the same files.
+            final Path dir1 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-files-only/directory-files-only1");
+            final Path dir2 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-files-only/directory-files-only2");
+            assertTrue(PathUtils.directoryAndFileContentEquals(dir1, dir2));
+            assertTrue(PathUtils.directoryAndFileContentEquals(dir2, dir2));
+            assertTrue(PathUtils.directoryAndFileContentEquals(dir1, dir1));
+            assertTrue(PathUtils.directoryAndFileContentEquals(dir2, dir2));
+        }
+        {
+            // Trees of directories containing other directories.
+            final Path dir1 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/dir1");
+            final Path dir2 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/dir2");
+            assertTrue(PathUtils.directoryAndFileContentEquals(dir1, dir2));
+            assertTrue(PathUtils.directoryAndFileContentEquals(dir2, dir2));
+            assertTrue(PathUtils.directoryAndFileContentEquals(dir1, dir1));
+            assertTrue(PathUtils.directoryAndFileContentEquals(dir2, dir2));
+        }
+        {
+            // Trees of directories containing other directories and files.
+            final Path dir1 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1");
+            final Path dir2 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1");
+            assertTrue(PathUtils.directoryAndFileContentEquals(dir1, dir2));
+            assertTrue(PathUtils.directoryAndFileContentEquals(dir2, dir2));
+            assertTrue(PathUtils.directoryAndFileContentEquals(dir1, dir1));
+            assertTrue(PathUtils.directoryAndFileContentEquals(dir2, dir2));
+        }
+        // Tree equals false tests
+        {
+            final Path dir1 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1/directory-files-only1");
+            final Path dir2 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1/");
+            assertFalse(PathUtils.directoryAndFileContentEquals(dir1, dir2));
+            assertFalse(PathUtils.directoryAndFileContentEquals(dir2, dir1));
+        }
+        {
+            final Path dir1 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-dirs-and-files");
+            final Path dir2 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-dirs-then-files");
+            assertFalse(PathUtils.directoryAndFileContentEquals(dir1, dir2));
+            assertFalse(PathUtils.directoryAndFileContentEquals(dir2, dir1));
+        }
+    }
+
+    @Test
+    public void testDirectoryContentEquals() throws Exception {
+        // Non-existent files
+        final Path path1 = new File(temporaryFolder, getName()).toPath();
+        final Path path2 = new File(temporaryFolder, getName() + "2").toPath();
+        assertTrue(PathUtils.directoryContentEquals(null, null));
+        assertFalse(PathUtils.directoryContentEquals(null, path1));
+        assertFalse(PathUtils.directoryContentEquals(path1, null));
+        // both don't exist
+        assertTrue(PathUtils.directoryContentEquals(path1, path1));
+        assertTrue(PathUtils.directoryContentEquals(path1, path2));
+        assertTrue(PathUtils.directoryContentEquals(path2, path2));
+        assertTrue(PathUtils.directoryContentEquals(path2, path1));
+        // Tree equals true tests
+        {
+            // Trees of files only that contain the same files.
+            final Path dir1 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-files-only/directory-files-only1");
+            final Path dir2 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-files-only/directory-files-only2");
+            assertTrue(PathUtils.directoryContentEquals(dir1, dir2));
+            assertTrue(PathUtils.directoryContentEquals(dir2, dir2));
+            assertTrue(PathUtils.directoryContentEquals(dir1, dir1));
+            assertTrue(PathUtils.directoryContentEquals(dir2, dir2));
+        }
+        {
+            // Trees of directories containing other directories.
+            final Path dir1 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/dir1");
+            final Path dir2 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/dir2");
+            assertTrue(PathUtils.directoryContentEquals(dir1, dir2));
+            assertTrue(PathUtils.directoryContentEquals(dir2, dir2));
+            assertTrue(PathUtils.directoryContentEquals(dir1, dir1));
+            assertTrue(PathUtils.directoryContentEquals(dir2, dir2));
+        }
+        {
+            // Trees of directories containing other directories and files.
+            final Path dir1 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1");
+            final Path dir2 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1");
+            assertTrue(PathUtils.directoryContentEquals(dir1, dir2));
+            assertTrue(PathUtils.directoryContentEquals(dir2, dir2));
+            assertTrue(PathUtils.directoryContentEquals(dir1, dir1));
+            assertTrue(PathUtils.directoryContentEquals(dir2, dir2));
+        }
+        // Tree equals false tests
+        {
+            final Path dir1 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1/directory-files-only1");
+            final Path dir2 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1/");
+            assertFalse(PathUtils.directoryContentEquals(dir1, dir2));
+            assertFalse(PathUtils.directoryContentEquals(dir2, dir1));
+        }
+        {
+            final Path dir1 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-dirs-and-files");
+            final Path dir2 = Paths.get("src/test/resources/dir-equals-tests/dir-equals-dirs-then-files");
+            assertFalse(PathUtils.directoryContentEquals(dir1, dir2));
+            assertFalse(PathUtils.directoryContentEquals(dir2, dir1));
+        }
+    }
+
+    @Test
+    public void testFileContentEquals() throws Exception {
+        // Non-existent files
+        final Path path1 = new File(temporaryFolder, getName()).toPath();
+        final Path path2 = new File(temporaryFolder, getName() + "2").toPath();
+        assertTrue(PathUtils.fileContentEquals(null, null));
+        assertFalse(PathUtils.fileContentEquals(null, path1));
+        assertFalse(PathUtils.fileContentEquals(path1, null));
+        // both don't exist
+        assertTrue(PathUtils.fileContentEquals(path1, path1));
+        assertTrue(PathUtils.fileContentEquals(path1, path2));
+        assertTrue(PathUtils.fileContentEquals(path2, path2));
+        assertTrue(PathUtils.fileContentEquals(path2, path1));
+
+        // Directories
+        assertThrows(IOException.class, () -> PathUtils.fileContentEquals(temporaryFolder.toPath(), temporaryFolder.toPath()));
+
+        // Different files
+        final Path objFile1 = Paths.get(temporaryFolder.getAbsolutePath(), getName() + ".object");
+        PathUtils.copyFile(getClass().getResource("/java/lang/Object.class"), objFile1);
+
+        final Path objFile1b = Paths.get(temporaryFolder.getAbsolutePath(), getName() + ".object2");
+        PathUtils.copyFile(getClass().getResource("/java/lang/Object.class"), objFile1b);
+
+        final Path objFile2 = Paths.get(temporaryFolder.getAbsolutePath(), getName() + ".collection");
+        PathUtils.copyFile(getClass().getResource("/java/util/Collection.class"), objFile2);
+
+        assertFalse(PathUtils.fileContentEquals(objFile1, objFile2));
+        assertFalse(PathUtils.fileContentEquals(objFile1b, objFile2));
+        assertTrue(PathUtils.fileContentEquals(objFile1, objFile1b));
+
+        assertTrue(PathUtils.fileContentEquals(objFile1, objFile1));
+        assertTrue(PathUtils.fileContentEquals(objFile1b, objFile1b));
+        assertTrue(PathUtils.fileContentEquals(objFile2, objFile2));
+
+        // Equal files
+        Files.createFile(path1);
+        Files.createFile(path2);
+        assertTrue(PathUtils.fileContentEquals(path1, path1));
+        assertTrue(PathUtils.fileContentEquals(path1, path2));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/file/PathUtilsCountingTest.java b/src/test/java/org/apache/commons/io/file/PathUtilsCountingTest.java
new file mode 100644
index 0000000..0092bd0
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/PathUtilsCountingTest.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.apache.commons.io.file.CounterAssertions.assertCounts;
+
+import java.io.IOException;
+import java.nio.file.Paths;
+
+import org.apache.commons.io.file.Counters.PathCounters;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link PathUtils}.
+ */
+public class PathUtilsCountingTest {
+
+    /**
+     * Tests an empty folder.
+     */
+    @Test
+    public void testCountEmptyFolder() throws IOException {
+        try (TempDirectory tempDir = TempDirectory.create(getClass().getCanonicalName())) {
+            final PathCounters pathCounts = PathUtils.countDirectory(tempDir.get());
+            assertCounts(1, 0, 0, pathCounts);
+        }
+    }
+
+    /**
+     * Tests a directory with one file of size 0.
+     */
+    @Test
+    public void testCountFolders1FileSize0() throws IOException {
+        final PathCounters pathCounts = PathUtils
+                .countDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-0"));
+        assertCounts(1, 1, 0, pathCounts);
+    }
+
+    /**
+     * Tests a directory with one file of size 1.
+     */
+    @Test
+    public void testCountFolders1FileSize1() throws IOException {
+        final PathCounters visitor = PathUtils
+                .countDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1"));
+        assertCounts(1, 1, 1, visitor);
+    }
+
+    /**
+     * Tests a directory with two subdirectories, each containing one file of size 1.
+     */
+    @Test
+    public void testCountFolders2FileSize2() throws IOException {
+        final PathCounters pathCounts = PathUtils
+                .countDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-2-file-size-2"));
+        assertCounts(3, 2, 2, pathCounts);
+    }
+
+    /**
+     * Tests a directory with two subdirectories, each containing one file of size 2.
+     */
+    @Test
+    public void testCountFolders2FileSize4() throws IOException {
+        final PathCounters pathCounts = PathUtils
+                .countDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-2-file-size-4"));
+        assertCounts(3, 4, 8, pathCounts);
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/file/PathUtilsDeleteDirectoryTest.java b/src/test/java/org/apache/commons/io/file/PathUtilsDeleteDirectoryTest.java
new file mode 100644
index 0000000..121b2dc
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/PathUtilsDeleteDirectoryTest.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.apache.commons.io.file.CounterAssertions.assertCounts;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link DeletingPathVisitor}.
+ */
+public class PathUtilsDeleteDirectoryTest extends AbstractTempDirTest {
+
+    /**
+     * Tests a directory with one file of size 0.
+     */
+    @Test
+    public void testDeleteDirectory1FileSize0() throws IOException {
+        PathUtils.copyDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-0"), tempDirPath);
+        assertCounts(1, 1, 0, PathUtils.deleteDirectory(tempDirPath));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDirPath);
+    }
+
+    /**
+     * Tests a directory with one file of size 0.
+     */
+    private void testDeleteDirectory1FileSize0(final DeleteOption... options) throws IOException {
+        // TODO Setup the test to use FileVisitOption.
+        PathUtils.copyDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-0"), tempDirPath);
+        assertCounts(1, 1, 0, PathUtils.deleteDirectory(tempDirPath, options));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDirPath);
+    }
+
+    @Test
+    public void testDeleteDirectory1FileSize0NoOptions() throws IOException {
+        testDeleteDirectory1FileSize0(PathUtils.EMPTY_DELETE_OPTION_ARRAY);
+    }
+
+    @Test
+    public void testDeleteDirectory1FileSize0OverrideReadOnly() throws IOException {
+        testDeleteDirectory1FileSize0(StandardDeleteOption.OVERRIDE_READ_ONLY);
+    }
+
+    /**
+     * Tests a directory with one file of size 1.
+     */
+    @Test
+    public void testDeleteDirectory1FileSize1() throws IOException {
+        PathUtils.copyDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1"), tempDirPath);
+        assertCounts(1, 1, 1, PathUtils.deleteDirectory(tempDirPath));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDirPath);
+    }
+
+    /**
+     * Tests a directory with two subdirectories, each containing one file of size 1.
+     */
+    @Test
+    public void testDeleteDirectory2FileSize2() throws IOException {
+        PathUtils.copyDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-2-file-size-2"), tempDirPath);
+        assertCounts(3, 2, 2, PathUtils.deleteDirectory(tempDirPath));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDirPath);
+    }
+
+    /**
+     * Tests an empty folder.
+     */
+    @Test
+    public void testDeleteEmptyDirectory() throws IOException {
+        assertCounts(1, 0, 0, PathUtils.deleteDirectory(tempDirPath));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDirPath);
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/file/PathUtilsDeleteFileTest.java b/src/test/java/org/apache/commons/io/file/PathUtilsDeleteFileTest.java
new file mode 100644
index 0000000..6383f64
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/PathUtilsDeleteFileTest.java
@@ -0,0 +1,162 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.apache.commons.io.file.CounterAssertions.assertCounts;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeFalse;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import org.apache.commons.io.file.Counters.PathCounters;
+import org.apache.commons.lang3.SystemUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link DeletingPathVisitor}.
+ */
+public class PathUtilsDeleteFileTest {
+
+    private Path tempDir;
+
+    @AfterEach
+    public void afterEach() throws IOException {
+        // backstop
+        if (Files.exists(tempDir) && PathUtils.isEmptyDirectory(tempDir)) {
+            Files.deleteIfExists(tempDir);
+        }
+    }
+
+    @BeforeEach
+    public void beforeEach() throws IOException {
+        tempDir = Files.createTempDirectory(getClass().getCanonicalName());
+    }
+
+    @Test
+    public void testDeleteBrokenLink() throws IOException {
+        assumeFalse(SystemUtils.IS_OS_WINDOWS);
+
+        final Path missingFile = tempDir.resolve("missing.txt");
+        final Path brokenLink = tempDir.resolve("broken.txt");
+        Files.createSymbolicLink(brokenLink, missingFile);
+
+        assertTrue(Files.exists(brokenLink, LinkOption.NOFOLLOW_LINKS));
+        assertFalse(Files.exists(missingFile, LinkOption.NOFOLLOW_LINKS));
+
+        PathUtils.deleteFile(brokenLink);
+
+        assertFalse(Files.exists(brokenLink, LinkOption.NOFOLLOW_LINKS), "Symbolic link not removed");
+    }
+
+    /**
+     * Tests a directory with one file of size 0.
+     */
+    @Test
+    public void testDeleteFileDirectory1FileSize0() throws IOException {
+        final String fileName = "file-size-0.bin";
+        PathUtils.copyFileToDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-0/" + fileName), tempDir);
+        assertCounts(0, 1, 0, PathUtils.deleteFile(tempDir.resolve(fileName)));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDir);
+    }
+
+    /**
+     * Tests a directory with one file of size 1.
+     */
+    @Test
+    public void testDeleteFileDirectory1FileSize1() throws IOException {
+        final String fileName = "file-size-1.bin";
+        PathUtils.copyFileToDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1/" + fileName), tempDir);
+        assertCounts(0, 1, 1, PathUtils.deleteFile(tempDir.resolve(fileName)));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDir);
+    }
+
+    /**
+     * Tests a file that does not exist.
+     */
+    @Test
+    public void testDeleteFileDoesNotExist() throws IOException {
+        testDeleteFileEmpty(PathUtils.deleteFile(tempDir.resolve("file-does-not-exist.bin")));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDir);
+    }
+
+    private void testDeleteFileEmpty(final PathCounters pathCounts) {
+        assertCounts(0, 0, 0, pathCounts);
+    }
+
+    /**
+     * Tests an empty folder.
+     */
+    @Test
+    public void testDeleteFileEmptyDirectory() throws IOException {
+        Assertions.assertThrows(NoSuchFileException.class, () -> testDeleteFileEmpty(PathUtils.deleteFile(tempDir)));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDir);
+    }
+
+    /**
+     * Tests a directory with one file of size 1.
+     */
+    @Test
+    public void testDeleteReadOnlyFileDirectory1FileSize1() throws IOException {
+        final String fileName = "file-size-1.bin";
+        PathUtils.copyFileToDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1/" + fileName), tempDir);
+        final Path resolved = tempDir.resolve(fileName);
+        PathUtils.setReadOnly(resolved, true);
+        if (SystemUtils.IS_OS_WINDOWS) {
+            // Fails on Windows's Ubuntu subsystem.
+            assertFalse(Files.isWritable(resolved));
+            assertThrows(IOException.class, () -> PathUtils.deleteFile(resolved));
+        }
+        assertCounts(0, 1, 1, PathUtils.deleteFile(resolved, StandardDeleteOption.OVERRIDE_READ_ONLY));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDir);
+    }
+
+    /**
+     * Tests a directory with one file of size 1.
+     */
+    @Test
+    public void testSetReadOnlyFileDirectory1FileSize1() throws IOException {
+        final String fileName = "file-size-1.bin";
+        PathUtils.copyFileToDirectory(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1/" + fileName), tempDir);
+        final Path resolved = tempDir.resolve(fileName);
+        PathUtils.setReadOnly(resolved, true);
+        if (SystemUtils.IS_OS_WINDOWS) {
+            // Fails on Windows's Ubuntu subsystem.
+            assertFalse(Files.isWritable(resolved));
+            assertThrows(IOException.class, () -> PathUtils.deleteFile(resolved));
+        }
+        PathUtils.setReadOnly(resolved, false);
+        PathUtils.deleteFile(resolved);
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDir);
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/file/PathUtilsDeleteTest.java b/src/test/java/org/apache/commons/io/file/PathUtilsDeleteTest.java
new file mode 100644
index 0000000..f916102
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/PathUtilsDeleteTest.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.apache.commons.io.file.CounterAssertions.assertCounts;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.file.Counters.PathCounters;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link DeletingPathVisitor}.
+ */
+public class PathUtilsDeleteTest extends AbstractTempDirTest {
+
+    @Test
+    public void testDeleteDirectory1FileSize0() throws IOException {
+        final String fileName = "file-size-0.bin";
+        FileUtils.copyFileToDirectory(
+            Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-0/" + fileName).toFile(),
+            tempDirPath.toFile());
+        assertCounts(0, 1, 0, PathUtils.delete(tempDirPath.resolve(fileName)));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDirPath);
+    }
+
+    private void testDeleteDirectory1FileSize0(final DeleteOption... options) throws IOException {
+        final String fileName = "file-size-0.bin";
+        FileUtils.copyFileToDirectory(
+            Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-0/" + fileName).toFile(),
+            tempDirPath.toFile());
+        assertCounts(0, 1, 0, PathUtils.delete(tempDirPath.resolve(fileName), options));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDirPath);
+    }
+
+    /**
+     * Tests a directory with one file of size 0.
+     */
+    @Test
+    public void testDeleteDirectory1FileSize0ForceOff() throws IOException {
+        testDeleteDirectory1FileSize0();
+    }
+
+    /**
+     * Tests a directory with one file of size 0.
+     */
+    @Test
+    public void testDeleteDirectory1FileSize0ForceOn() throws IOException {
+        testDeleteDirectory1FileSize0();
+    }
+
+    @Test
+    public void testDeleteDirectory1FileSize0NoOption() throws IOException {
+        testDeleteDirectory1FileSize0(PathUtils.EMPTY_DELETE_OPTION_ARRAY);
+    }
+
+    @Test
+    public void testDeleteDirectory1FileSize0OverrideReadonly() throws IOException {
+        testDeleteDirectory1FileSize0(StandardDeleteOption.OVERRIDE_READ_ONLY);
+    }
+
+    @Test
+    public void testDeleteDirectory1FileSize1() throws IOException {
+        final String fileName = "file-size-1.bin";
+        FileUtils.copyFileToDirectory(
+            Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1/" + fileName).toFile(),
+            tempDirPath.toFile());
+        assertCounts(0, 1, 1, PathUtils.delete(tempDirPath.resolve(fileName)));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDirPath);
+    }
+
+    private void testDeleteDirectory1FileSize1(final DeleteOption... options) throws IOException {
+        // TODO Setup the test to use LinkOption.
+        final String fileName = "file-size-1.bin";
+        FileUtils.copyFileToDirectory(
+            Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1/" + fileName).toFile(),
+            tempDirPath.toFile());
+        assertCounts(0, 1, 1, PathUtils.delete(tempDirPath.resolve(fileName), options));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDirPath);
+    }
+
+    /**
+     * Tests a directory with one file of size 1.
+     */
+    @Test
+    public void testDeleteDirectory1FileSize1ForceOff() throws IOException {
+        testDeleteDirectory1FileSize1();
+    }
+
+    /**
+     * Tests a directory with one file of size 1.
+     */
+    @Test
+    public void testDeleteDirectory1FileSize1ForceOn() throws IOException {
+        testDeleteDirectory1FileSize1();
+    }
+
+    @Test
+    public void testDeleteDirectory1FileSize1NoOption() throws IOException {
+        testDeleteDirectory1FileSize1(PathUtils.EMPTY_DELETE_OPTION_ARRAY);
+    }
+
+    @Test
+    public void testDeleteDirectory1FileSize1OverrideReadOnly() throws IOException {
+        testDeleteDirectory1FileSize1(StandardDeleteOption.OVERRIDE_READ_ONLY);
+    }
+
+    /**
+     * Tests an empty folder.
+     */
+    @Test
+    public void testDeleteEmptyDirectory() throws IOException {
+        testDeleteEmptyDirectory(PathUtils.delete(tempDirPath));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDirPath);
+    }
+
+    /**
+     * Tests an empty folder.
+     */
+    private void testDeleteEmptyDirectory(final DeleteOption... options) throws IOException {
+        testDeleteEmptyDirectory(PathUtils.delete(tempDirPath, options));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDirPath);
+    }
+
+    private void testDeleteEmptyDirectory(final PathCounters pathCounts) {
+        assertCounts(1, 0, 0, pathCounts);
+    }
+
+    /**
+     * Tests an empty folder.
+     */
+    @Test
+    public void testDeleteEmptyDirectoryForceOff() throws IOException {
+        testDeleteEmptyDirectory();
+    }
+
+    /**
+     * Tests an empty folder.
+     */
+    @Test
+    public void testDeleteEmptyDirectoryForceOn() throws IOException {
+        testDeleteEmptyDirectory();
+    }
+
+    @Test
+    public void testDeleteEmptyDirectoryNoOption() throws IOException {
+        testDeleteEmptyDirectory(PathUtils.EMPTY_DELETE_OPTION_ARRAY);
+    }
+
+    @Test
+    public void testDeleteEmptyDirectoryOverrideReadOnly() throws IOException {
+        testDeleteEmptyDirectory(StandardDeleteOption.OVERRIDE_READ_ONLY);
+    }
+
+    /**
+     * Tests a file that does not exist.
+     */
+    @Test
+    public void testDeleteFileDoesNotExist() throws IOException {
+        assertCounts(0, 0, 0, PathUtils.deleteFile(tempDirPath.resolve("file-does-not-exist.bin")));
+        // This will throw if not empty.
+        Files.deleteIfExists(tempDirPath);
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/file/PathUtilsIsEmptyTest.java b/src/test/java/org/apache/commons/io/file/PathUtilsIsEmptyTest.java
new file mode 100644
index 0000000..e5be730
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/PathUtilsIsEmptyTest.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link PathUtils}.
+ */
+public class PathUtilsIsEmptyTest {
+
+    public static final Path DIR_SIZE_1 = Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1");
+
+    private static final Path FILE_SIZE_0 = Paths
+            .get("src/test/resources/org/apache/commons/io/dirs-1-file-size-0/file-size-0.bin");
+
+    private static final Path FILE_SIZE_1 = Paths
+            .get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1/file-size-1.bin");
+
+    @Test
+    public void testIsEmpty() throws IOException {
+        Assertions.assertTrue(PathUtils.isEmpty(FILE_SIZE_0));
+        Assertions.assertFalse(PathUtils.isEmpty(FILE_SIZE_1));
+        try (TempDirectory tempDir = TempDirectory.create(getClass().getCanonicalName())) {
+            Assertions.assertTrue(PathUtils.isEmpty(tempDir.get()));
+        }
+        Assertions.assertFalse(PathUtils.isEmpty(DIR_SIZE_1));
+    }
+
+    @Test
+    public void testIsEmptyDirectory() throws IOException {
+        try (TempDirectory tempDir = TempDirectory.create(getClass().getCanonicalName())) {
+            Assertions.assertTrue(PathUtils.isEmptyDirectory(tempDir.get()));
+        }
+        Assertions.assertFalse(PathUtils.isEmptyDirectory(DIR_SIZE_1));
+    }
+
+    @Test
+    public void testisEmptyFile() throws IOException {
+        Assertions.assertTrue(PathUtils.isEmptyFile(FILE_SIZE_0));
+        Assertions.assertFalse(PathUtils.isEmptyFile(FILE_SIZE_1));
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/file/PathUtilsTest.java b/src/test/java/org/apache/commons/io/file/PathUtilsTest.java
new file mode 100644
index 0000000..12c1ec2
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/PathUtilsTest.java
@@ -0,0 +1,519 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertThrowsExactly;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeFalse;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryStream;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.DosFileAttributeView;
+import java.nio.file.attribute.FileTime;
+import java.nio.file.attribute.PosixFileAttributes;
+import java.util.GregorianCalendar;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.io.filefilter.NameFileFilter;
+import org.apache.commons.io.test.TestUtils;
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.SystemUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link PathUtils}.
+ */
+public class PathUtilsTest extends AbstractTempDirTest {
+
+    private static final String STRING_FIXTURE = "Hello World";
+
+    private static final byte[] BYTE_ARRAY_FIXTURE = STRING_FIXTURE.getBytes(StandardCharsets.UTF_8);
+
+    private static final String TEST_JAR_NAME = "test.jar";
+
+    private static final String TEST_JAR_PATH = "src/test/resources/org/apache/commons/io/test.jar";
+
+    private static final String PATH_FIXTURE = "NOTICE.txt";
+
+    /**
+     * Creates directory test fixtures.
+     * <ol>
+     * <li>tempDirPath/subdir</li>
+     * <li>tempDirPath/symlinked-dir -> tempDirPath/subdir</li>
+     * </ol>
+     *
+     * @return Path to tempDirPath/subdir
+     * @throws IOException if an I/O error occurs or the parent directory does not exist.
+     */
+    private Path createTempSymlinkedRelativeDir() throws IOException {
+        final Path targetDir = tempDirPath.resolve("subdir");
+        final Path symlinkDir = tempDirPath.resolve("symlinked-dir");
+        Files.createDirectory(targetDir);
+        Files.createSymbolicLink(symlinkDir, targetDir);
+        return symlinkDir;
+    }
+
+    private Path current() {
+        return PathUtils.current();
+    }
+
+    private Long getLastModifiedMillis(final Path file) throws IOException {
+        return Files.getLastModifiedTime(file).toMillis();
+    }
+
+    private Path getNonExistantPath() {
+        return Paths.get("/does not exist/for/certain");
+    }
+
+    private FileSystem openArchive(final Path p, final boolean createNew) throws IOException {
+        if (createNew) {
+            final Map<String, String> env = new HashMap<>();
+            env.put("create", "true");
+            final URI fileUri = p.toAbsolutePath().toUri();
+            final URI uri = URI.create("jar:" + fileUri.toASCIIString());
+            return FileSystems.newFileSystem(uri, env, null);
+        }
+        return FileSystems.newFileSystem(p, (ClassLoader) null);
+    }
+
+    private void setLastModifiedMillis(final Path file, final long millis) throws IOException {
+        Files.setLastModifiedTime(file, FileTime.fromMillis(millis));
+    }
+
+    @Test
+    public void testCopyDirectoryForDifferentFilesystemsWithAbsolutePath() throws IOException {
+        final Path archivePath = Paths.get(TEST_JAR_PATH);
+        try (FileSystem archive = openArchive(archivePath, false)) {
+            // relative jar -> absolute dir
+            Path sourceDir = archive.getPath("dir1");
+            PathUtils.copyDirectory(sourceDir, tempDirPath);
+            assertTrue(Files.exists(tempDirPath.resolve("f1")));
+
+            // absolute jar -> absolute dir
+            sourceDir = archive.getPath("/next");
+            PathUtils.copyDirectory(sourceDir, tempDirPath);
+            assertTrue(Files.exists(tempDirPath.resolve("dir")));
+        }
+    }
+
+    @Test
+    public void testCopyDirectoryForDifferentFilesystemsWithAbsolutePathReverse() throws IOException {
+        try (FileSystem archive = openArchive(tempDirPath.resolve(TEST_JAR_NAME), true)) {
+            // absolute dir -> relative jar
+            Path targetDir = archive.getPath("target");
+            Files.createDirectory(targetDir);
+            final Path sourceDir = Paths.get("src/test/resources/org/apache/commons/io/dirs-2-file-size-2").toAbsolutePath();
+            PathUtils.copyDirectory(sourceDir, targetDir);
+            assertTrue(Files.exists(targetDir.resolve("dirs-a-file-size-1")));
+
+            // absolute dir -> absolute jar
+            targetDir = archive.getPath("/");
+            PathUtils.copyDirectory(sourceDir, targetDir);
+            assertTrue(Files.exists(targetDir.resolve("dirs-a-file-size-1")));
+        }
+    }
+
+    @Test
+    public void testCopyDirectoryForDifferentFilesystemsWithRelativePath() throws IOException {
+        final Path archivePath = Paths.get(TEST_JAR_PATH);
+        try (FileSystem archive = openArchive(archivePath, false); final FileSystem targetArchive = openArchive(tempDirPath.resolve(TEST_JAR_NAME), true)) {
+            final Path targetDir = targetArchive.getPath("targetDir");
+            Files.createDirectory(targetDir);
+            // relative jar -> relative dir
+            Path sourceDir = archive.getPath("next");
+            PathUtils.copyDirectory(sourceDir, targetDir);
+            assertTrue(Files.exists(targetDir.resolve("dir")));
+
+            // absolute jar -> relative dir
+            sourceDir = archive.getPath("/dir1");
+            PathUtils.copyDirectory(sourceDir, targetDir);
+            assertTrue(Files.exists(targetDir.resolve("f1")));
+        }
+    }
+
+    @Test
+    public void testCopyDirectoryForDifferentFilesystemsWithRelativePathReverse() throws IOException {
+        try (FileSystem archive = openArchive(tempDirPath.resolve(TEST_JAR_NAME), true)) {
+            // relative dir -> relative jar
+            Path targetDir = archive.getPath("target");
+            Files.createDirectory(targetDir);
+            final Path sourceDir = Paths.get("src/test/resources/org/apache/commons/io/dirs-2-file-size-2");
+            PathUtils.copyDirectory(sourceDir, targetDir);
+            assertTrue(Files.exists(targetDir.resolve("dirs-a-file-size-1")));
+
+            // relative dir -> absolute jar
+            targetDir = archive.getPath("/");
+            PathUtils.copyDirectory(sourceDir, targetDir);
+            assertTrue(Files.exists(targetDir.resolve("dirs-a-file-size-1")));
+        }
+    }
+
+    @Test
+    public void testCopyFile() throws IOException {
+        final Path sourceFile = Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1/file-size-1.bin");
+        final Path targetFile = PathUtils.copyFileToDirectory(sourceFile, tempDirPath);
+        assertTrue(Files.exists(targetFile));
+        assertEquals(Files.size(sourceFile), Files.size(targetFile));
+    }
+
+    @Test
+    public void testCopyURL() throws IOException {
+        final Path sourceFile = Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1/file-size-1.bin");
+        final URL url = new URL("file:///" + FilenameUtils.getPath(sourceFile.toAbsolutePath().toString()) + sourceFile.getFileName());
+        final Path targetFile = PathUtils.copyFileToDirectory(url, tempDirPath);
+        assertTrue(Files.exists(targetFile));
+        assertEquals(Files.size(sourceFile), Files.size(targetFile));
+    }
+
+    @Test
+    public void testCreateDirectoriesAlreadyExists() throws IOException {
+        assertEquals(tempDirPath.getParent(), PathUtils.createParentDirectories(tempDirPath));
+    }
+
+    @SuppressWarnings("resource") // FileSystems.getDefault() is a singleton
+    @Test
+    public void testCreateDirectoriesForRoots() throws IOException {
+        for (final Path path : FileSystems.getDefault().getRootDirectories()) {
+            final Path parent = path.getParent();
+            assertNull(parent);
+            assertEquals(parent, PathUtils.createParentDirectories(path));
+        }
+    }
+
+    @Test
+    public void testCreateDirectoriesForRootsLinkOptionNull() throws IOException {
+        for (final File f : File.listRoots()) {
+            final Path path = f.toPath();
+            assertEquals(path.getParent(), PathUtils.createParentDirectories(path, (LinkOption) null));
+        }
+    }
+
+    @Test
+    public void testCreateDirectoriesNew() throws IOException {
+        assertEquals(tempDirPath, PathUtils.createParentDirectories(tempDirPath.resolve("child")));
+    }
+
+    @Test
+    public void testCreateDirectoriesSymlink() throws IOException {
+        final Path symlinkedDir = createTempSymlinkedRelativeDir();
+        final String leafDirName = "child";
+        final Path newDirFollowed = PathUtils.createParentDirectories(symlinkedDir.resolve(leafDirName), PathUtils.NULL_LINK_OPTION);
+        assertEquals(Files.readSymbolicLink(symlinkedDir), newDirFollowed);
+    }
+
+    @Test
+    public void testCreateDirectoriesSymlinkClashing() throws IOException {
+        final Path symlinkedDir = createTempSymlinkedRelativeDir();
+        assertThrowsExactly(FileAlreadyExistsException.class, () -> PathUtils.createParentDirectories(symlinkedDir.resolve("child")));
+    }
+
+    @Test
+    public void testGetLastModifiedFileTime_File_Present() throws IOException {
+        assertNotNull(PathUtils.getLastModifiedFileTime(current().toFile()));
+    }
+
+    @Test
+    public void testGetLastModifiedFileTime_Path_Absent() throws IOException {
+        assertNull(PathUtils.getLastModifiedFileTime(getNonExistantPath()));
+    }
+
+    @Test
+    public void testGetLastModifiedFileTime_Path_FileTime_Absent() throws IOException {
+        final FileTime fromMillis = FileTime.fromMillis(0);
+        assertEquals(fromMillis, PathUtils.getLastModifiedFileTime(getNonExistantPath(), fromMillis));
+    }
+
+    @Test
+    public void testGetLastModifiedFileTime_Path_Present() throws IOException {
+        assertNotNull(PathUtils.getLastModifiedFileTime(current()));
+    }
+
+    @Test
+    public void testGetLastModifiedFileTime_URI_Present() throws IOException {
+        assertNotNull(PathUtils.getLastModifiedFileTime(current().toUri()));
+    }
+
+    @Test
+    public void testGetLastModifiedFileTime_URL_Present() throws IOException, URISyntaxException {
+        assertNotNull(PathUtils.getLastModifiedFileTime(current().toUri().toURL()));
+    }
+
+    @Test
+    public void testGetTempDirectory() {
+        final Path tempDirectory = Paths.get(System.getProperty("java.io.tmpdir"));
+        assertEquals(tempDirectory, PathUtils.getTempDirectory());
+    }
+
+    @Test
+    public void testIsDirectory() throws IOException {
+        assertFalse(PathUtils.isDirectory(null));
+
+        assertTrue(PathUtils.isDirectory(tempDirPath));
+        try (TempFile testFile1 = TempFile.create(tempDirPath, "prefix", null)) {
+            assertFalse(PathUtils.isDirectory(testFile1.get()));
+
+            Path ref = null;
+            try (TempDirectory tempDir = TempDirectory.create(getClass().getCanonicalName())) {
+                ref = tempDir.get();
+                assertTrue(PathUtils.isDirectory(tempDir.get()));
+            }
+            assertFalse(PathUtils.isDirectory(ref));
+        }
+    }
+
+    @Test
+    public void testIsPosix() throws IOException {
+        boolean isPosix;
+        try {
+            Files.getPosixFilePermissions(current());
+            isPosix = true;
+        } catch (final UnsupportedOperationException e) {
+            isPosix = false;
+        }
+        assertEquals(isPosix, PathUtils.isPosix(current()));
+    }
+
+    @Test
+    public void testIsRegularFile() throws IOException {
+        assertFalse(PathUtils.isRegularFile(null));
+
+        assertFalse(PathUtils.isRegularFile(tempDirPath));
+        try (TempFile testFile1 = TempFile.create(tempDirPath, "prefix", null)) {
+            assertTrue(PathUtils.isRegularFile(testFile1.get()));
+
+            Files.delete(testFile1.get());
+            assertFalse(PathUtils.isRegularFile(testFile1.get()));
+        }
+    }
+
+    @Test
+    public void testNewDirectoryStream() throws Exception {
+        final PathFilter pathFilter = new NameFileFilter(PATH_FIXTURE);
+        try (DirectoryStream<Path> stream = PathUtils.newDirectoryStream(current(), pathFilter)) {
+            final Iterator<Path> iterator = stream.iterator();
+            final Path path = iterator.next();
+            assertEquals(PATH_FIXTURE, path.getFileName().toString());
+            assertFalse(iterator.hasNext());
+        }
+    }
+
+    @Test
+    public void testNewOutputStreamExistingFileAppendFalse() throws IOException {
+        testNewOutputStreamNewFile(false);
+        testNewOutputStreamNewFile(false);
+    }
+
+    @Test
+    public void testNewOutputStreamExistingFileAppendTrue() throws IOException {
+        testNewOutputStreamNewFile(true);
+        final Path file = writeToNewOutputStream(true);
+        assertArrayEquals(ArrayUtils.addAll(BYTE_ARRAY_FIXTURE, BYTE_ARRAY_FIXTURE), Files.readAllBytes(file));
+    }
+
+    public void testNewOutputStreamNewFile(final boolean append) throws IOException {
+        final Path file = writeToNewOutputStream(append);
+        assertArrayEquals(BYTE_ARRAY_FIXTURE, Files.readAllBytes(file));
+    }
+
+    @Test
+    public void testNewOutputStreamNewFileAppendFalse() throws IOException {
+        testNewOutputStreamNewFile(false);
+    }
+
+    @Test
+    public void testNewOutputStreamNewFileAppendTrue() throws IOException {
+        testNewOutputStreamNewFile(true);
+    }
+
+    @Test
+    public void testNewOutputStreamNewFileInsideExistingSymlinkedDir() throws IOException {
+        final Path symlinkDir = createTempSymlinkedRelativeDir();
+        final Path file = symlinkDir.resolve("test.txt");
+        try (OutputStream outputStream = PathUtils.newOutputStream(file, new LinkOption[] {})) {
+            // empty
+        }
+        try (OutputStream outputStream = PathUtils.newOutputStream(file, null)) {
+            // empty
+        }
+        try (OutputStream outputStream = PathUtils.newOutputStream(file, true)) {
+            // empty
+        }
+        try (OutputStream outputStream = PathUtils.newOutputStream(file, false)) {
+            // empty
+        }
+    }
+
+    @Test
+    public void testReadAttributesPosix() throws IOException {
+        boolean isPosix;
+        try {
+            Files.getPosixFilePermissions(current());
+            isPosix = true;
+        } catch (final UnsupportedOperationException e) {
+            isPosix = false;
+        }
+        assertEquals(isPosix, PathUtils.readAttributes(current(), PosixFileAttributes.class) != null);
+    }
+
+    @Test
+    public void testReadStringEmptyFile() throws IOException {
+        final Path path = Paths.get("src/test/resources/org/apache/commons/io/test-file-empty.bin");
+        assertEquals(StringUtils.EMPTY, PathUtils.readString(path, StandardCharsets.UTF_8));
+        assertEquals(StringUtils.EMPTY, PathUtils.readString(path, null));
+    }
+
+    @Test
+    public void testReadStringSimpleUtf8() throws IOException {
+        final Path path = Paths.get("src/test/resources/org/apache/commons/io/test-file-simple-utf8.bin");
+        final String expected = "ABC\r\n";
+        assertEquals(expected, PathUtils.readString(path, StandardCharsets.UTF_8));
+        assertEquals(expected, PathUtils.readString(path, null));
+    }
+
+    @Test
+    public void testSetReadOnlyFile() throws IOException {
+        final Path resolved = tempDirPath.resolve("testSetReadOnlyFile.txt");
+        // Ask now, as we are allowed before editing parent permissions.
+        final boolean isPosix = PathUtils.isPosix(tempDirPath);
+
+        // TEMP HACK
+        assumeFalse(SystemUtils.IS_OS_LINUX);
+
+        PathUtils.writeString(resolved, "test", StandardCharsets.UTF_8);
+        final boolean readable = Files.isReadable(resolved);
+        final boolean writable = Files.isWritable(resolved);
+        final boolean regularFile = Files.isRegularFile(resolved);
+        final boolean executable = Files.isExecutable(resolved);
+        final boolean hidden = Files.isHidden(resolved);
+        final boolean directory = Files.isDirectory(resolved);
+        final boolean symbolicLink = Files.isSymbolicLink(resolved);
+        // Sanity checks
+        assertTrue(readable);
+        assertTrue(writable);
+        // Test A
+        PathUtils.setReadOnly(resolved, false);
+        assertTrue(Files.isReadable(resolved), "isReadable");
+        assertTrue(Files.isWritable(resolved), "isWritable");
+        // Again, shouldn't blow up.
+        PathUtils.setReadOnly(resolved, false);
+        assertTrue(Files.isReadable(resolved), "isReadable");
+        assertTrue(Files.isWritable(resolved), "isWritable");
+        //
+        assertEquals(regularFile, Files.isReadable(resolved));
+        assertEquals(executable, Files.isExecutable(resolved));
+        assertEquals(hidden, Files.isHidden(resolved));
+        assertEquals(directory, Files.isDirectory(resolved));
+        assertEquals(symbolicLink, Files.isSymbolicLink(resolved));
+        // Test B
+        PathUtils.setReadOnly(resolved, true);
+        if (isPosix) {
+            // On POSIX, now that the parent is not WX, the file is not readable.
+            assertFalse(Files.isReadable(resolved), "isReadable");
+        } else {
+            assertTrue(Files.isReadable(resolved), "isReadable");
+        }
+        assertFalse(Files.isWritable(resolved), "isWritable");
+        final DosFileAttributeView dosFileAttributeView = PathUtils.getDosFileAttributeView(resolved);
+        if (dosFileAttributeView != null) {
+            assertTrue(dosFileAttributeView.readAttributes().isReadOnly());
+        }
+        if (isPosix) {
+            assertFalse(Files.isReadable(resolved));
+        } else {
+            assertEquals(regularFile, Files.isReadable(resolved));
+        }
+        assertEquals(executable, Files.isExecutable(resolved));
+        assertEquals(hidden, Files.isHidden(resolved));
+        assertEquals(directory, Files.isDirectory(resolved));
+        assertEquals(symbolicLink, Files.isSymbolicLink(resolved));
+        //
+        PathUtils.setReadOnly(resolved, false);
+        PathUtils.deleteFile(resolved);
+    }
+
+    @Test
+    public void testTouch() throws IOException {
+        assertThrows(NullPointerException.class, () -> FileUtils.touch(null));
+
+        final Path file = managedTempDirPath.resolve("touch.txt");
+        Files.deleteIfExists(file);
+        assertFalse(Files.exists(file), "Bad test: test file still exists");
+        PathUtils.touch(file);
+        assertTrue(Files.exists(file), "touch() created file");
+        try (OutputStream out = Files.newOutputStream(file)) {
+            assertEquals(0, Files.size(file), "Created empty file.");
+            out.write(0);
+        }
+        assertEquals(1, Files.size(file), "Wrote one byte to file");
+        final long y2k = new GregorianCalendar(2000, 0, 1).getTime().getTime();
+        setLastModifiedMillis(file, y2k); // 0L fails on Win98
+        assertEquals(y2k, getLastModifiedMillis(file), "Bad test: set lastModified set incorrect value");
+        final long nowMillis = System.currentTimeMillis();
+        PathUtils.touch(file);
+        assertEquals(1, Files.size(file), "FileUtils.touch() didn't empty the file.");
+        assertNotEquals(y2k, getLastModifiedMillis(file), "FileUtils.touch() changed lastModified");
+        final int delta = 3000;
+        assertTrue(getLastModifiedMillis(file) >= nowMillis - delta, "FileUtils.touch() changed lastModified to more than now-3s");
+        assertTrue(getLastModifiedMillis(file) <= nowMillis + delta, "FileUtils.touch() changed lastModified to less than now+3s");
+    }
+
+    @Test
+    public void testWriteStringToFile1() throws Exception {
+        final Path file = tempDirPath.resolve("write.txt");
+        PathUtils.writeString(file, "Hello /u1234", StandardCharsets.UTF_8);
+        final byte[] text = "Hello /u1234".getBytes(StandardCharsets.UTF_8);
+        TestUtils.assertEqualContent(text, file);
+    }
+
+    /**
+     * Tests newOutputStream() here and don't use Files.write obviously.
+     */
+    private Path writeToNewOutputStream(final boolean append) throws IOException {
+        final Path file = tempDirPath.resolve("test1.txt");
+        try (OutputStream os = PathUtils.newOutputStream(file, append)) {
+            os.write(BYTE_ARRAY_FIXTURE);
+        }
+        return file;
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/file/PathUtilsVisitorTest.java b/src/test/java/org/apache/commons/io/file/PathUtilsVisitorTest.java
new file mode 100644
index 0000000..80a06c6
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/PathUtilsVisitorTest.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.apache.commons.io.file.CounterAssertions.assertCounts;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.filefilter.AndFileFilter;
+import org.apache.commons.io.filefilter.DirectoryFileFilter;
+import org.apache.commons.io.filefilter.EmptyFileFilter;
+import org.apache.commons.io.filefilter.PathVisitorFileFilter;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests {@link PathUtils}.
+ */
+public class PathUtilsVisitorTest {
+
+    static Stream<Arguments> testParameters() {
+        return AccumulatorPathVisitorTest.testParameters();
+    }
+
+    @TempDir
+    File tempDirFile;
+
+    /**
+     * Tests an empty folder.
+     */
+    @ParameterizedTest
+    @MethodSource("testParameters")
+    public void testCountEmptyFolder(final Supplier<AccumulatorPathVisitor> supplier) throws IOException {
+        final Path tempDir = tempDirFile.toPath();
+        final CountingPathVisitor countingPathVisitor = CountingPathVisitor.withLongCounters();
+        final PathVisitorFileFilter countingFileFilter = new PathVisitorFileFilter(countingPathVisitor);
+        Files.walkFileTree(tempDir,
+            new AndFileFilter(countingFileFilter, DirectoryFileFilter.INSTANCE, EmptyFileFilter.EMPTY));
+        assertCounts(1, 0, 0, countingPathVisitor.getPathCounters());
+    }
+
+    /**
+     * Tests a directory with one file of size 0.
+     */
+    @ParameterizedTest
+    @MethodSource("testParameters")
+    public void testCountFolders1FileSize0(final Supplier<AccumulatorPathVisitor> supplier) throws IOException {
+        final CountingPathVisitor countingPathVisitor = CountingPathVisitor.withLongCounters();
+        final PathVisitorFileFilter countingFileFilter = new PathVisitorFileFilter(countingPathVisitor);
+        Files.walkFileTree(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-0"),
+            countingFileFilter);
+        assertCounts(1, 1, 0, countingPathVisitor.getPathCounters());
+    }
+
+    /**
+     * Tests a directory with one file of size 1.
+     */
+    @ParameterizedTest
+    @MethodSource("testParameters")
+    public void testCountFolders1FileSize1(final Supplier<AccumulatorPathVisitor> supplier) throws IOException {
+        final CountingPathVisitor countingPathVisitor = CountingPathVisitor.withLongCounters();
+        final PathVisitorFileFilter countingFileFilter = new PathVisitorFileFilter(countingPathVisitor);
+        Files.walkFileTree(Paths.get("src/test/resources/org/apache/commons/io/dirs-1-file-size-1"),
+            countingFileFilter);
+        assertCounts(1, 1, 1, countingPathVisitor.getPathCounters());
+    }
+
+    /**
+     * Tests a directory with two subdirectories, each containing one file of size 1.
+     */
+    @ParameterizedTest
+    @MethodSource("testParameters")
+    public void testCountFolders2FileSize2(final Supplier<AccumulatorPathVisitor> supplier) throws IOException {
+        final CountingPathVisitor countingPathVisitor = CountingPathVisitor.withLongCounters();
+        final PathVisitorFileFilter countingFileFilter = new PathVisitorFileFilter(countingPathVisitor);
+        Files.walkFileTree(Paths.get("src/test/resources/org/apache/commons/io/dirs-2-file-size-2"),
+            countingFileFilter);
+        assertCounts(3, 2, 2, countingPathVisitor.getPathCounters());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/file/PathWrapper.java b/src/test/java/org/apache/commons/io/file/PathWrapper.java
new file mode 100644
index 0000000..2bf1daa
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/PathWrapper.java
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.FileSystem;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.WatchEvent.Kind;
+import java.nio.file.WatchEvent.Modifier;
+import java.nio.file.WatchKey;
+import java.nio.file.WatchService;
+import java.util.Iterator;
+import java.util.Objects;
+import java.util.Spliterator;
+import java.util.function.Consumer;
+
+/**
+ * Wraps and delegates to a Path for subclasses.
+ *
+ * @since 2.12.0
+ */
+public abstract class PathWrapper implements Path {
+
+    /**
+     * The path delegate.
+     */
+    private final Path path;
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param path The path to wrap.
+     */
+    protected PathWrapper(final Path path) {
+        this.path = Objects.requireNonNull(path, "path");
+    }
+
+    @Override
+    public int compareTo(final Path other) {
+        return path.compareTo(other);
+    }
+
+    @Override
+    public boolean endsWith(final Path other) {
+        return path.endsWith(other);
+    }
+
+    @Override
+    public boolean endsWith(final String other) {
+        return path.endsWith(other);
+    }
+
+    @Override
+    public boolean equals(final Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!(obj instanceof PathWrapper)) {
+            return false;
+        }
+        final PathWrapper other = (PathWrapper) obj;
+        return Objects.equals(path, other.path);
+    }
+
+    @Override
+    public void forEach(final Consumer<? super Path> action) {
+        path.forEach(action);
+    }
+
+    /**
+     * Gets the delegate Path.
+     *
+     * @return the delegate Path.
+     */
+    public Path get() {
+        return path;
+    }
+
+    @Override
+    public Path getFileName() {
+        return path.getFileName();
+    }
+
+    @Override
+    public FileSystem getFileSystem() {
+        return path.getFileSystem();
+    }
+
+    @Override
+    public Path getName(final int index) {
+        return path.getName(index);
+    }
+
+    @Override
+    public int getNameCount() {
+        return path.getNameCount();
+    }
+
+    @Override
+    public Path getParent() {
+        return path.getParent();
+    }
+
+    @Override
+    public Path getRoot() {
+        return path.getRoot();
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(path);
+    }
+
+    @Override
+    public boolean isAbsolute() {
+        return path.isAbsolute();
+    }
+
+    @Override
+    public Iterator<Path> iterator() {
+        return path.iterator();
+    }
+
+    @Override
+    public Path normalize() {
+        return path.normalize();
+    }
+
+    @Override
+    public WatchKey register(final WatchService watcher, final Kind<?>... events) throws IOException {
+        return path.register(watcher, events);
+    }
+
+    @Override
+    public WatchKey register(final WatchService watcher, final Kind<?>[] events, final Modifier... modifiers) throws IOException {
+        return path.register(watcher, events, modifiers);
+    }
+
+    @Override
+    public Path relativize(final Path other) {
+        return path.relativize(other);
+    }
+
+    @Override
+    public Path resolve(final Path other) {
+        return path.resolve(other);
+    }
+
+    @Override
+    public Path resolve(final String other) {
+        return path.resolve(other);
+    }
+
+    @Override
+    public Path resolveSibling(final Path other) {
+        return path.resolveSibling(other);
+    }
+
+    @Override
+    public Path resolveSibling(final String other) {
+        return path.resolveSibling(other);
+    }
+
+    @Override
+    public Spliterator<Path> spliterator() {
+        return path.spliterator();
+    }
+
+    @Override
+    public boolean startsWith(final Path other) {
+        return path.startsWith(other);
+    }
+
+    @Override
+    public boolean startsWith(final String other) {
+        return path.startsWith(other);
+    }
+
+    @Override
+    public Path subpath(final int beginIndex, final int endIndex) {
+        return path.subpath(beginIndex, endIndex);
+    }
+
+    @Override
+    public Path toAbsolutePath() {
+        return path.toAbsolutePath();
+    }
+
+    @Override
+    public File toFile() {
+        return path.toFile();
+    }
+
+    @Override
+    public Path toRealPath(final LinkOption... options) throws IOException {
+        return path.toRealPath(options);
+    }
+
+    @Override
+    public String toString() {
+        return path.toString();
+    }
+
+    @Override
+    public URI toUri() {
+        return path.toUri();
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/file/TempDirectory.java b/src/test/java/org/apache/commons/io/file/TempDirectory.java
new file mode 100644
index 0000000..f601694
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/TempDirectory.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileAttribute;
+
+/**
+ * A temporary directory path that deletes its delegate on close.
+ *
+ * @since 2.12.0
+ */
+public class TempDirectory extends DeletablePath {
+
+    /**
+     * Creates a new instance for a new temporary directory in the specified directory, using the given prefix to generate
+     * its name.
+     *
+     * @param dir See {@link Files#createTempDirectory(String, FileAttribute...)}.
+     * @param prefix See {@link Files#createTempDirectory(String, FileAttribute...)}.
+     * @param attrs See {@link Files#createTempDirectory(String, FileAttribute...)}.
+     * @return a new instance for a new temporary directory
+     * @throws IOException See {@link Files#createTempDirectory(String, FileAttribute...)}.
+     */
+    public static TempDirectory create(final Path dir, final String prefix, final FileAttribute<?>... attrs) throws IOException {
+        return new TempDirectory(Files.createTempDirectory(dir, prefix, attrs));
+    }
+
+    /**
+     * Creates a new instance for a new temporary directory in the specified directory, using the given prefix to generate
+     * its name.
+     *
+     * @param prefix See {@link Files#createTempDirectory(String, FileAttribute...)}.
+     * @param attrs See {@link Files#createTempDirectory(String, FileAttribute...)}.
+     * @return a new instance for a new temporary directory
+     * @throws IOException See {@link Files#createTempDirectory(String, FileAttribute...)}.
+     */
+    public static TempDirectory create(final String prefix, final FileAttribute<?>... attrs) throws IOException {
+        return new TempDirectory(Files.createTempDirectory(prefix, attrs));
+    }
+
+    /**
+     * Constructs a new instance wrapping the given delegate.
+     *
+     * @param path The delegate.
+     */
+    private TempDirectory(final Path path) {
+        super(path);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/file/TempDirectoryTest.java b/src/test/java/org/apache/commons/io/file/TempDirectoryTest.java
new file mode 100644
index 0000000..05e5a91
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/TempDirectoryTest.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+import org.apache.commons.io.FileUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link TempDirectory}.
+ */
+public class TempDirectoryTest {
+
+    @SuppressWarnings("resource")
+    @Test
+    public void testCreatePath() throws IOException {
+        final TempDirectory ref;
+        try (TempDirectory tempDir = TempDirectory.create(getClass().getCanonicalName())) {
+            ref = tempDir;
+            assertTrue(FileUtils.isEmptyDirectory(tempDir.toFile()));
+        }
+        assertFalse(Files.exists(ref.get()));
+
+        // Fails with a ProviderMismatchException because the Windows FS uses "instanceof sun.nio.fs.WindowsPath".
+        // WindowsPath is a class, not an interface we can proxy.
+        // assertFalse(Files.exists(ref));
+    }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void testCreateString() throws IOException {
+        final TempDirectory ref;
+        try (TempDirectory tempDir = TempDirectory.create(Paths.get("target"), getClass().getCanonicalName())) {
+            ref = tempDir;
+            assertTrue(FileUtils.isEmptyDirectory(tempDir.toFile()));
+        }
+        assertFalse(Files.exists(ref.get()));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/file/TempFile.java b/src/test/java/org/apache/commons/io/file/TempFile.java
new file mode 100644
index 0000000..6d96eba
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/TempFile.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileAttribute;
+
+/**
+ * A temporary file path that deletes its delegate on close.
+ *
+ * @since 2.12.0
+ */
+public class TempFile extends DeletablePath {
+
+    /**
+     * Creates a new instance for a new temporary file in the specified directory, using the given prefix to generate its
+     * name.
+     *
+     * @param dir See {@link Files#createTempFile(Path, String, String, FileAttribute...)}.
+     * @param prefix See {@link Files#createTempFile(Path, String, String, FileAttribute...)}.
+     * @param suffix See {@link Files#createTempFile(Path, String, String, FileAttribute...)}.
+     * @param attrs See {@link Files#createTempFile(Path, String, String, FileAttribute...)}.
+     * @return a new instance for a new temporary directory
+     * @throws IOException See {@link Files#createTempFile(Path, String, String, FileAttribute...)}.
+     */
+    public static TempFile create(final Path dir, final String prefix, final String suffix, final FileAttribute<?>... attrs) throws IOException {
+        return new TempFile(Files.createTempFile(dir, prefix, suffix, attrs));
+    }
+
+    /**
+     * Creates a new instance for a new temporary file in the specified directory, using the given prefix to generate its
+     * name.
+     *
+     * @param prefix See {@link Files#createTempFile(Path, String, String, FileAttribute...)}.
+     * @param suffix See {@link Files#createTempFile(Path, String, String, FileAttribute...)}.
+     * @param attrs See {@link Files#createTempFile(Path, String, String, FileAttribute...)}.
+     * @return a new instance for a new temporary directory
+     * @throws IOException See {@link Files#createTempFile(Path, String, String, FileAttribute...)}.
+     */
+    public static TempFile create(final String prefix, final String suffix, final FileAttribute<?>... attrs) throws IOException {
+        return new TempFile(Files.createTempFile(prefix, suffix, attrs));
+    }
+
+    /**
+     * Constructs a new instance wrapping the given delegate.
+     *
+     * @param path The delegate.
+     */
+    private TempFile(final Path path) {
+        super(path);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/file/TempFileTest.java b/src/test/java/org/apache/commons/io/file/TempFileTest.java
new file mode 100644
index 0000000..566926a
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/TempFileTest.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link TempFile}.
+ */
+public class TempFileTest {
+
+    @SuppressWarnings("resource")
+    @Test
+    public void testCreatePath() throws IOException {
+        final TempFile ref;
+        try (TempFile tempDir = TempFile.create(Paths.get("target"), "prefix", ".suffix")) {
+            ref = tempDir;
+            assertTrue(Files.exists(ref.get()));
+        }
+        assertFalse(Files.exists(ref.get()));
+
+        // Fails with a ProviderMismatchException because the Windows FS uses "instanceof sun.nio.fs.WindowsPath".
+        // WindowsPath is a class, not an interface we can proxy.
+        // assertFalse(Files.exists(ref));
+    }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void testCreateString() throws IOException {
+        final TempFile ref;
+        try (TempFile tempDir = TempFile.create(getClass().getCanonicalName(), ".suffix")) {
+            ref = tempDir;
+            assertTrue(Files.exists(ref.get()));
+        }
+        assertFalse(Files.exists(ref.get()));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/file/TestArguments.java b/src/test/java/org/apache/commons/io/file/TestArguments.java
new file mode 100644
index 0000000..2b9f210
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/TestArguments.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file;
+
+import java.util.stream.Stream;
+
+import org.junit.jupiter.params.provider.Arguments;
+
+class TestArguments {
+
+    static Stream<Arguments> cleaningPathVisitors() {
+        // @formatter:off
+        return Stream.of(
+          Arguments.of(CleaningPathVisitor.withBigIntegerCounters()),
+          Arguments.of(CleaningPathVisitor.withLongCounters()));
+        // @formatter:on
+    }
+
+    static Stream<Arguments> countingPathVisitors() {
+        // @formatter:off
+        return Stream.of(
+          Arguments.of(CountingPathVisitor.withBigIntegerCounters()),
+          Arguments.of(CountingPathVisitor.withLongCounters()));
+        // @formatter:on
+    }
+
+    static Stream<Arguments> deletingPathVisitors() {
+        // @formatter:off
+        return Stream.of(
+          Arguments.of(DeletingPathVisitor.withBigIntegerCounters()),
+          Arguments.of(DeletingPathVisitor.withLongCounters()));
+        // @formatter:on
+    }
+
+    static Stream<Arguments> numberCounters() {
+        // @formatter:off
+        return Stream.of(
+          Arguments.of(Counters.longCounter()),
+          Arguments.of(Counters.bigIntegerCounter()));
+        // @formatter:on
+    }
+
+    static Stream<Arguments> pathCounters() {
+        // @formatter:off
+        return Stream.of(
+          Arguments.of(Counters.longPathCounters()),
+          Arguments.of(Counters.bigIntegerPathCounters()));
+        // @formatter:on
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/file/attribute/FileTimesTest.java b/src/test/java/org/apache/commons/io/file/attribute/FileTimesTest.java
new file mode 100644
index 0000000..8d506cc
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/attribute/FileTimesTest.java
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file.attribute;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.nio.file.attribute.FileTime;
+import java.time.Instant;
+import java.util.Date;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests {@link FileTimes}.
+ */
+public class FileTimesTest {
+
+    public static Stream<Arguments> dateToNtfsProvider() {
+        // @formatter:off
+        return Stream.of(
+            Arguments.of("1601-01-01T00:00:00.000Z", 0),
+            Arguments.of("1601-01-01T00:00:00.000Z", 1),
+            Arguments.of("1600-12-31T23:59:59.999Z", -1),
+            Arguments.of("1601-01-01T00:00:00.001Z", FileTimes.HUNDRED_NANOS_PER_MILLISECOND),
+            Arguments.of("1601-01-01T00:00:00.001Z", FileTimes.HUNDRED_NANOS_PER_MILLISECOND + 1),
+            Arguments.of("1601-01-01T00:00:00.000Z", FileTimes.HUNDRED_NANOS_PER_MILLISECOND - 1),
+            Arguments.of("1600-12-31T23:59:59.999Z", -FileTimes.HUNDRED_NANOS_PER_MILLISECOND),
+            Arguments.of("1600-12-31T23:59:59.999Z", -FileTimes.HUNDRED_NANOS_PER_MILLISECOND + 1),
+            Arguments.of("1600-12-31T23:59:59.998Z", -FileTimes.HUNDRED_NANOS_PER_MILLISECOND - 1),
+            Arguments.of("1970-01-01T00:00:00.000Z", -FileTimes.WINDOWS_EPOCH_OFFSET),
+            Arguments.of("1970-01-01T00:00:00.000Z", -FileTimes.WINDOWS_EPOCH_OFFSET + 1),
+            Arguments.of("1970-01-01T00:00:00.001Z", -FileTimes.WINDOWS_EPOCH_OFFSET + FileTimes.HUNDRED_NANOS_PER_MILLISECOND),
+            Arguments.of("1969-12-31T23:59:59.999Z", -FileTimes.WINDOWS_EPOCH_OFFSET - 1),
+            Arguments.of("1969-12-31T23:59:59.999Z", -FileTimes.WINDOWS_EPOCH_OFFSET - FileTimes.HUNDRED_NANOS_PER_MILLISECOND));
+        // @formatter:on
+    }
+
+    public static Stream<Arguments> fileTimeToNtfsProvider() {
+        // @formatter:off
+        return Stream.of(
+            Arguments.of("1601-01-01T00:00:00.0000000Z", 0),
+            Arguments.of("1601-01-01T00:00:00.0000001Z", 1),
+            Arguments.of("1600-12-31T23:59:59.9999999Z", -1),
+            Arguments.of("1601-01-01T00:00:00.0010000Z", FileTimes.HUNDRED_NANOS_PER_MILLISECOND),
+            Arguments.of("1601-01-01T00:00:00.0010001Z", FileTimes.HUNDRED_NANOS_PER_MILLISECOND + 1),
+            Arguments.of("1601-01-01T00:00:00.0009999Z", FileTimes.HUNDRED_NANOS_PER_MILLISECOND - 1),
+            Arguments.of("1600-12-31T23:59:59.9990000Z", -FileTimes.HUNDRED_NANOS_PER_MILLISECOND),
+            Arguments.of("1600-12-31T23:59:59.9990001Z", -FileTimes.HUNDRED_NANOS_PER_MILLISECOND + 1),
+            Arguments.of("1600-12-31T23:59:59.9989999Z", -FileTimes.HUNDRED_NANOS_PER_MILLISECOND - 1),
+            Arguments.of("1970-01-01T00:00:00.0000000Z", -FileTimes.WINDOWS_EPOCH_OFFSET),
+            Arguments.of("1970-01-01T00:00:00.0000001Z", -FileTimes.WINDOWS_EPOCH_OFFSET + 1),
+            Arguments.of("1970-01-01T00:00:00.0010000Z", -FileTimes.WINDOWS_EPOCH_OFFSET + FileTimes.HUNDRED_NANOS_PER_MILLISECOND),
+            Arguments.of("1969-12-31T23:59:59.9999999Z", -FileTimes.WINDOWS_EPOCH_OFFSET - 1),
+            Arguments.of("1969-12-31T23:59:59.9990000Z", -FileTimes.WINDOWS_EPOCH_OFFSET - FileTimes.HUNDRED_NANOS_PER_MILLISECOND));
+        // @formatter:on
+    }
+
+    @ParameterizedTest
+    @MethodSource("dateToNtfsProvider")
+    public void testDateToFileTime(final String instant, final long ignored) {
+        final Instant parsedInstant = Instant.parse(instant);
+        final FileTime parsedFileTime = FileTime.from(parsedInstant);
+        final Date parsedDate = Date.from(parsedInstant);
+        assertEquals(parsedFileTime, FileTimes.toFileTime(parsedDate));
+    }
+
+    @ParameterizedTest
+    @MethodSource("dateToNtfsProvider")
+    public void testDateToNtfsTime(final String instant, final long ntfsTime) {
+        final long ntfsMillis = Math.floorDiv(ntfsTime, FileTimes.HUNDRED_NANOS_PER_MILLISECOND) * FileTimes.HUNDRED_NANOS_PER_MILLISECOND;
+        final Date parsed = Date.from(Instant.parse(instant));
+        assertEquals(ntfsMillis, FileTimes.toNtfsTime(parsed));
+    }
+
+    @Test
+    public void testEpoch() {
+        assertEquals(0, FileTimes.EPOCH.toMillis());
+    }
+
+    @ParameterizedTest
+    @MethodSource("fileTimeToNtfsProvider")
+    public void testFileTimeToDate(final String instant, final long ignored) {
+        final Instant parsedInstant = Instant.parse(instant);
+        final FileTime parsedFileTime = FileTime.from(parsedInstant);
+        final Date parsedDate = Date.from(parsedInstant);
+        assertEquals(parsedDate, FileTimes.toDate(parsedFileTime));
+    }
+
+    @ParameterizedTest
+    @MethodSource("fileTimeToNtfsProvider")
+    public void testFileTimeToNtfsTime(final String instant, final long ntfsTime) {
+        final FileTime parsed = FileTime.from(Instant.parse(instant));
+        assertEquals(ntfsTime, FileTimes.toNtfsTime(parsed));
+    }
+
+    //
+
+    @Test
+    public void testMinusMillis() {
+        final int millis = 2;
+        assertEquals(Instant.EPOCH.minusMillis(millis), FileTimes.minusMillis(FileTimes.EPOCH, millis).toInstant());
+        assertEquals(Instant.EPOCH, FileTimes.minusMillis(FileTimes.EPOCH, 0).toInstant());
+    }
+
+    @Test
+    public void testMinusNanos() {
+        final int millis = 2;
+        assertEquals(Instant.EPOCH.minusNanos(millis), FileTimes.minusNanos(FileTimes.EPOCH, millis).toInstant());
+        assertEquals(Instant.EPOCH, FileTimes.minusNanos(FileTimes.EPOCH, 0).toInstant());
+    }
+
+    @Test
+    public void testMinusSeconds() {
+        final int seconds = 2;
+        assertEquals(Instant.EPOCH.minusSeconds(seconds), FileTimes.minusSeconds(FileTimes.EPOCH, seconds).toInstant());
+        assertEquals(Instant.EPOCH, FileTimes.minusSeconds(FileTimes.EPOCH, 0).toInstant());
+    }
+
+    @ParameterizedTest
+    @MethodSource("dateToNtfsProvider")
+    public void testNtfsTimeToDate(final String instant, final long ntfsTime) {
+        assertEquals(Instant.parse(instant), FileTimes.ntfsTimeToDate(ntfsTime).toInstant());
+    }
+
+    @ParameterizedTest
+    @MethodSource("fileTimeToNtfsProvider")
+    public void testNtfsTimeToFileTime(final String instant, final long ntfsTime) {
+        final FileTime parsed = FileTime.from(Instant.parse(instant));
+        assertEquals(parsed, FileTimes.ntfsTimeToFileTime(ntfsTime));
+    }
+
+    @Test
+    public void testNullDateToNullFileTime() {
+        assertNull(FileTimes.toFileTime(null));
+    }
+
+    @Test
+    public void testNullFileTimeToNullDate() {
+        assertNull(FileTimes.toDate(null));
+    }
+
+    @Test
+    public void testPlusMinusMillis() {
+        final int millis = 2;
+        assertEquals(Instant.EPOCH.plusMillis(millis), FileTimes.plusMillis(FileTimes.EPOCH, millis).toInstant());
+        assertEquals(Instant.EPOCH, FileTimes.plusMillis(FileTimes.EPOCH, 0).toInstant());
+    }
+
+    @Test
+    public void testPlusNanos() {
+        final int millis = 2;
+        assertEquals(Instant.EPOCH.plusNanos(millis), FileTimes.plusNanos(FileTimes.EPOCH, millis).toInstant());
+        assertEquals(Instant.EPOCH, FileTimes.plusNanos(FileTimes.EPOCH, 0).toInstant());
+    }
+
+    @Test
+    public void testPlusSeconds() {
+        final int seconds = 2;
+        assertEquals(Instant.EPOCH.plusSeconds(seconds), FileTimes.plusSeconds(FileTimes.EPOCH, seconds).toInstant());
+        assertEquals(Instant.EPOCH, FileTimes.plusSeconds(FileTimes.EPOCH, 0).toInstant());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/file/spi/FileSystemProvidersTest.java b/src/test/java/org/apache/commons/io/file/spi/FileSystemProvidersTest.java
new file mode 100644
index 0000000..8dc34ec
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/file/spi/FileSystemProvidersTest.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.file.spi;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Paths;
+import java.nio.file.spi.FileSystemProvider;
+
+import org.junit.jupiter.api.Test;
+
+public class FileSystemProvidersTest {
+
+    private static final String FILE_PATH = "file:///foo.txt";
+
+    @Test
+    public void testGetFileSystemProvider_all() throws URISyntaxException {
+        for (final FileSystemProvider fileSystemProvider : FileSystemProvider.installedProviders()) {
+            final String scheme = fileSystemProvider.getScheme();
+            final URI uri = new URI(scheme, "ssp", "fragment");
+            assertEquals(scheme, FileSystemProviders.installed().getFileSystemProvider(uri).getScheme());
+        }
+    }
+
+    @Test
+    public void testGetFileSystemProvider_filePath() {
+        assertNotNull(FileSystemProviders.getFileSystemProvider(Paths.get(URI.create(FILE_PATH))));
+    }
+
+    @Test
+    public void testGetFileSystemProvider_fileScheme() {
+        assertNotNull(FileSystemProviders.installed().getFileSystemProvider("file"));
+    }
+
+    @Test
+    public void testGetFileSystemProvider_fileURI() {
+        assertNotNull(FileSystemProviders.installed().getFileSystemProvider(URI.create(FILE_PATH)));
+    }
+
+    @Test
+    public void testGetFileSystemProvider_fileURL() throws MalformedURLException {
+        assertNotNull(FileSystemProviders.installed().getFileSystemProvider(new URL(FILE_PATH)));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/filefilter/AbstractFilterTest.java b/src/test/java/org/apache/commons/io/filefilter/AbstractFilterTest.java
new file mode 100644
index 0000000..4ed27be
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/filefilter/AbstractFilterTest.java
@@ -0,0 +1,169 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.commons.io.IOCase;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Used to test FileFilterUtils.
+ */
+public class AbstractFilterTest {
+
+    /**
+     * The subversion directory name.
+     */
+    static final String SVN_DIR_NAME = ".svn";
+
+    static final boolean WINDOWS = File.separatorChar == '\\';
+
+    @TempDir
+    public File temporaryFolder;
+
+    void assertFiltering(final IOFileFilter filter, final File file, final boolean expected) {
+        // Note. This only tests the (File, String) version if the parent of
+        // the File passed in is not null
+        assertEquals(expected, filter.accept(file), "Filter(File) " + filter.getClass().getName() + " not " + expected + " for " + file);
+
+        if (file != null && file.getParentFile() != null) {
+            assertEquals(expected, filter.accept(file.getParentFile(), file.getName()),
+                "Filter(File, String) " + filter.getClass().getName() + " not " + expected + " for " + file);
+        } else if (file == null) {
+            assertEquals(expected, filter.accept(file), "Filter(File, String) " + filter.getClass().getName() + " not " + expected + " for null");
+        }
+        assertNotNull(filter.toString());
+    }
+
+    void assertFiltering(final IOFileFilter filter, final Path path, final boolean expected) {
+        // Note. This only tests the (Path, Path) version if the parent of
+        // the File passed in is not null
+        final FileVisitResult expectedFileVisitResult = AbstractFileFilter.toDefaultFileVisitResult(expected);
+        assertEquals(expectedFileVisitResult, filter.accept(path, null),
+            "Filter(Path) " + filter.getClass().getName() + " not " + expectedFileVisitResult + " for " + path);
+
+        if (path != null && path.getParent() != null) {
+            assertEquals(expectedFileVisitResult, filter.accept(path, null),
+                "Filter(Path, Path) " + filter.getClass().getName() + " not " + expectedFileVisitResult + " for " + path);
+        } else if (path == null) {
+            assertEquals(expectedFileVisitResult, filter.accept(path, null),
+                "Filter(Path, Path) " + filter.getClass().getName() + " not " + expectedFileVisitResult + " for null");
+        }
+        assertNotNull(filter.toString());
+    }
+
+    void assertFooBarFileFiltering(IOFileFilter filter) {
+        assertFiltering(filter, new File("foo"), true);
+        assertFiltering(filter, new File("foo"), true);
+        assertFiltering(filter, new File("bar"), true);
+        assertFiltering(filter, new File("fred"), false);
+        assertFiltering(filter, new File("foo").toPath(), true);
+        assertFiltering(filter, new File("foo").toPath(), true);
+        assertFiltering(filter, new File("bar").toPath(), true);
+        assertFiltering(filter, new File("fred").toPath(), false);
+
+        filter = new NameFileFilter(new String[] {"foo", "bar"}, IOCase.SENSITIVE);
+        assertFiltering(filter, new File("foo"), true);
+        assertFiltering(filter, new File("bar"), true);
+        assertFiltering(filter, new File("FOO"), false);
+        assertFiltering(filter, new File("BAR"), false);
+        assertFiltering(filter, new File("foo").toPath(), true);
+        assertFiltering(filter, new File("bar").toPath(), true);
+        assertFiltering(filter, new File("FOO").toPath(), false);
+        assertFiltering(filter, new File("BAR").toPath(), false);
+
+        filter = new NameFileFilter(new String[] {"foo", "bar"}, IOCase.INSENSITIVE);
+        assertFiltering(filter, new File("foo"), true);
+        assertFiltering(filter, new File("bar"), true);
+        assertFiltering(filter, new File("FOO"), true);
+        assertFiltering(filter, new File("BAR"), true);
+        assertFiltering(filter, new File("foo").toPath(), true);
+        assertFiltering(filter, new File("bar").toPath(), true);
+        assertFiltering(filter, new File("FOO").toPath(), true);
+        assertFiltering(filter, new File("BAR").toPath(), true);
+
+        filter = new NameFileFilter(new String[] {"foo", "bar"}, IOCase.SYSTEM);
+        assertFiltering(filter, new File("foo"), true);
+        assertFiltering(filter, new File("bar"), true);
+        assertFiltering(filter, new File("FOO"), WINDOWS);
+        assertFiltering(filter, new File("BAR"), WINDOWS);
+        assertFiltering(filter, new File("foo").toPath(), true);
+        assertFiltering(filter, new File("bar").toPath(), true);
+        assertFiltering(filter, new File("FOO").toPath(), WINDOWS);
+        assertFiltering(filter, new File("BAR").toPath(), WINDOWS);
+
+        filter = new NameFileFilter(new String[] {"foo", "bar"}, null);
+        assertFiltering(filter, new File("foo"), true);
+        assertFiltering(filter, new File("bar"), true);
+        assertFiltering(filter, new File("FOO"), false);
+        assertFiltering(filter, new File("BAR"), false);
+        assertFiltering(filter, new File("foo").toPath(), true);
+        assertFiltering(filter, new File("bar").toPath(), true);
+        assertFiltering(filter, new File("FOO").toPath(), false);
+        assertFiltering(filter, new File("BAR").toPath(), false);
+
+        // repeat for a List
+        final java.util.ArrayList<String> list = new java.util.ArrayList<>();
+        list.add("foo");
+        list.add("bar");
+        filter = new NameFileFilter(list);
+        assertFiltering(filter, new File("foo"), true);
+        assertFiltering(filter, new File("bar"), true);
+        assertFiltering(filter, new File("fred"), false);
+        assertFiltering(filter, new File("foo").toPath(), true);
+        assertFiltering(filter, new File("bar").toPath(), true);
+        assertFiltering(filter, new File("fred").toPath(), false);
+
+        filter = new NameFileFilter("foo");
+        assertFiltering(filter, new File("foo"), true);
+        assertFiltering(filter, new File("FOO"), false); // case-sensitive
+        assertFiltering(filter, new File("barfoo"), false);
+        assertFiltering(filter, new File("foobar"), false);
+        assertFiltering(filter, new File("fred"), false);
+        assertFiltering(filter, new File("foo").toPath(), true);
+        assertFiltering(filter, new File("FOO").toPath(), false); // case-sensitive
+        assertFiltering(filter, new File("barfoo").toPath(), false);
+        assertFiltering(filter, new File("foobar").toPath(), false);
+        assertFiltering(filter, new File("fred").toPath(), false);
+
+        // FileFilterUtils.nameFileFilter(String, IOCase) tests
+        filter = FileFilterUtils.nameFileFilter("foo", IOCase.INSENSITIVE);
+        assertFiltering(filter, new File("foo"), true);
+        assertFiltering(filter, new File("FOO"), true); // case-insensitive
+        assertFiltering(filter, new File("barfoo"), false);
+        assertFiltering(filter, new File("foobar"), false);
+        assertFiltering(filter, new File("fred"), false);
+        assertFiltering(filter, new File("foo").toPath(), true);
+        assertFiltering(filter, new File("FOO").toPath(), true); // case-insensitive
+        assertFiltering(filter, new File("barfoo").toPath(), false);
+        assertFiltering(filter, new File("foobar").toPath(), false);
+        assertFiltering(filter, new File("fred").toPath(), false);
+    }
+
+    boolean equalsLastModified(final File left, final File right) throws IOException {
+        return Files.getLastModifiedTime(left.toPath()).equals(Files.getLastModifiedTime(right.toPath()));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/filefilter/AgeFileFilterTest.java b/src/test/java/org/apache/commons/io/filefilter/AgeFileFilterTest.java
new file mode 100644
index 0000000..c0ea794
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/filefilter/AgeFileFilterTest.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Collections;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.file.AccumulatorPathVisitor;
+import org.apache.commons.io.file.CounterAssertions;
+import org.apache.commons.io.file.Counters;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link AgeFileFilter}.
+ */
+public class AgeFileFilterTest {
+
+    /**
+     * Javadoc example.
+     *
+     * System.out calls are commented out here but not in the Javadoc.
+     */
+    @Test
+    public void testJavadocExampleUsingIo() {
+        final File dir = FileUtils.current();
+        // We are interested in files older than one day
+        final long cutoffMillis = System.currentTimeMillis();
+        final String[] files = dir.list(new AgeFileFilter(cutoffMillis));
+        // End of Javadoc example
+        assertTrue(files.length > 0);
+    }
+
+    /**
+     * Javadoc example.
+     *
+     * System.out calls are commented out here but not in the Javadoc.
+     */
+    @Test
+    public void testJavadocExampleUsingNio() throws IOException {
+        final Path dir = Paths.get("");
+        // We are interested in files older than one day
+        final long cutoffMillis = System.currentTimeMillis();
+        final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(new AgeFileFilter(cutoffMillis),
+            TrueFileFilter.INSTANCE);
+        //
+        // Walk one dir
+        Files.walkFileTree(dir, Collections.emptySet(), 1, visitor);
+        // System.out.println(visitor.getPathCounters());
+        // System.out.println(visitor.getFileList());
+        //
+        visitor.getPathCounters().reset();
+        //
+        // Walk dir tree
+        Files.walkFileTree(dir, visitor);
+        // System.out.println(visitor.getPathCounters());
+        // System.out.println(visitor.getDirList());
+        // System.out.println(visitor.getFileList());
+        //
+        // End of Javadoc example
+        assertTrue(visitor.getPathCounters().getFileCounter().get() > 0);
+        assertTrue(visitor.getPathCounters().getDirectoryCounter().get() > 0);
+        assertTrue(visitor.getPathCounters().getByteCounter().get() > 0);
+        // We counted and accumulated
+        assertFalse(visitor.getDirList().isEmpty());
+        assertFalse(visitor.getFileList().isEmpty());
+        //
+        assertNotEquals(Counters.noopPathCounters(), visitor.getPathCounters());
+        visitor.getPathCounters().reset();
+        CounterAssertions.assertZeroCounters(visitor.getPathCounters());
+    }
+
+    @Test
+    public void testNoCounting() throws IOException {
+        final Path dir = Paths.get("");
+        final long cutoffMillis = System.currentTimeMillis();
+        final AccumulatorPathVisitor visitor = new AccumulatorPathVisitor(Counters.noopPathCounters(),
+            new AgeFileFilter(cutoffMillis), TrueFileFilter.INSTANCE);
+        Files.walkFileTree(dir, Collections.emptySet(), 1, visitor);
+        //
+        CounterAssertions.assertZeroCounters(visitor.getPathCounters());
+        // We did not count, but we still accumulated
+        assertFalse(visitor.getDirList().isEmpty());
+        assertFalse(visitor.getFileList().isEmpty());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/filefilter/AndFileFilterTest.java b/src/test/java/org/apache/commons/io/filefilter/AndFileFilterTest.java
new file mode 100644
index 0000000..28886bd
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/filefilter/AndFileFilterTest.java
@@ -0,0 +1,313 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link AndFileFilter}.
+ */
+public class AndFileFilterTest extends ConditionalFileFilterAbstractTest {
+
+  private static final String DEFAULT_WORKING_PATH = "./AndFileFilterTestCase/";
+  private static final String WORKING_PATH_NAME_PROPERTY_KEY = AndFileFilterTest.class.getName() + ".workingDirectory";
+
+  private List<List<IOFileFilter>> testFilters;
+  private List<boolean[]> testTrueResults;
+  private List<boolean[]> testFalseResults;
+  private List<Boolean> testFileResults;
+  private List<Boolean> testFilenameResults;
+
+  @Override
+  protected IOFileFilter buildFilterUsingAdd(final List<IOFileFilter> filters) {
+    final AndFileFilter filter = new AndFileFilter();
+    filters.forEach(filter::addFileFilter);
+    return filter;
+  }
+
+  @Override
+  protected IOFileFilter buildFilterUsingConstructor(final List<IOFileFilter> filters) {
+    return new AndFileFilter(filters);
+  }
+
+  @Override
+  protected ConditionalFileFilter getConditionalFileFilter() {
+    return new AndFileFilter();
+  }
+
+  @Override
+  protected String getDefaultWorkingPath() {
+    return DEFAULT_WORKING_PATH;
+  }
+
+  @Override
+  protected List<boolean[]> getFalseResults() {
+    return this.testFalseResults;
+  }
+
+  @Override
+  protected List<Boolean> getFilenameResults() {
+    return this.testFilenameResults;
+  }
+
+  @Override
+  protected List<Boolean> getFileResults() {
+    return this.testFileResults;
+  }
+
+  @Override
+  protected List<List<IOFileFilter>> getTestFilters() {
+    return this.testFilters;
+  }
+
+  @Override
+  protected List<boolean[]> getTrueResults() {
+    return this.testTrueResults;
+  }
+
+  @Override
+  protected String getWorkingPathNamePropertyKey() {
+    return WORKING_PATH_NAME_PROPERTY_KEY;
+  }
+
+  @Test
+  public void setTestFiltersClearsOld() {
+    // test that new filters correctly clear old filters
+    final List<IOFileFilter> simpleEmptyFileFilter = Collections.singletonList(EmptyFileFilter.EMPTY);
+    final AndFileFilter andFileFilter = new AndFileFilter(simpleEmptyFileFilter);
+    // make sure the filters at this point are the same
+    assertEquals(simpleEmptyFileFilter, andFileFilter.getFileFilters());
+
+    final List<IOFileFilter> simpleNonEmptyFilter = Collections.singletonList(EmptyFileFilter.NOT_EMPTY);
+    // when calling the setter the filters should reference the new filters
+    andFileFilter.setFileFilters(simpleNonEmptyFilter);
+    assertEquals(simpleNonEmptyFilter, andFileFilter.getFileFilters());
+  }
+
+  @BeforeEach
+  public void setUpTestFilters() {
+    // filters
+    //tests
+    this.testFilters = new ArrayList<>();
+    this.testTrueResults = new ArrayList<>();
+    this.testFalseResults = new ArrayList<>();
+    this.testFileResults = new ArrayList<>();
+    this.testFilenameResults = new ArrayList<>();
+
+    // test 0 - add empty elements
+    {
+      testFilters.add(0, null);
+      testTrueResults.add(0, null);
+      testFalseResults.add(0, null);
+      testFileResults.add(0, null);
+      testFilenameResults.add(0, null);
+    }
+
+    // test 1 - Test conditional and with all filters returning true
+    {
+      // test 1 filters
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(trueFilters[1]);
+      filters.add(trueFilters[2]);
+      filters.add(trueFilters[3]);
+      // test 1 true results
+      final boolean[] trueResults = {true, true, true};
+      // test 1 false results
+      final boolean[] falseResults = {false, false, false};
+
+      testFilters.add(1, filters);
+      testTrueResults.add(1, trueResults);
+      testFalseResults.add(1, falseResults);
+      testFileResults.add(1, Boolean.TRUE);
+      testFilenameResults.add(1, Boolean.TRUE);
+    }
+
+    // test 2 - Test conditional and with first filter returning false
+    {
+      // test 2 filters
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(falseFilters[1]);
+      filters.add(trueFilters[1]);
+      filters.add(trueFilters[2]);
+      filters.add(trueFilters[3]);
+      filters.add(falseFilters[2]);
+      filters.add(falseFilters[3]);
+      // test 2 true results
+      final boolean[] trueResults = {false, false, false};
+      // test 2 false results
+      final boolean[] falseResults = {true, false, false};
+
+      testFilters.add(2, filters);
+      testTrueResults.add(2, trueResults);
+      testFalseResults.add(2, falseResults);
+      testFileResults.add(2, Boolean.FALSE);
+      testFilenameResults.add(2, Boolean.FALSE);
+    }
+
+    // test 3 - Test conditional and with second filter returning false
+    {
+      // test 3 filters
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(trueFilters[1]);
+      filters.add(falseFilters[1]);
+      filters.add(trueFilters[2]);
+      filters.add(trueFilters[3]);
+      filters.add(falseFilters[2]);
+      filters.add(falseFilters[3]);
+      // test 3 true results
+      final boolean[] trueResults = {true, false, false};
+      // test 3 false results
+      final boolean[] falseResults = {true, false, false};
+
+      testFilters.add(3, filters);
+      testTrueResults.add(3, trueResults);
+      testFalseResults.add(3, falseResults);
+      testFileResults.add(3, Boolean.FALSE);
+      testFilenameResults.add(3, Boolean.FALSE);
+    }
+
+    // test 4 - Test conditional and with third filter returning false
+    {
+      // test 4 filters
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(trueFilters[1]);
+      filters.add(trueFilters[2]);
+      filters.add(falseFilters[1]);
+      filters.add(trueFilters[3]);
+      filters.add(falseFilters[2]);
+      filters.add(falseFilters[3]);
+      // test 4 true results
+      final boolean[] trueResults = {true, true, false};
+      // test 4 false results
+      final boolean[] falseResults = {true, false, false};
+
+      testFilters.add(4, filters);
+      testTrueResults.add(4, trueResults);
+      testFalseResults.add(4, falseResults);
+      testFileResults.add(4, Boolean.FALSE);
+      testFilenameResults.add(4, Boolean.FALSE);
+    }
+
+    // test 5 - Test conditional and with first and third filters returning false
+    {
+      // test 5 filters
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(falseFilters[1]);
+      filters.add(trueFilters[1]);
+      filters.add(falseFilters[2]);
+      filters.add(falseFilters[3]);
+      filters.add(trueFilters[2]);
+      filters.add(trueFilters[3]);
+      // test 5 true results
+      final boolean[] trueResults = {false, false, false};
+      // test 5 false results
+      final boolean[] falseResults = {true, false, false};
+
+      testFilters.add(5, filters);
+      testTrueResults.add(5, trueResults);
+      testFalseResults.add(5, falseResults);
+      testFileResults.add(5, Boolean.FALSE);
+      testFilenameResults.add(5, Boolean.FALSE);
+    }
+
+    // test 6 - Test conditional and with second and third filters returning false
+    {
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(trueFilters[1]);
+      filters.add(falseFilters[1]);
+      filters.add(falseFilters[2]);
+      filters.add(trueFilters[2]);
+      filters.add(trueFilters[3]);
+      filters.add(falseFilters[3]);
+      // test 6 true results
+      final boolean[] trueResults = {true, false, false};
+      // test 6 false results
+      final boolean[] falseResults = {true, false, false};
+
+      testFilters.add(6, filters);
+      testTrueResults.add(6, trueResults);
+      testFalseResults.add(6, falseResults);
+      testFileResults.add(6, Boolean.FALSE);
+      testFilenameResults.add(6, Boolean.FALSE);
+    }
+
+    // test 7 - Test conditional and with first and second filters returning false
+    {
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(falseFilters[1]);
+      filters.add(falseFilters[2]);
+      filters.add(trueFilters[3]);
+      filters.add(falseFilters[3]);
+      filters.add(trueFilters[2]);
+      filters.add(trueFilters[3]);
+      // test 7 true results
+      final boolean[] trueResults = {false, false, false};
+      // test 7 false results
+      final boolean[] falseResults = {true, false, false};
+
+      testFilters.add(7, filters);
+      testTrueResults.add(7, trueResults);
+      testFalseResults.add(7, falseResults);
+      testFileResults.add(7, Boolean.FALSE);
+      testFilenameResults.add(7, Boolean.FALSE);
+    }
+
+    // test 8 - Test conditional and with fourth filters returning false
+    {
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(trueFilters[1]);
+      filters.add(trueFilters[2]);
+      filters.add(trueFilters[3]);
+      filters.add(falseFilters[1]);
+      // test 8 true results
+      final boolean[] trueResults = {true, true, true};
+      // test 8 false results
+      final boolean[] falseResults = {true, false, false};
+
+      testFilters.add(8, filters);
+      testTrueResults.add(8, trueResults);
+      testFalseResults.add(8, falseResults);
+      testFileResults.add(8, Boolean.FALSE);
+      testFilenameResults.add(8, Boolean.FALSE);
+    }
+
+    // test 9 - Test conditional and with all filters returning false
+    {
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(falseFilters[1]);
+      filters.add(falseFilters[2]);
+      filters.add(falseFilters[3]);
+      // test 9 true results
+      final boolean[] trueResults = {false, false, false};
+      // test 9 false results
+      final boolean[] falseResults = {true, false, false};
+
+      testFilters.add(9, filters);
+      testTrueResults.add(9, trueResults);
+      testFalseResults.add(9, falseResults);
+      testFileResults.add(9, Boolean.FALSE);
+      testFilenameResults.add(9, Boolean.FALSE);
+    }
+  }
+}
diff --git a/src/test/java/org/apache/commons/io/filefilter/ConditionalFileFilterAbstractTest.java b/src/test/java/org/apache/commons/io/filefilter/ConditionalFileFilterAbstractTest.java
new file mode 100644
index 0000000..daffdb4
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/filefilter/ConditionalFileFilterAbstractTest.java
@@ -0,0 +1,184 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public abstract class ConditionalFileFilterAbstractTest extends IOFileFilterAbstractTest {
+
+    private static final String TEST_FILE_NAME_PREFIX = "TestFile";
+    private static final String TEST_FILE_TYPE = ".tst";
+
+    protected TesterTrueFileFilter[] trueFilters;
+    protected TesterFalseFileFilter[] falseFilters;
+
+    private File file;
+    private File workingPath;
+
+    protected abstract IOFileFilter buildFilterUsingAdd(List<IOFileFilter> filters);
+
+    protected abstract IOFileFilter buildFilterUsingConstructor(List<IOFileFilter> filters);
+
+    protected abstract ConditionalFileFilter getConditionalFileFilter();
+
+    protected abstract String getDefaultWorkingPath();
+
+    protected abstract List<boolean[]> getFalseResults();
+
+    protected abstract List<Boolean> getFilenameResults();
+
+    protected abstract List<Boolean> getFileResults();
+
+    protected abstract List<List<IOFileFilter>> getTestFilters();
+
+    protected abstract List<boolean[]> getTrueResults();
+
+    protected abstract String getWorkingPathNamePropertyKey();
+
+    @BeforeEach
+    public void setUp() {
+        this.workingPath = determineWorkingDirectoryPath(this.getWorkingPathNamePropertyKey(), this.getDefaultWorkingPath());
+        this.file = new File(this.workingPath, TEST_FILE_NAME_PREFIX + 1 + TEST_FILE_TYPE);
+        this.trueFilters = new TesterTrueFileFilter[4];
+        this.falseFilters = new TesterFalseFileFilter[4];
+        this.trueFilters[1] = new TesterTrueFileFilter();
+        this.trueFilters[2] = new TesterTrueFileFilter();
+        this.trueFilters[3] = new TesterTrueFileFilter();
+        this.falseFilters[1] = new TesterFalseFileFilter();
+        this.falseFilters[2] = new TesterFalseFileFilter();
+        this.falseFilters[3] = new TesterFalseFileFilter();
+    }
+
+    @Test
+    public void testAdd() {
+        final List<TesterTrueFileFilter> filters = new ArrayList<>();
+        final ConditionalFileFilter fileFilter = this.getConditionalFileFilter();
+        filters.add(new TesterTrueFileFilter());
+        filters.add(new TesterTrueFileFilter());
+        filters.add(new TesterTrueFileFilter());
+        filters.add(new TesterTrueFileFilter());
+        for (int i = 0; i < filters.size(); i++) {
+            assertEquals(i, fileFilter.getFileFilters().size(), "file filters count: ");
+            fileFilter.addFileFilter(filters.get(i));
+            assertEquals(i + 1, fileFilter.getFileFilters().size(), "file filters count: ");
+        }
+        fileFilter.getFileFilters().forEach(filter -> {
+            assertTrue(filters.contains(filter), "found file filter");
+        });
+        assertEquals(filters.size(), fileFilter.getFileFilters().size(), "file filters count");
+    }
+
+    @Test
+    public void testFilterBuiltUsingAdd() {
+        final List<List<IOFileFilter>> testFilters = this.getTestFilters();
+        final List<boolean[]> testTrueResults = this.getTrueResults();
+        final List<boolean[]> testFalseResults = this.getFalseResults();
+        final List<Boolean> testFileResults = this.getFileResults();
+        final List<Boolean> testFilenameResults = this.getFilenameResults();
+
+        for (int i = 1; i < testFilters.size(); i++) {
+            final List<IOFileFilter> filters = testFilters.get(i);
+            final boolean[] trueResults = testTrueResults.get(i);
+            final boolean[] falseResults = testFalseResults.get(i);
+            final boolean fileResults = testFileResults.get(i);
+            final boolean filenameResults = testFilenameResults.get(i);
+
+            // Test conditional AND filter created by passing filters to the constructor
+            final IOFileFilter filter = this.buildFilterUsingAdd(filters);
+
+            // Test as a file filter
+            resetTrueFilters(this.trueFilters);
+            resetFalseFilters(this.falseFilters);
+            assertFileFiltering(i, filter, this.file, fileResults);
+            assertTrueFiltersInvoked(i, trueFilters, trueResults);
+            assertFalseFiltersInvoked(i, falseFilters, falseResults);
+
+            // Test as a filename filter
+            resetTrueFilters(this.trueFilters);
+            resetFalseFilters(this.falseFilters);
+            assertFilenameFiltering(i, filter, this.file, filenameResults);
+            assertTrueFiltersInvoked(i, trueFilters, trueResults);
+            assertFalseFiltersInvoked(i, falseFilters, falseResults);
+        }
+    }
+
+    @Test
+    public void testFilterBuiltUsingConstructor() {
+        final List<List<IOFileFilter>> testFilters = this.getTestFilters();
+        final List<boolean[]> testTrueResults = this.getTrueResults();
+        final List<boolean[]> testFalseResults = this.getFalseResults();
+        final List<Boolean> testFileResults = this.getFileResults();
+        final List<Boolean> testFilenameResults = this.getFilenameResults();
+
+        for (int i = 1; i < testFilters.size(); i++) {
+            final List<IOFileFilter> filters = testFilters.get(i);
+            final boolean[] trueResults = testTrueResults.get(i);
+            final boolean[] falseResults = testFalseResults.get(i);
+            final boolean fileResults = testFileResults.get(i);
+            final boolean filenameResults = testFilenameResults.get(i);
+
+            // Test conditional AND filter created by passing filters to the constructor
+            final IOFileFilter filter = this.buildFilterUsingConstructor(filters);
+
+            // Test as a file filter
+            resetTrueFilters(this.trueFilters);
+            resetFalseFilters(this.falseFilters);
+            assertFileFiltering(i, filter, this.file, fileResults);
+            assertTrueFiltersInvoked(i, trueFilters, trueResults);
+            assertFalseFiltersInvoked(i, falseFilters, falseResults);
+
+            // Test as a filename filter
+            resetTrueFilters(this.trueFilters);
+            resetFalseFilters(this.falseFilters);
+            assertFilenameFiltering(i, filter, this.file, filenameResults);
+            assertTrueFiltersInvoked(i, trueFilters, trueResults);
+            assertFalseFiltersInvoked(i, falseFilters, falseResults);
+        }
+    }
+
+    @Test
+    public void testNoFilters() {
+        final ConditionalFileFilter fileFilter = this.getConditionalFileFilter();
+        final File file = new File(this.workingPath, TEST_FILE_NAME_PREFIX + 1 + TEST_FILE_TYPE);
+        assertFileFiltering(1, (IOFileFilter) fileFilter, file, false);
+        assertFilenameFiltering(1, (IOFileFilter) fileFilter, file, false);
+    }
+
+    @Test
+    public void testRemove() {
+        final List<TesterTrueFileFilter> filters = new ArrayList<>();
+        final ConditionalFileFilter fileFilter = this.getConditionalFileFilter();
+        filters.add(new TesterTrueFileFilter());
+        filters.add(new TesterTrueFileFilter());
+        filters.add(new TesterTrueFileFilter());
+        filters.add(new TesterTrueFileFilter());
+        filters.forEach(filter -> {
+            fileFilter.removeFileFilter(filter);
+            assertFalse(fileFilter.getFileFilters().contains(filter), "file filter removed");
+        });
+        assertEquals(0, fileFilter.getFileFilters().size(), "file filters count");
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/filefilter/DirectoryFileFilterTest.java b/src/test/java/org/apache/commons/io/filefilter/DirectoryFileFilterTest.java
new file mode 100644
index 0000000..eae1796
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/filefilter/DirectoryFileFilterTest.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Collections;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.file.AccumulatorPathVisitor;
+import org.apache.commons.io.file.CounterAssertions;
+import org.apache.commons.io.file.Counters;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link DirectoryFileFilter}.
+ */
+public class DirectoryFileFilterTest {
+
+    /**
+     * Javadoc example.
+     *
+     * System.out calls are commented out here but not in the Javadoc.
+     */
+    @Test
+    public void testJavadocExampleUsingIo() {
+        final File dir = FileUtils.current();
+        final String[] files = dir.list(DirectoryFileFilter.INSTANCE);
+        // End of Javadoc example
+        assertTrue(files.length > 0);
+    }
+
+    /**
+     * Javadoc example.
+     *
+     * System.out calls are commented out here but not in the Javadoc.
+     */
+    @Test
+    public void testJavadocExampleUsingNio() throws IOException {
+        final Path dir = Paths.get("");
+        final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(DirectoryFileFilter.INSTANCE,
+            TrueFileFilter.INSTANCE);
+        //
+        // Walk one dir
+        Files.walkFileTree(dir, Collections.emptySet(), 1, visitor);
+        // System.out.println(visitor.getPathCounters());
+        // System.out.println(visitor.getFileList());
+        //
+        visitor.getPathCounters().reset();
+        //
+        // Walk dir tree
+        Files.walkFileTree(dir, visitor);
+        // System.out.println(visitor.getPathCounters());
+        // System.out.println(visitor.getDirList());
+        // System.out.println(visitor.getFileList());
+        //
+        // End of Javadoc example
+        assertEquals(0, visitor.getPathCounters().getFileCounter().get());
+        assertTrue(visitor.getPathCounters().getDirectoryCounter().get() > 0);
+        assertEquals(0, visitor.getPathCounters().getByteCounter().get());
+        // We counted and accumulated
+        assertFalse(visitor.getDirList().isEmpty());
+        assertFalse(visitor.getFileList().isEmpty());
+        //
+        assertNotEquals(Counters.noopPathCounters(), visitor.getPathCounters());
+        visitor.getPathCounters().reset();
+        CounterAssertions.assertZeroCounters(visitor.getPathCounters());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/filefilter/FileFilterTest.java b/src/test/java/org/apache/commons/io/filefilter/FileFilterTest.java
new file mode 100644
index 0000000..3b8f4bc
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/filefilter/FileFilterTest.java
@@ -0,0 +1,1417 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileFilter;
+import java.io.FilenameFilter;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.IOCase;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.file.PathUtils;
+import org.apache.commons.io.file.TempFile;
+import org.apache.commons.io.test.TestUtils;
+import org.apache.commons.lang3.SystemUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Used to test FileFilterUtils.
+ */
+public class FileFilterTest extends AbstractFilterTest {
+
+    @Test
+    public void testAgeFilter() throws Exception {
+        final File oldFile = new File(temporaryFolder, "old.txt");
+        final Path oldPath = oldFile.toPath();
+        final File reference = new File(temporaryFolder, "reference.txt");
+        final File newFile = new File(temporaryFolder, "new.txt");
+        final Path newPath = newFile.toPath();
+
+        if (!oldFile.getParentFile().exists()) {
+            fail("Cannot create file " + oldFile + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output1 = new BufferedOutputStream(Files.newOutputStream(oldFile.toPath()))) {
+            TestUtils.generateTestData(output1, 0);
+        }
+
+        do {
+            try {
+                TestUtils.sleep(1000);
+            } catch (final InterruptedException ie) {
+                // ignore
+            }
+            if (!reference.getParentFile().exists()) {
+                fail("Cannot create file " + reference + " as the parent directory does not exist");
+            }
+            try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(reference.toPath()))) {
+                TestUtils.generateTestData(output, 0);
+            }
+        } while (equalsLastModified(oldFile, reference));
+
+        final Date date = new Date();
+        final long now = date.getTime();
+
+        do {
+            try {
+                TestUtils.sleep(1000);
+            } catch (final InterruptedException ie) {
+                // ignore
+            }
+            if (!newFile.getParentFile().exists()) {
+                fail("Cannot create file " + newFile + " as the parent directory does not exist");
+            }
+            try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(newFile.toPath()))) {
+                TestUtils.generateTestData(output, 0);
+            }
+        } while (equalsLastModified(reference, newFile));
+
+        final IOFileFilter filter1 = FileFilterUtils.ageFileFilter(now);
+        final IOFileFilter filter2 = FileFilterUtils.ageFileFilter(now, true);
+        final IOFileFilter filter3 = FileFilterUtils.ageFileFilter(now, false);
+        final IOFileFilter filter4 = FileFilterUtils.ageFileFilter(date);
+        final IOFileFilter filter5 = FileFilterUtils.ageFileFilter(date, true);
+        final IOFileFilter filter6 = FileFilterUtils.ageFileFilter(date, false);
+        final IOFileFilter filter7 = FileFilterUtils.ageFileFilter(reference);
+        final IOFileFilter filter8 = FileFilterUtils.ageFileFilter(reference, true);
+        final IOFileFilter filter9 = FileFilterUtils.ageFileFilter(reference, false);
+
+        assertFiltering(filter1, oldFile, true);
+        assertFiltering(filter2, oldFile, true);
+        assertFiltering(filter3, oldFile, false);
+        assertFiltering(filter4, oldFile, true);
+        assertFiltering(filter5, oldFile, true);
+        assertFiltering(filter6, oldFile, false);
+        assertFiltering(filter7, oldFile, true);
+        assertFiltering(filter8, oldFile, true);
+        assertFiltering(filter9, oldFile, false);
+        assertFiltering(filter1, newFile, false);
+        assertFiltering(filter2, newFile, false);
+        assertFiltering(filter3, newFile, true);
+        assertFiltering(filter4, newFile, false);
+        assertFiltering(filter5, newFile, false);
+        assertFiltering(filter6, newFile, true);
+        assertFiltering(filter7, newFile, false);
+        assertFiltering(filter8, newFile, false);
+        assertFiltering(filter9, newFile, true);
+        //
+        assertFiltering(filter1, oldPath, true);
+        assertFiltering(filter2, oldPath, true);
+        assertFiltering(filter3, oldPath, false);
+        assertFiltering(filter4, oldPath, true);
+        assertFiltering(filter5, oldPath, true);
+        assertFiltering(filter6, oldPath, false);
+        assertFiltering(filter7, oldPath, true);
+        assertFiltering(filter8, oldPath, true);
+        assertFiltering(filter9, oldPath, false);
+        assertFiltering(filter1, newPath, false);
+        assertFiltering(filter2, newPath, false);
+        assertFiltering(filter3, newPath, true);
+        assertFiltering(filter4, newPath, false);
+        assertFiltering(filter5, newPath, false);
+        assertFiltering(filter6, newPath, true);
+        assertFiltering(filter7, newPath, false);
+        assertFiltering(filter8, newPath, false);
+        assertFiltering(filter9, newPath, true);
+    }
+
+    @Test
+    public void testAnd() {
+        final IOFileFilter trueFilter = TrueFileFilter.INSTANCE;
+        final IOFileFilter falseFilter = FalseFileFilter.INSTANCE;
+        assertFiltering(trueFilter.and(trueFilter), new File("foo.test"), true);
+        assertFiltering(trueFilter.and(falseFilter), new File("foo.test"), false);
+        assertFiltering(falseFilter.and(trueFilter), new File("foo.test"), false);
+        assertFiltering(falseFilter.and(falseFilter), new File("foo.test"), false);
+    }
+
+    @Test
+    public void testAnd2() {
+        final IOFileFilter trueFilter = TrueFileFilter.INSTANCE;
+        final IOFileFilter falseFilter = FalseFileFilter.INSTANCE;
+        assertFiltering(new AndFileFilter(trueFilter, trueFilter), new File("foo.test"), true);
+        assertFiltering(new AndFileFilter(trueFilter, falseFilter), new File("foo.test"), false);
+        assertFiltering(new AndFileFilter(falseFilter, trueFilter), new File("foo.test"), false);
+        assertFiltering(new AndFileFilter(falseFilter, falseFilter), new File("foo.test"), false);
+
+        final List<IOFileFilter> filters = new ArrayList<>();
+        assertFiltering(new AndFileFilter(filters), new File("test"), false);
+        assertFiltering(new AndFileFilter(), new File("test"), false);
+
+        assertThrows(NullPointerException.class, () -> new AndFileFilter(falseFilter, null));
+        assertThrows(NullPointerException.class, () -> new AndFileFilter(null, falseFilter));
+        assertThrows(NullPointerException.class, () -> new AndFileFilter((List<IOFileFilter>) null));
+    }
+
+    @Test
+    public void testAndArray() {
+        final IOFileFilter trueFilter = TrueFileFilter.INSTANCE;
+        final IOFileFilter falseFilter = FalseFileFilter.INSTANCE;
+        assertFiltering(new AndFileFilter(trueFilter, trueFilter, trueFilter), new File("foo.test"), true);
+        assertFiltering(new AndFileFilter(trueFilter, falseFilter, falseFilter), new File("foo.test"), false);
+        assertFiltering(new AndFileFilter(falseFilter, trueFilter, trueFilter), new File("foo.test"), false);
+        assertFiltering(new AndFileFilter(falseFilter, falseFilter, falseFilter), new File("foo.test"), false);
+
+        final List<IOFileFilter> filters = new ArrayList<>();
+        assertFiltering(new AndFileFilter(filters), new File("test"), false);
+        assertFiltering(new AndFileFilter(), new File("test"), false);
+    }
+
+    @Test
+    public void testCanExecute() throws Exception {
+        assumeTrue(SystemUtils.IS_OS_WINDOWS);
+        try (TempFile executablePath = TempFile.create(getClass().getSimpleName(), null)) {
+            final File executableFile = executablePath.toFile();
+            try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(executablePath.get()))) {
+                TestUtils.generateTestData(output, 32);
+            }
+            assertTrue(executableFile.setExecutable(true));
+            assertFiltering(CanExecuteFileFilter.CAN_EXECUTE, executablePath.get(), true);
+            assertFiltering(CanExecuteFileFilter.CAN_EXECUTE, executableFile, true);
+            executableFile.setExecutable(false);
+            assertFiltering(CanExecuteFileFilter.CANNOT_EXECUTE, executablePath.get(), false);
+            assertFiltering(CanExecuteFileFilter.CANNOT_EXECUTE, executableFile, false);
+        }
+    }
+
+    @Test
+    public void testCanRead() throws Exception {
+        final File readOnlyFile = new File(temporaryFolder, "read-only-file1.txt");
+        final Path readOnlyPath = readOnlyFile.toPath();
+        if (!readOnlyFile.getParentFile().exists()) {
+            fail("Cannot create file " + readOnlyFile + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(readOnlyFile.toPath()))) {
+            TestUtils.generateTestData(output, 32);
+        }
+        assertTrue(readOnlyFile.setReadOnly());
+        assertFiltering(CanReadFileFilter.CAN_READ, readOnlyFile, true);
+        assertFiltering(CanReadFileFilter.CAN_READ, readOnlyPath, true);
+        assertFiltering(CanReadFileFilter.CANNOT_READ, readOnlyFile, false);
+        assertFiltering(CanReadFileFilter.CANNOT_READ, readOnlyPath, false);
+        assertFiltering(CanReadFileFilter.READ_ONLY, readOnlyFile, true);
+        assertFiltering(CanReadFileFilter.READ_ONLY, readOnlyPath, true);
+        readOnlyFile.delete();
+    }
+
+    @Test
+    public void testCanWrite() throws Exception {
+        final File readOnlyFile = new File(temporaryFolder, "read-only-file2.txt");
+        final Path readOnlyPath = readOnlyFile.toPath();
+        if (!readOnlyFile.getParentFile().exists()) {
+            fail("Cannot create file " + readOnlyFile + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(readOnlyFile.toPath()))) {
+            TestUtils.generateTestData(output, 32);
+        }
+        assertTrue(readOnlyFile.setReadOnly());
+        assertFiltering(CanWriteFileFilter.CAN_WRITE, temporaryFolder, true);
+        assertFiltering(CanWriteFileFilter.CANNOT_WRITE, temporaryFolder, false);
+        assertFiltering(CanWriteFileFilter.CAN_WRITE, readOnlyFile, false);
+        assertFiltering(CanWriteFileFilter.CAN_WRITE, readOnlyPath, false);
+        assertFiltering(CanWriteFileFilter.CANNOT_WRITE, readOnlyFile, true);
+        assertFiltering(CanWriteFileFilter.CANNOT_WRITE, readOnlyPath, true);
+        readOnlyFile.delete();
+    }
+
+    @Test
+    public void testDelegateFileFilter() {
+        final OrFileFilter orFilter = new OrFileFilter();
+        final File testFile = new File("test.txt");
+
+        IOFileFilter filter = new DelegateFileFilter((FileFilter) orFilter);
+        assertFiltering(filter, testFile, false);
+        assertNotNull(filter.toString()); // TODO better test
+
+        filter = new DelegateFileFilter((FilenameFilter) orFilter);
+        assertFiltering(filter, testFile, false);
+        assertNotNull(filter.toString()); // TODO better test
+
+        assertThrows(NullPointerException.class, () -> new DelegateFileFilter((FileFilter) null));
+        assertThrows(NullPointerException.class, () -> new DelegateFileFilter((FilenameFilter) null));
+    }
+
+    @Test
+    public void testDelegation() { // TODO improve these tests
+        assertNotNull(FileFilterUtils.asFileFilter((FileFilter) FalseFileFilter.INSTANCE));
+        assertNotNull(FileFilterUtils.asFileFilter((FilenameFilter) FalseFileFilter.INSTANCE).toString());
+    }
+
+    @SuppressWarnings("deprecation")
+    @Test
+    public void testDeprecatedWildcard() {
+        IOFileFilter filter = new WildcardFilter("*.txt");
+        final List<String> patternList = Arrays.asList("*.txt", "*.xml", "*.gif");
+        final IOFileFilter listFilter = new WildcardFilter(patternList);
+        final File txtFile = new File("test.txt");
+        final Path txtPath = txtFile.toPath();
+        final File bmpFile = new File("test.bmp");
+        final Path bmpPath = bmpFile.toPath();
+        final File dirFile = new File("src/java");
+        final Path dirPath = dirFile.toPath();
+
+        assertFiltering(filter, new File("log.txt"), true);
+//        assertFiltering(filter, new File("log.txt.bak"), false);
+        assertFiltering(filter, new File("log.txt").toPath(), true);
+
+        filter = new WildcardFilter("log?.txt");
+        assertFiltering(filter, new File("log1.txt"), true);
+        assertFiltering(filter, new File("log12.txt"), false);
+        //
+        assertFiltering(filter, new File("log1.txt").toPath(), true);
+        assertFiltering(filter, new File("log12.txt").toPath(), false);
+
+        filter = new WildcardFilter("open??.????04");
+        assertFiltering(filter, new File("openAB.102504"), true);
+        assertFiltering(filter, new File("openA.102504"), false);
+        assertFiltering(filter, new File("openXY.123103"), false);
+//        assertFiltering(filter, new File("openAB.102504.old"), false);
+        //
+        assertFiltering(filter, new File("openAB.102504").toPath(), true);
+        assertFiltering(filter, new File("openA.102504").toPath(), false);
+        assertFiltering(filter, new File("openXY.123103").toPath(), false);
+//        assertFiltering(filter, new File("openAB.102504.old").toPath(), false);
+
+        filter = new WildcardFilter("*.java", "*.class");
+        assertFiltering(filter, new File("Test.java"), true);
+        assertFiltering(filter, new File("Test.class"), true);
+        assertFiltering(filter, new File("Test.jsp"), false);
+        //
+        assertFiltering(filter, new File("Test.java").toPath(), true);
+        assertFiltering(filter, new File("Test.class").toPath(), true);
+        assertFiltering(filter, new File("Test.jsp").toPath(), false);
+
+        assertFiltering(listFilter, new File("Test.txt"), true);
+        assertFiltering(listFilter, new File("Test.xml"), true);
+        assertFiltering(listFilter, new File("Test.gif"), true);
+        assertFiltering(listFilter, new File("Test.bmp"), false);
+        //
+        assertFiltering(listFilter, new File("Test.txt").toPath(), true);
+        assertFiltering(listFilter, new File("Test.xml").toPath(), true);
+        assertFiltering(listFilter, new File("Test.gif").toPath(), true);
+        assertFiltering(listFilter, new File("Test.bmp").toPath(), false);
+
+        assertTrue(listFilter.accept(txtFile));
+        assertFalse(listFilter.accept(bmpFile));
+        assertFalse(listFilter.accept(dirFile));
+        //
+        assertEquals(FileVisitResult.CONTINUE, listFilter.accept(txtPath, null));
+        assertEquals(FileVisitResult.TERMINATE, listFilter.accept(bmpPath, null));
+        assertEquals(FileVisitResult.TERMINATE, listFilter.accept(dirPath, null));
+
+        assertTrue(listFilter.accept(txtFile.getParentFile(), txtFile.getName()));
+        assertFalse(listFilter.accept(bmpFile.getParentFile(), bmpFile.getName()));
+        assertFalse(listFilter.accept(dirFile.getParentFile(), dirFile.getName()));
+        //
+        assertEquals(FileVisitResult.CONTINUE, listFilter.accept(txtPath, null));
+        assertEquals(FileVisitResult.TERMINATE, listFilter.accept(bmpPath, null));
+        assertEquals(FileVisitResult.TERMINATE, listFilter.accept(dirPath, null));
+
+        assertThrows(NullPointerException.class, () -> new WildcardFilter((String) null));
+        assertThrows(NullPointerException.class, () -> new WildcardFilter((String[]) null));
+        assertThrows(NullPointerException.class, () -> new WildcardFilter((List<String>) null));
+    }
+
+    @Test
+    public void testDirectory() {
+        // XXX: This test presumes the current working dir is the base dir of the source checkout.
+        final IOFileFilter filter = new DirectoryFileFilter();
+
+        assertFiltering(filter, new File("src/"), true);
+        assertFiltering(filter, new File("src/").toPath(), true);
+        assertFiltering(filter, new File("src/main/java/"), true);
+        assertFiltering(filter, new File("src/main/java/").toPath(), true);
+
+        assertFiltering(filter, new File("pom.xml"), false);
+        assertFiltering(filter, new File("pom.xml").toPath(), false);
+
+        assertFiltering(filter, new File("imaginary"), false);
+        assertFiltering(filter, new File("imaginary").toPath(), false);
+        assertFiltering(filter, new File("imaginary/"), false);
+        assertFiltering(filter, new File("imaginary/").toPath(), false);
+
+        assertFiltering(filter, new File("LICENSE.txt"), false);
+        assertFiltering(filter, new File("LICENSE.txt").toPath(), false);
+
+        assertSame(DirectoryFileFilter.DIRECTORY, DirectoryFileFilter.INSTANCE);
+    }
+
+    @Test
+    public void testEmpty() throws Exception {
+
+        // Empty Dir
+        final File emptyDirFile = new File(temporaryFolder, "empty-dir");
+        final Path emptyDirPath = emptyDirFile.toPath();
+        emptyDirFile.mkdirs();
+        assertFiltering(EmptyFileFilter.EMPTY, emptyDirFile, true);
+        assertFiltering(EmptyFileFilter.EMPTY, emptyDirPath, true);
+        assertFiltering(EmptyFileFilter.NOT_EMPTY, emptyDirFile, false);
+        assertFiltering(EmptyFileFilter.NOT_EMPTY, emptyDirPath, false);
+
+        // Empty File
+        final File emptyFile = new File(emptyDirFile, "empty-file.txt");
+        final Path emptyPath = emptyFile.toPath();
+        if (!emptyFile.getParentFile().exists()) {
+            fail("Cannot create file " + emptyFile + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output1 = new BufferedOutputStream(Files.newOutputStream(emptyFile.toPath()))) {
+            TestUtils.generateTestData(output1, 0);
+        }
+        assertFiltering(EmptyFileFilter.EMPTY, emptyFile, true);
+        assertFiltering(EmptyFileFilter.EMPTY, emptyPath, true);
+        assertFiltering(EmptyFileFilter.NOT_EMPTY, emptyFile, false);
+        assertFiltering(EmptyFileFilter.NOT_EMPTY, emptyPath, false);
+
+        // Not Empty Dir
+        assertFiltering(EmptyFileFilter.EMPTY, emptyDirFile, false);
+        assertFiltering(EmptyFileFilter.EMPTY, emptyDirPath, false);
+        assertFiltering(EmptyFileFilter.NOT_EMPTY, emptyDirFile, true);
+        assertFiltering(EmptyFileFilter.NOT_EMPTY, emptyDirPath, true);
+
+        // Not Empty File
+        final File notEmptyFile = new File(emptyDirFile, "not-empty-file.txt");
+        final Path notEmptyPath = notEmptyFile.toPath();
+        if (!notEmptyFile.getParentFile().exists()) {
+            fail("Cannot create file " + notEmptyFile + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(notEmptyFile.toPath()))) {
+            TestUtils.generateTestData(output, 32);
+        }
+        assertFiltering(EmptyFileFilter.EMPTY, notEmptyFile, false);
+        assertFiltering(EmptyFileFilter.EMPTY, notEmptyPath, false);
+        assertFiltering(EmptyFileFilter.NOT_EMPTY, notEmptyFile, true);
+        assertFiltering(EmptyFileFilter.NOT_EMPTY, notEmptyPath, true);
+        FileUtils.forceDelete(emptyDirFile);
+    }
+
+    @Test
+    public void testEnsureTestCoverage() {
+        assertNotNull(new FileFilterUtils()); // dummy for test coverage
+    }
+
+    @Test
+    public void testFalse() {
+        final IOFileFilter filter = FileFilterUtils.falseFileFilter();
+        assertFiltering(filter, new File("foo.test"), false);
+        assertFiltering(filter, new File("foo.test").toPath(), false);
+        assertFiltering(filter, new File("foo"), false);
+        assertFiltering(filter, new File("foo").toPath(), false);
+        assertFiltering(filter, (File) null, false);
+        assertFiltering(filter, (Path) null, false);
+        assertSame(FalseFileFilter.FALSE, FalseFileFilter.INSTANCE);
+        assertSame(TrueFileFilter.TRUE, FalseFileFilter.INSTANCE.negate());
+        assertSame(TrueFileFilter.INSTANCE, FalseFileFilter.INSTANCE.negate());
+        assertNotNull(FalseFileFilter.INSTANCE.toString());
+    }
+
+    @Test
+    public void testFileEqualsFilter() {
+        assertFooBarFileFiltering(
+            new FileEqualsFileFilter(new File("foo")).or(new FileEqualsFileFilter(new File("bar"))));
+    }
+
+    @Test
+    public void testFileFilterUtils_and() {
+        final IOFileFilter trueFilter = TrueFileFilter.INSTANCE;
+        final IOFileFilter falseFilter = FalseFileFilter.INSTANCE;
+        assertFiltering(FileFilterUtils.and(trueFilter, trueFilter, trueFilter), new File("foo.test"), true);
+        assertFiltering(FileFilterUtils.and(trueFilter, falseFilter, trueFilter), new File("foo.test"), false);
+        assertFiltering(FileFilterUtils.and(falseFilter, trueFilter), new File("foo.test"), false);
+        assertFiltering(FileFilterUtils.and(falseFilter, falseFilter), new File("foo.test"), false);
+    }
+
+    @Test
+    public void testFileFilterUtils_or() {
+        final IOFileFilter trueFilter = TrueFileFilter.INSTANCE;
+        final IOFileFilter falseFilter = FalseFileFilter.INSTANCE;
+        final File testFile = new File("foo.test");
+        assertFiltering(FileFilterUtils.or(trueFilter, trueFilter), testFile, true);
+        assertFiltering(FileFilterUtils.or(trueFilter, trueFilter, falseFilter), testFile, true);
+        assertFiltering(FileFilterUtils.or(falseFilter, trueFilter), testFile, true);
+        assertFiltering(FileFilterUtils.or(falseFilter, falseFilter, falseFilter), testFile, false);
+    }
+
+    @Test
+    public void testFiles() {
+        // XXX: This test presumes the current working dir is the base dir of the source checkout.
+        final IOFileFilter filter = FileFileFilter.INSTANCE;
+
+        assertFiltering(filter, new File("src/"), false);
+        assertFiltering(filter, new File("src/").toPath(), false);
+        assertFiltering(filter, new File("src/java/"), false);
+        assertFiltering(filter, new File("src/java/").toPath(), false);
+
+        assertFiltering(filter, new File("pom.xml"), true);
+        assertFiltering(filter, new File("pom.xml").toPath(), true);
+
+        assertFiltering(filter, new File("imaginary"), false);
+        assertFiltering(filter, new File("imaginary").toPath(), false);
+        assertFiltering(filter, new File("imaginary/"), false);
+        assertFiltering(filter, new File("imaginary/").toPath(), false);
+
+        assertFiltering(filter, new File("LICENSE.txt"), true);
+        assertFiltering(filter, new File("LICENSE.txt").toPath(), true);
+    }
+
+    /*
+     * Test method for {@link FileFilterUtils#filter(IOFileFilter, java.lang.Iterable)} that tests that the method
+     * properly filters files from the list.
+     */
+    @Test
+    public void testFilterArray_fromList() throws Exception {
+        final File fileA = TestUtils.newFile(temporaryFolder, "A");
+        final File fileB = TestUtils.newFile(temporaryFolder, "B");
+        final List<File> fileList = Arrays.asList(fileA, fileB);
+
+        final IOFileFilter filter = FileFilterUtils.nameFileFilter("A");
+
+        final File[] filtered = FileFilterUtils.filter(filter, fileList);
+
+        assertEquals(1, filtered.length);
+        assertEquals(fileA, filtered[0]);
+    }
+
+    /*
+     * Test method for {@link FileFilterUtils#filter(IOFileFilter, File...)} that tests that the method properly filters
+     * files from the list.
+     */
+    @Test
+    public void testFilterArray_IOFileFilter() throws Exception {
+        final File fileA = TestUtils.newFile(temporaryFolder, "A");
+        final File fileB = TestUtils.newFile(temporaryFolder, "B");
+
+        final IOFileFilter filter = FileFilterUtils.nameFileFilter("A");
+
+        final File[] filtered = FileFilterUtils.filter(filter, fileA, fileB);
+
+        assertEquals(1, filtered.length);
+        assertEquals(fileA, filtered[0]);
+    }
+
+    /*
+     * Test method for {@link FileFilterUtils#filter(IOFileFilter, File...)} that tests that the method properly filters
+     * files from the list.
+     */
+    @Test
+    public void testFilterArray_PathVisitorFileFilter_FileExistsNo() throws Exception {
+        final File fileA = TestUtils.newFile(temporaryFolder, "A");
+        final File fileB = TestUtils.newFile(temporaryFolder, "B");
+
+        final IOFileFilter filter = new PathVisitorFileFilter(new NameFileFilter("A"));
+
+        final File[] filtered = FileFilterUtils.filter(filter, fileA, fileB);
+
+        assertEquals(1, filtered.length);
+        assertEquals(fileA, filtered[0]);
+    }
+
+    /*
+     * Test method for {@link FileFilterUtils#filter(IOFileFilter, File...)} that tests that the method properly filters
+     * files from the list.
+     */
+    @Test
+    public void testFilterArray_PathVisitorFileFilter_FileExistsYes() throws Exception {
+        final File fileA = TestUtils.newFile(temporaryFolder, "A");
+        final File fileB = TestUtils.newFile(temporaryFolder, "B");
+        FileUtils.write(fileA, "test", StandardCharsets.US_ASCII);
+
+        final IOFileFilter filter = new PathVisitorFileFilter(new NameFileFilter("A"));
+
+        final File[] filtered = FileFilterUtils.filter(filter, fileA, fileB);
+
+        assertEquals(1, filtered.length);
+        assertEquals(fileA, filtered[0]);
+    }
+
+    /*
+     * Test method for {@link FileFilterUtils#filter(IOFileFilter, File...)} that tests {@code null} parameters.
+     */
+    @Test
+    public void testFilterFilesArrayNullParameters() throws Exception {
+        final File fileA = TestUtils.newFile(temporaryFolder, "A");
+        final File fileB = TestUtils.newFile(temporaryFolder, "B");
+        assertThrows(NullPointerException.class, () -> FileFilterUtils.filter(null, fileA, fileB));
+
+        final IOFileFilter filter = FileFilterUtils.trueFileFilter();
+        FileFilterUtils.filter(filter, fileA, null);
+
+        final File[] filtered = FileFilterUtils.filter(filter, (File[]) null);
+        assertEquals(0, filtered.length);
+    }
+
+    /*
+     * Test method for {@link FileFilterUtils#filterList(IOFileFilter, java.lang.Iterable)} that tests that the method
+     * properly filters files from the list.
+     */
+    @Test
+    public void testFilterList() throws Exception {
+        final File fileA = TestUtils.newFile(temporaryFolder, "A");
+        final File fileB = TestUtils.newFile(temporaryFolder, "B");
+        final List<File> fileList = Arrays.asList(fileA, fileB);
+
+        final IOFileFilter filter = FileFilterUtils.nameFileFilter("A");
+
+        final List<File> filteredList = FileFilterUtils.filterList(filter, fileList);
+
+        assertTrue(filteredList.contains(fileA));
+        assertFalse(filteredList.contains(fileB));
+    }
+
+    /*
+     * Test method for {@link FileFilterUtils#filterList(IOFileFilter, File...)} that tests that the method properly
+     * filters files from the list.
+     */
+    @Test
+    public void testFilterList_fromArray() throws Exception {
+        final File fileA = TestUtils.newFile(temporaryFolder, "A");
+        final File fileB = TestUtils.newFile(temporaryFolder, "B");
+
+        final IOFileFilter filter = FileFilterUtils.nameFileFilter("A");
+
+        final List<File> filteredList = FileFilterUtils.filterList(filter, fileA, fileB);
+
+        assertTrue(filteredList.contains(fileA));
+        assertFalse(filteredList.contains(fileB));
+    }
+
+    /*
+     * Test method for {@link FileFilterUtils#filterList(IOFileFilter, java.lang.Iterable)} that tests {@code null}
+     * parameters and {@code null} elements in the provided list.
+     */
+    @Test
+    public void testFilterListNullParameters() {
+        assertThrows(NullPointerException.class, () -> FileFilterUtils.filterList(null, Collections.emptyList()));
+
+        final IOFileFilter filter = FileFilterUtils.trueFileFilter();
+        List<File> filteredList = FileFilterUtils.filterList(filter, Collections.singletonList(null));
+        assertEquals(1, filteredList.size());
+        assertEquals(null, filteredList.get(0));
+
+        filteredList = FileFilterUtils.filterList(filter, (List<File>) null);
+        assertEquals(0, filteredList.size());
+    }
+
+    /*
+     * Test method for {@link FileFilterUtils#filter(IOFileFilter, Path...)}.
+     */
+    @Test
+    public void testFilterPathsArrayNullParameters() throws Exception {
+        final Path fileA = TestUtils.newFile(temporaryFolder, "A").toPath();
+        final Path fileB = TestUtils.newFile(temporaryFolder, "B").toPath();
+        assertThrows(NullPointerException.class, () -> PathUtils.filter(null, fileA, fileB));
+
+        final IOFileFilter filter = FileFilterUtils.trueFileFilter();
+        PathUtils.filter(filter, fileA, null);
+
+        final File[] filtered = FileFilterUtils.filter(filter, (File[]) null);
+        assertEquals(0, filtered.length);
+    }
+
+    /*
+     * Test method for {@link FileFilterUtils#filterSet(IOFileFilter, java.lang.Iterable)} that tests that the method
+     * properly filters files from the set.
+     */
+    @Test
+    public void testFilterSet() throws Exception {
+        final File fileA = TestUtils.newFile(temporaryFolder, "A");
+        final File fileB = TestUtils.newFile(temporaryFolder, "B");
+        final Set<File> fileList = new HashSet<>(Arrays.asList(fileA, fileB));
+
+        final IOFileFilter filter = FileFilterUtils.nameFileFilter("A");
+
+        final Set<File> filteredSet = FileFilterUtils.filterSet(filter, fileList);
+
+        assertTrue(filteredSet.contains(fileA));
+        assertFalse(filteredSet.contains(fileB));
+    }
+
+    /*
+     * Test method for {@link FileFilterUtils#filterSet(IOFileFilter, File...)} that tests that the method properly
+     * filters files from the set.
+     */
+    @Test
+    public void testFilterSet_fromArray() throws Exception {
+        final File fileA = TestUtils.newFile(temporaryFolder, "A");
+        final File fileB = TestUtils.newFile(temporaryFolder, "B");
+
+        final IOFileFilter filter = FileFilterUtils.nameFileFilter("A");
+
+        final Set<File> filteredSet = FileFilterUtils.filterSet(filter, fileA, fileB);
+
+        assertTrue(filteredSet.contains(fileA));
+        assertFalse(filteredSet.contains(fileB));
+    }
+
+    /*
+     * Test method for {@link FileFilterUtils#filterSet(IOFileFilter, java.lang.Iterable)} that tests {@code null}
+     * parameters and {@code null} elements in the provided set.
+     */
+    @Test
+    public void testFilterSetNullParameters() {
+        assertThrows(NullPointerException.class, () -> FileFilterUtils.filterSet(null, Collections.emptySet()));
+
+        final IOFileFilter filter = FileFilterUtils.trueFileFilter();
+        FileFilterUtils.filterSet(filter, new HashSet<>(Collections.singletonList(null)));
+
+        final Set<File> filteredSet = FileFilterUtils.filterSet(filter, (Set<File>) null);
+        assertEquals(0, filteredSet.size());
+    }
+
+    @Test
+    public void testHidden() {
+        final File hiddenDirFile = new File(SVN_DIR_NAME);
+        final Path hiddenDirPath = hiddenDirFile.toPath();
+        if (hiddenDirFile.exists()) {
+            assertFiltering(HiddenFileFilter.HIDDEN, hiddenDirFile, hiddenDirFile.isHidden());
+            assertFiltering(HiddenFileFilter.HIDDEN, hiddenDirPath, hiddenDirFile.isHidden());
+            assertFiltering(HiddenFileFilter.VISIBLE, hiddenDirFile, !hiddenDirFile.isHidden());
+            assertFiltering(HiddenFileFilter.VISIBLE, hiddenDirPath, !hiddenDirFile.isHidden());
+        }
+        final Path path = temporaryFolder.toPath();
+        assertFiltering(HiddenFileFilter.HIDDEN, temporaryFolder, false);
+        assertFiltering(HiddenFileFilter.HIDDEN, path, false);
+        assertFiltering(HiddenFileFilter.VISIBLE, temporaryFolder, true);
+        assertFiltering(HiddenFileFilter.VISIBLE, path, true);
+    }
+
+    @Test
+    public void testMagicNumberFileFilterBytes() throws Exception {
+        final byte[] classFileMagicNumber = {(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE};
+        final String xmlFileContent = "<?xml version=\"1.0\" encoding=\"UTF-8\">\n" + "<element>text</element>";
+
+        final File classAFile = new File(temporaryFolder, "A.class");
+        final Path classAPath = classAFile.toPath();
+        final File xmlBFile = new File(temporaryFolder, "B.xml");
+        final Path xmlBPath = xmlBFile.toPath();
+        final File emptyFile = new File(temporaryFolder, "C.xml");
+        final Path emptyPath = emptyFile.toPath();
+        final File dirFile = new File(temporaryFolder, "D");
+        final Path dirPath = dirFile.toPath();
+        dirFile.mkdirs();
+
+        try (OutputStream classFileAStream = FileUtils.openOutputStream(classAFile)) {
+            IOUtils.write(classFileMagicNumber, classFileAStream);
+            TestUtils.generateTestData(classFileAStream, 32);
+        }
+
+        FileUtils.write(xmlBFile, xmlFileContent, StandardCharsets.UTF_8);
+        FileUtils.touch(emptyFile);
+
+        IOFileFilter filter = new MagicNumberFileFilter(classFileMagicNumber);
+
+        assertFiltering(filter, classAFile, true);
+        assertFiltering(filter, classAPath, true);
+        assertFiltering(filter, xmlBFile, false);
+        assertFiltering(filter, xmlBPath, false);
+        assertFiltering(filter, emptyFile, false);
+        assertFiltering(filter, emptyPath, false);
+        assertFiltering(filter, dirFile, false);
+        assertFiltering(filter, dirPath, false);
+
+        filter = FileFilterUtils.magicNumberFileFilter(classFileMagicNumber);
+
+        assertFiltering(filter, classAFile, true);
+        assertFiltering(filter, classAPath, true);
+        assertFiltering(filter, xmlBFile, false);
+        assertFiltering(filter, xmlBPath, false);
+        assertFiltering(filter, emptyFile, false);
+        assertFiltering(filter, emptyPath, false);
+        assertFiltering(filter, dirFile, false);
+        assertFiltering(filter, dirPath, false);
+    }
+
+    @Test
+    public void testMagicNumberFileFilterBytesOffset() throws Exception {
+        final byte[] tarMagicNumber = {0x75, 0x73, 0x74, 0x61, 0x72};
+        final long tarMagicNumberOffset = 257;
+
+        final File tarFileA = new File(temporaryFolder, "A.tar");
+        final File randomFileB = new File(temporaryFolder, "B.txt");
+        final File dir = new File(temporaryFolder, "D");
+        dir.mkdirs();
+
+        try (OutputStream tarFileAStream = FileUtils.openOutputStream(tarFileA)) {
+            TestUtils.generateTestData(tarFileAStream, tarMagicNumberOffset);
+            IOUtils.write(tarMagicNumber, tarFileAStream);
+        }
+
+        if (!randomFileB.getParentFile().exists()) {
+            fail("Cannot create file " + randomFileB + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(randomFileB.toPath()))) {
+            TestUtils.generateTestData(output, 2 * tarMagicNumberOffset);
+        }
+
+        IOFileFilter filter = new MagicNumberFileFilter(tarMagicNumber, tarMagicNumberOffset);
+
+        assertFiltering(filter, tarFileA, true);
+        assertFiltering(filter, randomFileB, false);
+        assertFiltering(filter, dir, false);
+
+        filter = FileFilterUtils.magicNumberFileFilter(tarMagicNumber, tarMagicNumberOffset);
+
+        assertFiltering(filter, tarFileA, true);
+        assertFiltering(filter, randomFileB, false);
+        assertFiltering(filter, dir, false);
+    }
+
+    // -----------------------------------------------------------------------
+
+    @Test
+    public void testMagicNumberFileFilterString() throws Exception {
+        final byte[] classFileMagicNumber = {(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE};
+        final String xmlFileContent = "<?xml version=\"1.0\" encoding=\"UTF-8\">\n" + "<element>text</element>";
+        final String xmlMagicNumber = "<?xml version=\"1.0\"";
+
+        final File classFileA = new File(temporaryFolder, "A.class");
+        final File xmlFileB = new File(temporaryFolder, "B.xml");
+        final File dir = new File(temporaryFolder, "D");
+        dir.mkdirs();
+
+        try (OutputStream classFileAStream = FileUtils.openOutputStream(classFileA)) {
+            IOUtils.write(classFileMagicNumber, classFileAStream);
+            TestUtils.generateTestData(classFileAStream, 32);
+        }
+
+        FileUtils.write(xmlFileB, xmlFileContent, StandardCharsets.UTF_8);
+
+        IOFileFilter filter = new MagicNumberFileFilter(xmlMagicNumber);
+
+        assertFiltering(filter, classFileA, false);
+        assertFiltering(filter, xmlFileB, true);
+        assertFiltering(filter, dir, false);
+
+        filter = FileFilterUtils.magicNumberFileFilter(xmlMagicNumber);
+
+        assertFiltering(filter, classFileA, false);
+        assertFiltering(filter, xmlFileB, true);
+        assertFiltering(filter, dir, false);
+    }
+
+    @Test
+    public void testMagicNumberFileFilterStringOffset() throws Exception {
+        final String tarMagicNumber = "ustar";
+        final long tarMagicNumberOffset = 257;
+
+        final File tarFileA = new File(temporaryFolder, "A.tar");
+        final File randomFileB = new File(temporaryFolder, "B.txt");
+        final File dir = new File(temporaryFolder, "D");
+        dir.mkdirs();
+
+        try (OutputStream tarFileAStream = FileUtils.openOutputStream(tarFileA)) {
+            TestUtils.generateTestData(tarFileAStream, tarMagicNumberOffset);
+            IOUtils.write(tarMagicNumber, tarFileAStream, StandardCharsets.UTF_8);
+        }
+
+        if (!randomFileB.getParentFile().exists()) {
+            fail("Cannot create file " + randomFileB + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(randomFileB.toPath()))) {
+            TestUtils.generateTestData(output, 2 * tarMagicNumberOffset);
+        }
+
+        IOFileFilter filter = new MagicNumberFileFilter(tarMagicNumber, tarMagicNumberOffset);
+
+        assertFiltering(filter, tarFileA, true);
+        assertFiltering(filter, randomFileB, false);
+        assertFiltering(filter, dir, false);
+
+        filter = FileFilterUtils.magicNumberFileFilter(tarMagicNumber, tarMagicNumberOffset);
+
+        assertFiltering(filter, tarFileA, true);
+        assertFiltering(filter, randomFileB, false);
+        assertFiltering(filter, dir, false);
+    }
+
+    @Test
+    public void testMagicNumberFileFilterValidation() {
+        assertThrows(NullPointerException.class, () -> new MagicNumberFileFilter((String) null, 0));
+        assertThrows(IllegalArgumentException.class, () -> new MagicNumberFileFilter("0", -1));
+        assertThrows(IllegalArgumentException.class, () -> new MagicNumberFileFilter("", 0));
+        assertThrows(NullPointerException.class, () -> new MagicNumberFileFilter((byte[]) null, 0));
+        assertThrows(IllegalArgumentException.class, () -> new MagicNumberFileFilter(new byte[] {0}, -1));
+        assertThrows(IllegalArgumentException.class, () -> new MagicNumberFileFilter(new byte[] {}, 0));
+    }
+
+    @Test
+    public void testMakeCVSAware() throws Exception {
+        final IOFileFilter filter1 = FileFilterUtils.makeCVSAware(null);
+        final IOFileFilter filter2 = FileFilterUtils.makeCVSAware(FileFilterUtils.nameFileFilter("test-file1.txt"));
+
+        File file = new File(temporaryFolder, "CVS");
+        file.mkdirs();
+        assertFiltering(filter1, file, false);
+        assertFiltering(filter2, file, false);
+        FileUtils.deleteDirectory(file);
+
+        file = new File(temporaryFolder, "test-file1.txt");
+        if (!file.getParentFile().exists()) {
+            fail("Cannot create file " + file + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output2 = new BufferedOutputStream(Files.newOutputStream(file.toPath()))) {
+            TestUtils.generateTestData(output2, 0);
+        }
+        assertFiltering(filter1, file, true);
+        assertFiltering(filter2, file, true);
+
+        file = new File(temporaryFolder, "test-file2.log");
+        if (!file.getParentFile().exists()) {
+            fail("Cannot create file " + file + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output1 = new BufferedOutputStream(Files.newOutputStream(file.toPath()))) {
+            TestUtils.generateTestData(output1, 0);
+        }
+        assertFiltering(filter1, file, true);
+        assertFiltering(filter2, file, false);
+
+        file = new File(temporaryFolder, "CVS");
+        if (!file.getParentFile().exists()) {
+            fail("Cannot create file " + file + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(file.toPath()))) {
+            TestUtils.generateTestData(output, 0);
+        }
+        assertFiltering(filter1, file, true);
+        assertFiltering(filter2, file, false);
+    }
+
+    @Test
+    public void testMakeDirectoryOnly() throws Exception {
+        assertSame(DirectoryFileFilter.DIRECTORY, FileFilterUtils.makeDirectoryOnly(null));
+
+        final IOFileFilter filter = FileFilterUtils.makeDirectoryOnly(FileFilterUtils.nameFileFilter("B"));
+
+        final File fileA = new File(temporaryFolder, "A");
+        final File fileB = new File(temporaryFolder, "B");
+
+        fileA.mkdirs();
+        fileB.mkdirs();
+
+        assertFiltering(filter, fileA, false);
+        assertFiltering(filter, fileB, true);
+
+        FileUtils.deleteDirectory(fileA);
+        FileUtils.deleteDirectory(fileB);
+
+        if (!fileA.getParentFile().exists()) {
+            fail("Cannot create file " + fileA + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output1 = new BufferedOutputStream(Files.newOutputStream(fileA.toPath()))) {
+            TestUtils.generateTestData(output1, 32);
+        }
+        if (!fileB.getParentFile().exists()) {
+            fail("Cannot create file " + fileB + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(fileB.toPath()))) {
+            TestUtils.generateTestData(output, 32);
+        }
+
+        assertFiltering(filter, fileA, false);
+        assertFiltering(filter, fileB, false);
+
+        fileA.delete();
+        fileB.delete();
+    }
+
+    // -----------------------------------------------------------------------
+    @Test
+    public void testMakeFileOnly() throws Exception {
+        assertSame(FileFileFilter.INSTANCE, FileFilterUtils.makeFileOnly(null));
+
+        final IOFileFilter filter = FileFilterUtils.makeFileOnly(FileFilterUtils.nameFileFilter("B"));
+
+        final File fileA = new File(temporaryFolder, "A");
+        final File fileB = new File(temporaryFolder, "B");
+
+        fileA.mkdirs();
+        fileB.mkdirs();
+
+        assertFiltering(filter, fileA, false);
+        assertFiltering(filter, fileB, false);
+
+        FileUtils.deleteDirectory(fileA);
+        FileUtils.deleteDirectory(fileB);
+
+        if (!fileA.getParentFile().exists()) {
+            fail("Cannot create file " + fileA + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output1 = new BufferedOutputStream(Files.newOutputStream(fileA.toPath()))) {
+            TestUtils.generateTestData(output1, 32);
+        }
+        if (!fileB.getParentFile().exists()) {
+            fail("Cannot create file " + fileB + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(fileB.toPath()))) {
+            TestUtils.generateTestData(output, 32);
+        }
+
+        assertFiltering(filter, fileA, false);
+        assertFiltering(filter, fileB, true);
+
+        fileA.delete();
+        fileB.delete();
+    }
+
+    @Test
+    public void testMakeSVNAware() throws Exception {
+        final IOFileFilter filter1 = FileFilterUtils.makeSVNAware(null);
+        final IOFileFilter filter2 = FileFilterUtils.makeSVNAware(FileFilterUtils.nameFileFilter("test-file1.txt"));
+
+        File file = new File(temporaryFolder, SVN_DIR_NAME);
+        file.mkdirs();
+        assertFiltering(filter1, file, false);
+        assertFiltering(filter2, file, false);
+        FileUtils.deleteDirectory(file);
+
+        file = new File(temporaryFolder, "test-file1.txt");
+        if (!file.getParentFile().exists()) {
+            fail("Cannot create file " + file + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output2 = new BufferedOutputStream(Files.newOutputStream(file.toPath()))) {
+            TestUtils.generateTestData(output2, 0);
+        }
+        assertFiltering(filter1, file, true);
+        assertFiltering(filter2, file, true);
+
+        file = new File(temporaryFolder, "test-file2.log");
+        if (!file.getParentFile().exists()) {
+            fail("Cannot create file " + file + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output1 = new BufferedOutputStream(Files.newOutputStream(file.toPath()))) {
+            TestUtils.generateTestData(output1, 0);
+        }
+        assertFiltering(filter1, file, true);
+        assertFiltering(filter2, file, false);
+
+        file = new File(temporaryFolder, SVN_DIR_NAME);
+        if (!file.getParentFile().exists()) {
+            fail("Cannot create file " + file + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(file.toPath()))) {
+            TestUtils.generateTestData(output, 0);
+        }
+        assertFiltering(filter1, file, true);
+        assertFiltering(filter2, file, false);
+    }
+
+    @Test
+    public void testNameFilter() {
+        assertFooBarFileFiltering(new NameFileFilter("foo", "bar"));
+    }
+
+    @Test
+    public void testNameFilterNullArgument() {
+        final String test = null;
+        final String failMessage = "constructing a NameFileFilter with a null String argument should fail.";
+        assertThrows(NullPointerException.class, () -> new NameFileFilter(test), failMessage);
+        assertThrows(NullPointerException.class, () -> FileFilterUtils.nameFileFilter(test, IOCase.INSENSITIVE), failMessage);
+    }
+
+    @Test
+    public void testNameFilterNullArrayArgument() {
+        assertThrows(NullPointerException.class, () -> new NameFileFilter((String[]) null));
+    }
+
+    @Test
+    public void testNameFilterNullListArgument() {
+        final List<String> test = null;
+        assertThrows(NullPointerException.class, () -> new NameFileFilter(test));
+    }
+
+    @Test
+    public void testNegate() {
+        final IOFileFilter filter = FileFilterUtils.notFileFilter(FileFilterUtils.trueFileFilter());
+        assertFiltering(filter, new File("foo.test"), false);
+        assertFiltering(filter, new File("foo"), false);
+        assertFiltering(filter.negate(), new File("foo"), true);
+        assertFiltering(filter, (File) null, false);
+        assertThrows(NullPointerException.class, () -> new NotFileFilter(null));
+    }
+
+    @Test
+    public void testNullFilters() {
+        assertThrows(NullPointerException.class, () -> FileFilterUtils.toList((IOFileFilter) null));
+        assertThrows(NullPointerException.class, () -> FileFilterUtils.toList(new IOFileFilter[] {null}));
+    }
+
+    @Test
+    public void testOr() {
+        final IOFileFilter trueFilter = TrueFileFilter.INSTANCE;
+        final IOFileFilter falseFilter = FalseFileFilter.INSTANCE;
+        final File testFile = new File("foo.test");
+        final Path testPath = testFile.toPath();
+        assertFiltering(new OrFileFilter(trueFilter, trueFilter), testFile, true);
+        assertFiltering(new OrFileFilter(trueFilter, falseFilter), testFile, true);
+        assertFiltering(new OrFileFilter(falseFilter, trueFilter), testFile, true);
+        assertFiltering(new OrFileFilter(falseFilter, falseFilter), testFile, false);
+        assertFiltering(new OrFileFilter(), testFile, false);
+        //
+        assertFiltering(new OrFileFilter(trueFilter, trueFilter), testPath, true);
+        assertFiltering(new OrFileFilter(trueFilter, falseFilter), testPath, true);
+        assertFiltering(new OrFileFilter(falseFilter, trueFilter), testPath, true);
+        assertFiltering(new OrFileFilter(falseFilter, falseFilter), testPath, false);
+        assertFiltering(new OrFileFilter(), testPath, false);
+        //
+        assertFiltering(falseFilter.or(trueFilter), testPath, true);
+
+        final List<IOFileFilter> filters = new ArrayList<>();
+        filters.add(trueFilter);
+        filters.add(falseFilter);
+
+        final OrFileFilter orFilter = new OrFileFilter(filters);
+
+        assertFiltering(orFilter, testFile, true);
+        assertFiltering(orFilter, testPath, true);
+        assertEquals(orFilter.getFileFilters(), filters);
+        orFilter.removeFileFilter(trueFilter);
+        assertFiltering(orFilter, testFile, false);
+        assertFiltering(orFilter, testPath, false);
+        orFilter.setFileFilters(filters);
+        assertFiltering(orFilter, testFile, true);
+        assertFiltering(orFilter, testPath, true);
+
+        assertTrue(orFilter.accept(testFile.getParentFile(), testFile.getName()));
+        assertEquals(FileVisitResult.CONTINUE, orFilter.accept(testPath, null));
+        orFilter.removeFileFilter(trueFilter);
+        assertFalse(orFilter.accept(testFile.getParentFile(), testFile.getName()));
+        assertEquals(FileVisitResult.TERMINATE, orFilter.accept(testPath, null));
+
+        assertThrows(NullPointerException.class, () -> new OrFileFilter(falseFilter, null));
+    }
+
+    @Test
+    public void testPathEqualsFilter() {
+        assertFooBarFileFiltering(
+            new PathEqualsFileFilter(Paths.get("foo")).or(new PathEqualsFileFilter(Paths.get("bar"))));
+    }
+
+    @Test
+    public void testPrefix() {
+        IOFileFilter filter = new PrefixFileFilter("foo", "bar");
+        final File testFile = new File("test");
+        final Path testPath = testFile.toPath();
+        final File fredFile = new File("fred");
+        final Path fredPath = fredFile.toPath();
+
+        assertFiltering(filter, new File("foo.test"), true);
+        assertFiltering(filter, new File("FOO.test"), false); // case-sensitive
+        assertFiltering(filter, new File("foo"), true);
+        assertFiltering(filter, new File("bar"), true);
+        assertFiltering(filter, new File("food/"), true);
+        //
+        assertFiltering(filter, new File("foo.test").toPath(), true);
+        assertFiltering(filter, new File("FOO.test").toPath(), false); // case-sensitive
+        assertFiltering(filter, new File("foo").toPath(), true);
+        assertFiltering(filter, new File("bar").toPath(), true);
+        assertFiltering(filter, new File("food/").toPath(), true);
+
+        filter = FileFilterUtils.prefixFileFilter("bar");
+        assertFiltering(filter, new File("barred\\"), true);
+        assertFiltering(filter, new File("test"), false);
+        assertFiltering(filter, new File("fo_o.test"), false);
+        assertFiltering(filter, new File("abar.exe"), false);
+        //
+        assertFiltering(filter, new File("barred\\").toPath(), true);
+        assertFiltering(filter, new File("test").toPath(), false);
+        assertFiltering(filter, new File("fo_o.test").toPath(), false);
+        assertFiltering(filter, new File("abar.exe").toPath(), false);
+
+        filter = new PrefixFileFilter("tes");
+        assertFiltering(filter, new File("test"), true);
+        assertFiltering(filter, new File("fred"), false);
+        //
+        assertFiltering(filter, new File("test").toPath(), true);
+        assertFiltering(filter, new File("fred").toPath(), false);
+
+        assertTrue(filter.accept(testFile.getParentFile(), testFile.getName()));
+        assertFalse(filter.accept(fredFile.getParentFile(), fredFile.getName()));
+        //
+        assertEquals(FileVisitResult.CONTINUE, filter.accept(testPath, null));
+        assertEquals(FileVisitResult.TERMINATE, filter.accept(fredPath, null));
+
+        final List<String> prefixes = Arrays.asList("foo", "fre");
+        final IOFileFilter listFilter = new PrefixFileFilter(prefixes);
+
+        assertFalse(listFilter.accept(testFile.getParentFile(), testFile.getName()));
+        assertTrue(listFilter.accept(fredFile.getParentFile(), fredFile.getName()));
+        //
+        assertEquals(FileVisitResult.TERMINATE, listFilter.accept(testPath, null));
+        assertEquals(FileVisitResult.CONTINUE, listFilter.accept(fredPath, null));
+
+        assertThrows(NullPointerException.class, () -> new PrefixFileFilter((String) null));
+        assertThrows(NullPointerException.class, () -> new PrefixFileFilter((String[]) null));
+        assertThrows(NullPointerException.class, () -> new PrefixFileFilter((List<String>) null));
+    }
+
+    @Test
+    public void testPrefixCaseInsensitive() {
+
+        IOFileFilter filter = new PrefixFileFilter(new String[] {"foo", "bar"}, IOCase.INSENSITIVE);
+        assertFiltering(filter, new File("foo.test1"), true);
+        assertFiltering(filter, new File("bar.test1"), true);
+        assertFiltering(filter, new File("FOO.test1"), true); // case-insensitive
+        assertFiltering(filter, new File("BAR.test1"), true); // case-insensitive
+
+        filter = new PrefixFileFilter("bar", IOCase.INSENSITIVE);
+        assertFiltering(filter, new File("foo.test2"), false);
+        assertFiltering(filter, new File("bar.test2"), true);
+        assertFiltering(filter, new File("FOO.test2"), false); // case-insensitive
+        assertFiltering(filter, new File("BAR.test2"), true); // case-insensitive
+
+        final List<String> prefixes = Arrays.asList("foo", "bar");
+        filter = new PrefixFileFilter(prefixes, IOCase.INSENSITIVE);
+        assertFiltering(filter, new File("foo.test3"), true);
+        assertFiltering(filter, new File("bar.test3"), true);
+        assertFiltering(filter, new File("FOO.test3"), true); // case-insensitive
+        assertFiltering(filter, new File("BAR.test3"), true); // case-insensitive
+
+        assertThrows(NullPointerException.class, () -> new PrefixFileFilter((String) null, IOCase.INSENSITIVE));
+        assertThrows(NullPointerException.class, () -> new PrefixFileFilter((String[]) null, IOCase.INSENSITIVE));
+        assertThrows(NullPointerException.class, () -> new PrefixFileFilter((List<String>) null, IOCase.INSENSITIVE));
+        // FileFilterUtils.prefixFileFilter(String, IOCase) tests
+        filter = FileFilterUtils.prefixFileFilter("bar", IOCase.INSENSITIVE);
+        assertFiltering(filter, new File("foo.test2"), false);
+        assertFiltering(filter, new File("bar.test2"), true);
+        assertFiltering(filter, new File("FOO.test2"), false); // case-insensitive
+        assertFiltering(filter, new File("BAR.test2"), true); // case-insensitive
+
+        assertThrows(NullPointerException.class, () -> FileFilterUtils.prefixFileFilter(null, IOCase.INSENSITIVE));
+    }
+
+    @Test
+    public void testSizeFilterOnFiles() throws Exception {
+        final File smallFile = new File(temporaryFolder, "small.txt");
+        if (!smallFile.getParentFile().exists()) {
+            fail("Cannot create file " + smallFile + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output1 = new BufferedOutputStream(Files.newOutputStream(smallFile.toPath()))) {
+            TestUtils.generateTestData(output1, 32);
+        }
+        final File largeFile = new File(temporaryFolder, "large.txt");
+        if (!largeFile.getParentFile().exists()) {
+            fail("Cannot create file " + largeFile + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(largeFile.toPath()))) {
+            TestUtils.generateTestData(output, 128);
+        }
+        final IOFileFilter filter1 = FileFilterUtils.sizeFileFilter(64);
+        final IOFileFilter filter2 = FileFilterUtils.sizeFileFilter(64, true);
+        final IOFileFilter filter3 = FileFilterUtils.sizeFileFilter(64, false);
+
+        assertFiltering(filter1, smallFile, false);
+        assertFiltering(filter2, smallFile, false);
+        assertFiltering(filter3, smallFile, true);
+        assertFiltering(filter1, largeFile, true);
+        assertFiltering(filter2, largeFile, true);
+        assertFiltering(filter3, largeFile, false);
+
+        // size range tests
+        final IOFileFilter filter4 = FileFilterUtils.sizeRangeFileFilter(33, 127);
+        final IOFileFilter filter5 = FileFilterUtils.sizeRangeFileFilter(32, 127);
+        final IOFileFilter filter6 = FileFilterUtils.sizeRangeFileFilter(33, 128);
+        final IOFileFilter filter7 = FileFilterUtils.sizeRangeFileFilter(31, 129);
+        final IOFileFilter filter8 = FileFilterUtils.sizeRangeFileFilter(128, 128);
+
+        assertFiltering(filter4, smallFile, false);
+        assertFiltering(filter4, largeFile, false);
+        assertFiltering(filter5, smallFile, true);
+        assertFiltering(filter5, largeFile, false);
+        assertFiltering(filter6, smallFile, false);
+        assertFiltering(filter6, largeFile, true);
+        assertFiltering(filter7, smallFile, true);
+        assertFiltering(filter7, largeFile, true);
+        assertFiltering(filter8, largeFile, true);
+
+        assertThrows(IllegalArgumentException.class, () -> FileFilterUtils.sizeFileFilter(-1));
+    }
+
+    @Test
+    public void testSizeFilterOnPaths() throws Exception {
+        final Path smallFile = Paths.get(temporaryFolder.toString(), "small.txt");
+        if (Files.notExists(smallFile.getParent())) {
+            fail("Cannot create file " + smallFile + " as the parent directory does not exist");
+        }
+        try (OutputStream output = Files.newOutputStream(smallFile)) {
+            TestUtils.generateTestData(output, 32);
+        }
+        final Path largeFile = Paths.get(temporaryFolder.toString(), "large.txt");
+        if (Files.notExists(largeFile.getParent())) {
+            fail("Cannot create file " + largeFile + " as the parent directory does not exist");
+        }
+        try (OutputStream output = Files.newOutputStream(largeFile)) {
+            TestUtils.generateTestData(output, 128);
+        }
+        final IOFileFilter filter1 = FileFilterUtils.sizeFileFilter(64);
+        final IOFileFilter filter2 = FileFilterUtils.sizeFileFilter(64, true);
+        final IOFileFilter filter3 = FileFilterUtils.sizeFileFilter(64, false);
+
+        assertFiltering(filter1, smallFile, false);
+        assertFiltering(filter2, smallFile, false);
+        assertFiltering(filter3, smallFile, true);
+        assertFiltering(filter1, largeFile, true);
+        assertFiltering(filter2, largeFile, true);
+        assertFiltering(filter3, largeFile, false);
+
+        // size range tests
+        final IOFileFilter filter4 = FileFilterUtils.sizeRangeFileFilter(33, 127);
+        final IOFileFilter filter5 = FileFilterUtils.sizeRangeFileFilter(32, 127);
+        final IOFileFilter filter6 = FileFilterUtils.sizeRangeFileFilter(33, 128);
+        final IOFileFilter filter7 = FileFilterUtils.sizeRangeFileFilter(31, 129);
+        final IOFileFilter filter8 = FileFilterUtils.sizeRangeFileFilter(128, 128);
+
+        assertFiltering(filter4, smallFile, false);
+        assertFiltering(filter4, largeFile, false);
+        assertFiltering(filter5, smallFile, true);
+        assertFiltering(filter5, largeFile, false);
+        assertFiltering(filter6, smallFile, false);
+        assertFiltering(filter6, largeFile, true);
+        assertFiltering(filter7, smallFile, true);
+        assertFiltering(filter7, largeFile, true);
+        assertFiltering(filter8, largeFile, true);
+
+        assertThrows(IllegalArgumentException.class, () -> FileFilterUtils.sizeFileFilter(-1));
+    }
+
+    @Test
+    public void testSuffix() {
+        IOFileFilter filter = new SuffixFileFilter("tes", "est");
+        final File testFile = new File("test");
+        final Path testPath = testFile.toPath();
+        final File fredFile = new File("fred");
+        final Path fredPath = fredFile.toPath();
+        //
+        assertFiltering(filter, new File("fred.tes"), true);
+        assertFiltering(filter, new File("fred.est"), true);
+        assertFiltering(filter, new File("fred.EST"), false); // case-sensitive
+        assertFiltering(filter, new File("fred.exe"), false);
+        //
+        assertFiltering(filter, new File("fred.tes").toPath(), true);
+        assertFiltering(filter, new File("fred.est").toPath(), true);
+        assertFiltering(filter, new File("fred.EST").toPath(), false); // case-sensitive
+        assertFiltering(filter, new File("fred.exe").toPath(), false);
+
+        filter = FileFilterUtils.or(FileFilterUtils.suffixFileFilter("tes"), FileFilterUtils.suffixFileFilter("est"));
+        assertFiltering(filter, new File("fred"), false);
+        assertFiltering(filter, new File(".tes"), true);
+        assertFiltering(filter, new File("fred.test"), true);
+        //
+        assertFiltering(filter, new File("fred").toPath(), false);
+        assertFiltering(filter, new File(".tes").toPath(), true);
+        assertFiltering(filter, new File("fred.test").toPath(), true);
+
+        filter = new SuffixFileFilter("est");
+        assertFiltering(filter, new File("test"), true);
+        assertFiltering(filter, new File("fred"), false);
+        //
+        assertFiltering(filter, new File("test").toPath(), true);
+        assertFiltering(filter, new File("fred").toPath(), false);
+
+        assertTrue(filter.accept(testFile.getParentFile(), testFile.getName()));
+        assertFalse(filter.accept(fredFile.getParentFile(), fredFile.getName()));
+        //
+        assertEquals(FileVisitResult.CONTINUE, filter.accept(testPath, null));
+        assertEquals(FileVisitResult.TERMINATE, filter.accept(fredPath, null));
+
+        final List<String> prefixes = Arrays.asList("ood", "red");
+        final IOFileFilter listFilter = new SuffixFileFilter(prefixes);
+
+        assertFalse(listFilter.accept(testFile.getParentFile(), testFile.getName()));
+        assertTrue(listFilter.accept(fredFile.getParentFile(), fredFile.getName()));
+        //
+        assertEquals(FileVisitResult.TERMINATE, listFilter.accept(testPath, null));
+        assertEquals(FileVisitResult.CONTINUE, listFilter.accept(fredPath, null));
+
+        assertThrows(NullPointerException.class, () -> new SuffixFileFilter((String) null));
+        assertThrows(NullPointerException.class, () -> new SuffixFileFilter((String[]) null));
+        assertThrows(NullPointerException.class, () -> new SuffixFileFilter((List<String>) null));
+    }
+
+    @Test
+    public void testSuffixCaseInsensitive() {
+
+        IOFileFilter filter = new SuffixFileFilter(new String[] {"tes", "est"}, IOCase.INSENSITIVE);
+        assertFiltering(filter, new File("foo.tes"), true);
+        assertFiltering(filter, new File("foo.est"), true);
+        assertFiltering(filter, new File("foo.EST"), true); // case-sensitive
+        assertFiltering(filter, new File("foo.TES"), true); // case-sensitive
+        assertFiltering(filter, new File("foo.exe"), false);
+
+        filter = new SuffixFileFilter("est", IOCase.INSENSITIVE);
+        assertFiltering(filter, new File("test"), true);
+        assertFiltering(filter, new File("TEST"), true);
+
+        final List<String> suffixes = Arrays.asList("tes", "est");
+        filter = new SuffixFileFilter(suffixes, IOCase.INSENSITIVE);
+        assertFiltering(filter, new File("bar.tes"), true);
+        assertFiltering(filter, new File("bar.est"), true);
+        assertFiltering(filter, new File("bar.EST"), true); // case-sensitive
+        assertFiltering(filter, new File("bar.TES"), true); // case-sensitive
+        assertFiltering(filter, new File("bar.exe"), false);
+
+        assertThrows(NullPointerException.class, () -> new SuffixFileFilter((String) null, IOCase.INSENSITIVE));
+        assertThrows(NullPointerException.class, () -> new SuffixFileFilter((String[]) null, IOCase.INSENSITIVE));
+        assertThrows(NullPointerException.class, () -> new SuffixFileFilter((List<String>) null, IOCase.INSENSITIVE));
+
+        // FileFilterUtils.suffixFileFilter(String, IOCase) tests
+        filter = FileFilterUtils.suffixFileFilter("est", IOCase.INSENSITIVE);
+        assertFiltering(filter, new File("test"), true);
+        assertFiltering(filter, new File("TEST"), true);
+
+        assertThrows(NullPointerException.class, () -> FileFilterUtils.suffixFileFilter(null, IOCase.INSENSITIVE));
+    }
+
+    @Test
+    public void testTrue() {
+        final IOFileFilter filter = FileFilterUtils.trueFileFilter();
+        assertFiltering(filter, new File("foo.test"), true);
+        assertFiltering(filter, new File("foo"), true);
+        assertFiltering(filter, (File) null, true);
+        //
+        assertFiltering(filter, new File("foo.test").toPath(), true);
+        assertFiltering(filter, new File("foo").toPath(), true);
+        assertFiltering(filter, (Path) null, true);
+        //
+        assertSame(TrueFileFilter.TRUE, TrueFileFilter.INSTANCE);
+        assertSame(FalseFileFilter.FALSE, TrueFileFilter.INSTANCE.negate());
+        assertSame(FalseFileFilter.INSTANCE, TrueFileFilter.INSTANCE.negate());
+        assertNotNull(TrueFileFilter.INSTANCE.toString());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/filefilter/IOFileFilterAbstractTest.java b/src/test/java/org/apache/commons/io/filefilter/IOFileFilterAbstractTest.java
new file mode 100644
index 0000000..5009ec5
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/filefilter/IOFileFilterAbstractTest.java
@@ -0,0 +1,140 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.File;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+public abstract class IOFileFilterAbstractTest {
+
+    class TesterFalseFileFilter extends FalseFileFilter {
+
+        private static final long serialVersionUID = -3603047664010401872L;
+        private boolean invoked;
+
+        @Override
+        public boolean accept(final File file) {
+            setInvoked(true);
+            return super.accept(file);
+        }
+
+        @Override
+        public boolean accept(final File file, final String str) {
+            setInvoked(true);
+            return super.accept(file, str);
+        }
+
+        public boolean isInvoked() {
+            return this.invoked;
+        }
+
+        public void reset() {
+            setInvoked(false);
+        }
+
+        public void setInvoked(final boolean invoked) {
+            this.invoked = invoked;
+        }
+    }
+
+    class TesterTrueFileFilter extends TrueFileFilter {
+
+        private static final long serialVersionUID = 1828930358172422914L;
+        private boolean invoked;
+
+        @Override
+        public boolean accept(final File file) {
+            setInvoked(true);
+            return super.accept(file);
+        }
+
+        @Override
+        public boolean accept(final File file, final String str) {
+            setInvoked(true);
+            return super.accept(file, str);
+        }
+
+        public boolean isInvoked() {
+            return this.invoked;
+        }
+
+        public void reset() {
+            setInvoked(false);
+        }
+
+        public void setInvoked(final boolean invoked) {
+            this.invoked = invoked;
+        }
+    }
+
+    public static void assertFalseFiltersInvoked(final int testNumber, final TesterFalseFileFilter[] filters, final boolean[] invoked) {
+        for (int i = 1; i < filters.length; i++) {
+            assertEquals(invoked[i - 1], filters[i].isInvoked(), "test " + testNumber + " filter " + i + " invoked");
+        }
+    }
+
+    public static void assertFileFiltering(final int testNumber, final IOFileFilter filter, final File file, final boolean expected) {
+        assertEquals(expected, filter.accept(file),
+                "test " + testNumber + " Filter(File) " + filter.getClass().getName() + " not " + expected + " for " + file);
+    }
+
+    public static void assertFilenameFiltering(final int testNumber, final IOFileFilter filter, final File file, final boolean expected) {
+        // Assumes file has a parent and is not passed as null
+        assertEquals(expected, filter.accept(file.getParentFile(), file.getName()),
+                "test " + testNumber + " Filter(File, String) " + filter.getClass().getName() + " not " + expected + " for " + file);
+    }
+
+    public static void assertFiltering(final int testNumber, final IOFileFilter filter, final File file, final boolean expected) {
+        // Note. This only tests the (File, String) version if the parent of
+        //       the File passed in is not null
+        assertEquals(expected, filter.accept(file),
+            "test " + testNumber + " Filter(File) " + filter.getClass().getName() + " not " + expected + " for " + file);
+        assertEquals(expected, filter.accept(file.toPath(), null),
+            "test " + testNumber + " Filter(File) " + filter.getClass().getName() + " not " + expected + " for " + file);
+
+        if (file != null && file.getParentFile() != null) {
+            assertEquals(expected, filter.accept(file.getParentFile(), file.getName()),
+                    "test " + testNumber + " Filter(File, String) " + filter.getClass().getName() + " not " + expected + " for " + file);
+        } else if (file == null) {
+            assertEquals(expected, filter.accept(file),
+                    "test " + testNumber + " Filter(File, String) " + filter.getClass().getName() + " not " + expected + " for null");
+        }
+    }
+
+    public static void assertTrueFiltersInvoked(final int testNumber, final TesterTrueFileFilter[] filters, final boolean[] invoked) {
+        for (int i = 1; i < filters.length; i++) {
+            assertEquals(invoked[i - 1], filters[i].isInvoked(), "test " + testNumber + " filter " + i + " invoked");
+        }
+    }
+
+    public static File determineWorkingDirectoryPath(final String key, final String defaultPath) {
+        // Look for a system property to specify the working directory
+        final String workingPathName = System.getProperty(key, defaultPath);
+        return new File(workingPathName);
+    }
+
+    public static void resetFalseFilters(final TesterFalseFileFilter[] filters) {
+        Stream.of(filters).filter(Objects::nonNull).forEach(TesterFalseFileFilter::reset);
+    }
+
+    public static void resetTrueFilters(final TesterTrueFileFilter[] filters) {
+        Stream.of(filters).filter(Objects::nonNull).forEach(TesterTrueFileFilter::reset);
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/filefilter/NameFileFilterTest.java b/src/test/java/org/apache/commons/io/filefilter/NameFileFilterTest.java
new file mode 100644
index 0000000..cca9c67
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/filefilter/NameFileFilterTest.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Collections;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.file.AccumulatorPathVisitor;
+import org.apache.commons.io.file.CounterAssertions;
+import org.apache.commons.io.file.Counters;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link NameFileFilter}.
+ */
+public class NameFileFilterTest {
+
+    /**
+     * Javadoc example.
+     *
+     * System.out calls are commented out here but not in the Javadoc.
+     */
+    @Test
+    public void testJavadocExampleUsingIo() {
+        final File dir = FileUtils.current();
+        final String[] files = dir.list(new NameFileFilter("NOTICE.txt"));
+        // End of Javadoc example
+        assertEquals(1, files.length);
+    }
+
+    /**
+     * Javadoc example.
+     *
+     * System.out calls are commented out here but not in the Javadoc.
+     */
+    @Test
+    public void testJavadocExampleUsingNio() throws IOException {
+        final Path dir = Paths.get("");
+        // We are interested in files older than one day
+        final AccumulatorPathVisitor visitor = AccumulatorPathVisitor.withLongCounters(new NameFileFilter("NOTICE.txt"),
+            TrueFileFilter.INSTANCE);
+        //
+        // Walk one dir
+        Files.walkFileTree(dir, Collections.emptySet(), 1, visitor);
+        // System.out.println(visitor.getPathCounters());
+        // System.out.println(visitor.getFileList());
+        // System.out.println(visitor.getDirList());
+        assertEquals(1, visitor.getPathCounters().getFileCounter().get());
+        assertEquals(1, visitor.getPathCounters().getDirectoryCounter().get());
+        assertTrue(visitor.getPathCounters().getByteCounter().get() > 0);
+        assertFalse(visitor.getDirList().isEmpty());
+        assertFalse(visitor.getFileList().isEmpty());
+        assertEquals(1, visitor.getFileList().size());
+        //
+        visitor.getPathCounters().reset();
+        //
+        // Walk dir tree
+        Files.walkFileTree(dir, visitor);
+        // System.out.println(visitor.getPathCounters());
+        // System.out.println(visitor.getDirList());
+        // System.out.println(visitor.getFileList());
+        //
+        // End of Javadoc example
+        assertTrue(visitor.getPathCounters().getFileCounter().get() > 0);
+        assertTrue(visitor.getPathCounters().getDirectoryCounter().get() > 0);
+        assertTrue(visitor.getPathCounters().getByteCounter().get() > 0);
+        // We counted and accumulated
+        assertFalse(visitor.getDirList().isEmpty());
+        assertFalse(visitor.getFileList().isEmpty());
+        //
+        assertNotEquals(Counters.noopPathCounters(), visitor.getPathCounters());
+        visitor.getPathCounters().reset();
+        CounterAssertions.assertZeroCounters(visitor.getPathCounters());
+    }
+
+    @Test
+    public void testNoCounting() throws IOException {
+        final Path dir = Paths.get("");
+        final AccumulatorPathVisitor visitor = new AccumulatorPathVisitor(Counters.noopPathCounters(),
+            new NameFileFilter("NOTICE.txt"), TrueFileFilter.INSTANCE);
+        Files.walkFileTree(dir, Collections.emptySet(), 1, visitor);
+        //
+        CounterAssertions.assertZeroCounters(visitor.getPathCounters());
+        // We did not count, but we still accumulated
+        assertFalse(visitor.getDirList().isEmpty());
+        assertFalse(visitor.getFileList().isEmpty());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/filefilter/OrFileFilterTest.java b/src/test/java/org/apache/commons/io/filefilter/OrFileFilterTest.java
new file mode 100644
index 0000000..932bca0
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/filefilter/OrFileFilterTest.java
@@ -0,0 +1,295 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+
+/**
+ * Tests {@link IOFileFilter}.
+ */
+public class OrFileFilterTest extends ConditionalFileFilterAbstractTest {
+
+  private static final String DEFAULT_WORKING_PATH = "./OrFileFilterTestCase/";
+  private static final String WORKING_PATH_NAME_PROPERTY_KEY = OrFileFilterTest.class.getName() + ".workingDirectory";
+
+  private List<List<IOFileFilter>> testFilters;
+  private List<boolean[]> testTrueResults;
+  private List<boolean[]> testFalseResults;
+  private List<Boolean> testFileResults;
+  private List<Boolean> testFilenameResults;
+
+  @Override
+  protected IOFileFilter buildFilterUsingAdd(final List<IOFileFilter> filters) {
+    final OrFileFilter filter = new OrFileFilter();
+    filters.forEach(filter::addFileFilter);
+    return filter;
+  }
+
+  @Override
+  protected IOFileFilter buildFilterUsingConstructor(final List<IOFileFilter> filters) {
+    return new OrFileFilter(filters);
+  }
+
+  @Override
+  protected ConditionalFileFilter getConditionalFileFilter() {
+    return new OrFileFilter();
+  }
+
+  @Override
+  protected String getDefaultWorkingPath() {
+    return DEFAULT_WORKING_PATH;
+  }
+
+  @Override
+  protected List<boolean[]> getFalseResults() {
+    return this.testFalseResults;
+  }
+
+  @Override
+  protected List<Boolean> getFilenameResults() {
+    return this.testFilenameResults;
+  }
+
+  @Override
+  protected List<Boolean> getFileResults() {
+    return this.testFileResults;
+  }
+
+  @Override
+  protected List<List<IOFileFilter>>  getTestFilters() {
+    return this.testFilters;
+  }
+
+  @Override
+  protected List<boolean[]> getTrueResults() {
+    return this.testTrueResults;
+  }
+
+  @Override
+  protected String getWorkingPathNamePropertyKey() {
+    return WORKING_PATH_NAME_PROPERTY_KEY;
+  }
+
+  @BeforeEach
+  public void setUpTestFilters() {
+    // filters
+    //tests
+    this.testFilters = new ArrayList<>();
+    this.testTrueResults = new ArrayList<>();
+    this.testFalseResults = new ArrayList<>();
+    this.testFileResults = new ArrayList<>();
+    this.testFilenameResults = new ArrayList<>();
+
+    // test 0 - add empty elements
+    {
+      testFilters.add(0, null);
+      testTrueResults.add(0, null);
+      testFalseResults.add(0, null);
+      testFileResults.add(0, null);
+      testFilenameResults.add(0, null);
+    }
+
+    // test 1 - Test conditional or with all filters returning true
+    {
+      // test 1 filters
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(trueFilters[1]);
+      filters.add(trueFilters[2]);
+      filters.add(trueFilters[3]);
+      // test 1 true results
+      final boolean[] trueResults = {true, false, false};
+      // test 1 false results
+      final boolean[] falseResults = {false, false, false};
+
+      testFilters.add(1, filters);
+      testTrueResults.add(1, trueResults);
+      testFalseResults.add(1, falseResults);
+      testFileResults.add(1, Boolean.TRUE);
+      testFilenameResults.add(1, Boolean.TRUE);
+    }
+
+    // test 2 - Test conditional or with first filter returning false
+    {
+      // test 2 filters
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(falseFilters[1]);
+      filters.add(trueFilters[1]);
+      filters.add(trueFilters[2]);
+      filters.add(trueFilters[3]);
+      filters.add(falseFilters[2]);
+      filters.add(falseFilters[3]);
+      // test 2 true results
+      final boolean[] trueResults = {true, false, false};
+      // test 2 false results
+      final boolean[] falseResults = {true, false, false};
+
+      testFilters.add(2, filters);
+      testTrueResults.add(2, trueResults);
+      testFalseResults.add(2, falseResults);
+      testFileResults.add(2, Boolean.TRUE);
+      testFilenameResults.add(2, Boolean.TRUE);
+    }
+
+    // test 3 - Test conditional or with second filter returning false
+    {
+      // test 3 filters
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(trueFilters[1]);
+      filters.add(falseFilters[1]);
+      filters.add(trueFilters[2]);
+      filters.add(trueFilters[3]);
+      filters.add(falseFilters[2]);
+      filters.add(falseFilters[3]);
+      // test 3 true results
+      final boolean[] trueResults = {true, false, false};
+      // test 3 false results
+      final boolean[] falseResults = {false, false, false};
+
+      testFilters.add(3, filters);
+      testTrueResults.add(3, trueResults);
+      testFalseResults.add(3, falseResults);
+      testFileResults.add(3, Boolean.TRUE);
+      testFilenameResults.add(3, Boolean.TRUE);
+    }
+
+    // test 4 - Test conditional or with third filter returning false
+    {
+      // test 4 filters
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(trueFilters[1]);
+      filters.add(trueFilters[2]);
+      filters.add(falseFilters[1]);
+      filters.add(trueFilters[3]);
+      filters.add(falseFilters[2]);
+      filters.add(falseFilters[3]);
+      // test 4 true results
+      final boolean[] trueResults = {true, false, false};
+      // test 4 false results
+      final boolean[] falseResults = {false, false, false};
+
+      testFilters.add(4, filters);
+      testTrueResults.add(4, trueResults);
+      testFalseResults.add(4, falseResults);
+      testFileResults.add(4, Boolean.TRUE);
+      testFilenameResults.add(4, Boolean.TRUE);
+    }
+
+    // test 5 - Test conditional or with first and third filters returning false
+    {
+      // test 5 filters
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(falseFilters[1]);
+      filters.add(trueFilters[1]);
+      filters.add(falseFilters[2]);
+      filters.add(falseFilters[3]);
+      filters.add(trueFilters[2]);
+      filters.add(trueFilters[3]);
+      // test 5 true results
+      final boolean[] trueResults = {true, false, false};
+      // test 5 false results
+      final boolean[] falseResults = {true, false, false};
+
+      testFilters.add(5, filters);
+      testTrueResults.add(5, trueResults);
+      testFalseResults.add(5, falseResults);
+      testFileResults.add(5, Boolean.TRUE);
+      testFilenameResults.add(5, Boolean.TRUE);
+    }
+
+    // test 6 - Test conditional or with second and third filters returning false
+    {
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(trueFilters[1]);
+      filters.add(falseFilters[1]);
+      filters.add(falseFilters[2]);
+      filters.add(trueFilters[2]);
+      filters.add(trueFilters[3]);
+      filters.add(falseFilters[3]);
+      // test 6 true results
+      final boolean[] trueResults = {true, false, false};
+      // test 6 false results
+      final boolean[] falseResults = {false, false, false};
+
+      testFilters.add(6, filters);
+      testTrueResults.add(6, trueResults);
+      testFalseResults.add(6, falseResults);
+      testFileResults.add(6, Boolean.TRUE);
+      testFilenameResults.add(6, Boolean.TRUE);
+    }
+
+    // test 7 - Test conditional or with first and second filters returning false
+    {
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(falseFilters[1]);
+      filters.add(falseFilters[2]);
+      filters.add(trueFilters[1]);
+      filters.add(falseFilters[3]);
+      filters.add(trueFilters[2]);
+      filters.add(trueFilters[3]);
+      // test 7 true results
+      final boolean[] trueResults = {true, false, false};
+      // test 7 false results
+      final boolean[] falseResults = {true, true, false};
+
+      testFilters.add(7, filters);
+      testTrueResults.add(7, trueResults);
+      testFalseResults.add(7, falseResults);
+      testFileResults.add(7, Boolean.TRUE);
+      testFilenameResults.add(7, Boolean.TRUE);
+    }
+
+    // test 8 - Test conditional or with fourth filter returning false
+    {
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(trueFilters[1]);
+      filters.add(trueFilters[2]);
+      filters.add(trueFilters[3]);
+      filters.add(falseFilters[1]);
+      // test 8 true results
+      final boolean[] trueResults = {true, false, false};
+      // test 8 false results
+      final boolean[] falseResults = {false, false, false};
+
+      testFilters.add(8, filters);
+      testTrueResults.add(8, trueResults);
+      testFalseResults.add(8, falseResults);
+      testFileResults.add(8, Boolean.TRUE);
+      testFilenameResults.add(8, Boolean.TRUE);
+    }
+
+    // test 9 - Test conditional or with all filters returning false
+    {
+      final List<IOFileFilter> filters = new ArrayList<>();
+      filters.add(falseFilters[1]);
+      filters.add(falseFilters[2]);
+      filters.add(falseFilters[3]);
+      // test 9 true results
+      final boolean[] trueResults = {false, false, false};
+      // test 9 false results
+      final boolean[] falseResults = {true, true, true};
+
+      testFilters.add(9, filters);
+      testTrueResults.add(9, trueResults);
+      testFalseResults.add(9, falseResults);
+      testFileResults.add(9, Boolean.FALSE);
+      testFilenameResults.add(9, Boolean.FALSE);
+    }
+  }
+}
diff --git a/src/test/java/org/apache/commons/io/filefilter/RegexFileFilterTest.java b/src/test/java/org/apache/commons/io/filefilter/RegexFileFilterTest.java
new file mode 100644
index 0000000..3318ad1
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/filefilter/RegexFileFilterTest.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.filefilter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.function.Function;
+import java.util.regex.Pattern;
+
+import org.apache.commons.io.IOCase;
+import org.apache.commons.io.output.ByteArrayOutputStream;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link RegexFileFilter}.
+ */
+public class RegexFileFilterTest {
+
+    public void assertFiltering(final IOFileFilter filter, final File file, final boolean expected) {
+        // Note. This only tests the (File, String) version if the parent of
+        //       the File passed in is not null
+        assertEquals(expected, filter.accept(file),
+                "Filter(File) " + filter.getClass().getName() + " not " + expected + " for " + file);
+
+        if (file != null && file.getParentFile() != null) {
+            assertEquals(expected, filter.accept(file.getParentFile(), file.getName()),
+                    "Filter(File, String) " + filter.getClass().getName() + " not " + expected + " for " + file);
+        } else if (file == null) {
+            assertEquals(expected, filter.accept(file),
+                    "Filter(File, String) " + filter.getClass().getName() + " not " + expected + " for null");
+        }
+        // Just don't blow up
+        assertNotNull(filter.toString());
+    }
+
+    public void assertFiltering(final IOFileFilter filter, final Path path, final boolean expected) {
+        // Note. This only tests the (Path, Path) version if the parent of
+        // the Path passed in is not null
+        final FileVisitResult expectedFileVisitResult = AbstractFileFilter.toDefaultFileVisitResult(expected);
+        assertEquals(expectedFileVisitResult, filter.accept(path, null),
+            "Filter(Path) " + filter.getClass().getName() + " not " + expectedFileVisitResult + " for " + path);
+
+        if (path != null && path.getParent() != null) {
+            assertEquals(expectedFileVisitResult, filter.accept(path, null),
+                "Filter(Path, Path) " + filter.getClass().getName() + " not " + expectedFileVisitResult + " for "
+                    + path);
+        } else if (path == null) {
+            assertEquals(expectedFileVisitResult, filter.accept(path, null),
+                "Filter(Path, Path) " + filter.getClass().getName() + " not " + expectedFileVisitResult + " for null");
+        }
+        // Just don't blow up
+        assertNotNull(filter.toString());
+    }
+
+    private RegexFileFilter assertSerializable(final RegexFileFilter serializable) throws IOException {
+        try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+            try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+                oos.writeObject(serializable);
+            }
+            baos.flush();
+            assertTrue(baos.toByteArray().length > 0);
+        }
+        return serializable;
+    }
+
+    @Test
+    public void testRegex() throws IOException {
+        RegexFileFilter filter = new RegexFileFilter("^.*[tT]est(-\\d+)?\\.java$");
+        assertSerializable(filter);
+        assertFiltering(filter, new File("Test.java"), true);
+        assertFiltering(filter, new File("test-10.java"), true);
+        assertFiltering(filter, new File("test-.java"), false);
+        //
+        assertFiltering(filter, new File("Test.java").toPath(), true);
+        assertFiltering(filter, new File("test-10.java").toPath(), true);
+        assertFiltering(filter, new File("test-.java").toPath(), false);
+
+        filter = new RegexFileFilter("^[Tt]est.java$");
+        assertSerializable(filter);
+        assertFiltering(filter, new File("Test.java"), true);
+        assertFiltering(filter, new File("test.java"), true);
+        assertFiltering(filter, new File("tEST.java"), false);
+        //
+        assertFiltering(filter, new File("Test.java").toPath(), true);
+        assertFiltering(filter, new File("test.java").toPath(), true);
+        assertFiltering(filter, new File("tEST.java").toPath(), false);
+
+        filter = new RegexFileFilter(Pattern.compile("^test.java$", Pattern.CASE_INSENSITIVE));
+        assertSerializable(filter);
+        assertFiltering(filter, new File("Test.java"), true);
+        assertFiltering(filter, new File("test.java"), true);
+        assertFiltering(filter, new File("tEST.java"), true);
+        //
+        assertFiltering(filter, new File("Test.java").toPath(), true);
+        assertFiltering(filter, new File("test.java").toPath(), true);
+        assertFiltering(filter, new File("tEST.java").toPath(), true);
+
+        filter = new RegexFileFilter("^test.java$", Pattern.CASE_INSENSITIVE);
+        assertSerializable(filter);
+        assertFiltering(filter, new File("Test.java"), true);
+        assertFiltering(filter, new File("test.java"), true);
+        assertFiltering(filter, new File("tEST.java"), true);
+        //
+        assertFiltering(filter, new File("Test.java").toPath(), true);
+        assertFiltering(filter, new File("test.java").toPath(), true);
+        assertFiltering(filter, new File("tEST.java").toPath(), true);
+
+        filter = new RegexFileFilter("^test.java$", IOCase.INSENSITIVE);
+        assertSerializable(filter);
+        assertFiltering(filter, new File("Test.java"), true);
+        assertFiltering(filter, new File("test.java"), true);
+        assertFiltering(filter, new File("tEST.java"), true);
+        //
+        assertFiltering(filter, new File("Test.java").toPath(), true);
+        assertFiltering(filter, new File("test.java").toPath(), true);
+        assertFiltering(filter, new File("tEST.java").toPath(), true);
+    }
+
+    @Test
+    public void testRegexEdgeCases() {
+        assertThrows(NullPointerException.class, () -> assertSerializable(new RegexFileFilter((String) null)));
+        assertThrows(NullPointerException.class, () -> assertSerializable(new RegexFileFilter(null, Pattern.CASE_INSENSITIVE)));
+        assertThrows(NullPointerException.class, () -> assertSerializable(new RegexFileFilter(null, IOCase.INSENSITIVE)));
+        assertThrows(NullPointerException.class, () -> assertSerializable(new RegexFileFilter((java.util.regex.Pattern) null)));
+    }
+
+    /**
+     * Tests https://issues.apache.org/jira/browse/IO-733.
+     * @throws IOException
+     */
+    @SuppressWarnings("unchecked")
+    @Test
+    public void testRegexFileNameOnly() throws IOException {
+        final Path path = Paths.get("folder", "Foo.java");
+        final String patternStr = "Foo.*";
+        assertFiltering(assertSerializable(new RegexFileFilter(patternStr)), path, true);
+        assertFiltering(assertSerializable(new RegexFileFilter(Pattern.compile(patternStr), (Function<Path, String> & Serializable) Path::toString)), path,
+            false);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/filefilter/SymbolicLinkFileFilterTest.java b/src/test/java/org/apache/commons/io/filefilter/SymbolicLinkFileFilterTest.java
new file mode 100644
index 0000000..786a952
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/filefilter/SymbolicLinkFileFilterTest.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.filefilter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.nio.file.FileVisitResult;
+
+import org.apache.commons.io.file.PathUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link SymbolicLinkFileFilter}.
+ */
+public class SymbolicLinkFileFilterTest {
+
+    @Test
+    public void testSymbolicLinkFileFilter() {
+        assertEquals(FileVisitResult.TERMINATE, SymbolicLinkFileFilter.INSTANCE.accept(PathUtils.current(), null));
+
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/filefilter/WildcardFileFilterTest.java b/src/test/java/org/apache/commons/io/filefilter/WildcardFileFilterTest.java
new file mode 100644
index 0000000..f3c8ed6
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/filefilter/WildcardFileFilterTest.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.filefilter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.io.IOCase;
+import org.junit.jupiter.api.Test;
+
+public class WildcardFileFilterTest extends AbstractFilterTest {
+
+    @Test
+    public void testWildcard() {
+        IOFileFilter filter = new WildcardFileFilter("*.txt");
+        assertFiltering(filter, new File("log.txt"), true);
+        assertFiltering(filter, new File("log.TXT"), false);
+        //
+        assertFiltering(filter, new File("log.txt").toPath(), true);
+        assertFiltering(filter, new File("log.TXT").toPath(), false);
+
+        filter = new WildcardFileFilter("*.txt", IOCase.SENSITIVE);
+        assertFiltering(filter, new File("log.txt"), true);
+        assertFiltering(filter, new File("log.TXT"), false);
+        //
+        assertFiltering(filter, new File("log.txt").toPath(), true);
+        assertFiltering(filter, new File("log.TXT").toPath(), false);
+
+        filter = new WildcardFileFilter("*.txt", IOCase.INSENSITIVE);
+        assertFiltering(filter, new File("log.txt"), true);
+        assertFiltering(filter, new File("log.TXT"), true);
+        //
+        assertFiltering(filter, new File("log.txt").toPath(), true);
+        assertFiltering(filter, new File("log.TXT").toPath(), true);
+
+        filter = new WildcardFileFilter("*.txt", IOCase.SYSTEM);
+        assertFiltering(filter, new File("log.txt"), true);
+        assertFiltering(filter, new File("log.TXT"), WINDOWS);
+        //
+        assertFiltering(filter, new File("log.txt").toPath(), true);
+        assertFiltering(filter, new File("log.TXT").toPath(), WINDOWS);
+
+        filter = new WildcardFileFilter("*.txt", null);
+        assertFiltering(filter, new File("log.txt"), true);
+        assertFiltering(filter, new File("log.TXT"), false);
+        //
+        assertFiltering(filter, new File("log.txt").toPath(), true);
+        assertFiltering(filter, new File("log.TXT").toPath(), false);
+
+        filter = new WildcardFileFilter("*.java", "*.class");
+        assertFiltering(filter, new File("Test.java"), true);
+        assertFiltering(filter, new File("Test.class"), true);
+        assertFiltering(filter, new File("Test.jsp"), false);
+        //
+        assertFiltering(filter, new File("Test.java").toPath(), true);
+        assertFiltering(filter, new File("Test.class").toPath(), true);
+        assertFiltering(filter, new File("Test.jsp").toPath(), false);
+
+        filter = new WildcardFileFilter(new String[] {"*.java", "*.class"}, IOCase.SENSITIVE);
+        assertFiltering(filter, new File("Test.java"), true);
+        assertFiltering(filter, new File("Test.JAVA"), false);
+        //
+        assertFiltering(filter, new File("Test.java").toPath(), true);
+        assertFiltering(filter, new File("Test.JAVA").toPath(), false);
+
+        filter = new WildcardFileFilter(new String[] {"*.java", "*.class"}, IOCase.INSENSITIVE);
+        assertFiltering(filter, new File("Test.java"), true);
+        assertFiltering(filter, new File("Test.JAVA"), true);
+        //
+        assertFiltering(filter, new File("Test.java").toPath(), true);
+        assertFiltering(filter, new File("Test.JAVA").toPath(), true);
+
+        filter = new WildcardFileFilter(new String[] {"*.java", "*.class"}, IOCase.SYSTEM);
+        assertFiltering(filter, new File("Test.java"), true);
+        assertFiltering(filter, new File("Test.JAVA"), WINDOWS);
+        //
+        assertFiltering(filter, new File("Test.java").toPath(), true);
+        assertFiltering(filter, new File("Test.JAVA").toPath(), WINDOWS);
+
+        filter = new WildcardFileFilter(new String[] {"*.java", "*.class"}, null);
+        assertFiltering(filter, new File("Test.java"), true);
+        assertFiltering(filter, new File("Test.JAVA"), false);
+        //
+        assertFiltering(filter, new File("Test.java").toPath(), true);
+        assertFiltering(filter, new File("Test.JAVA").toPath(), false);
+
+        final List<String> patternList = Arrays.asList("*.txt", "*.xml", "*.gif");
+        final IOFileFilter listFilter = new WildcardFileFilter(patternList);
+        assertFiltering(listFilter, new File("Test.txt"), true);
+        assertFiltering(listFilter, new File("Test.xml"), true);
+        assertFiltering(listFilter, new File("Test.gif"), true);
+        assertFiltering(listFilter, new File("Test.bmp"), false);
+        //
+        assertFiltering(listFilter, new File("Test.txt").toPath(), true);
+        assertFiltering(listFilter, new File("Test.xml").toPath(), true);
+        assertFiltering(listFilter, new File("Test.gif").toPath(), true);
+        assertFiltering(listFilter, new File("Test.bmp").toPath(), false);
+
+        final File txtFile = new File("test.txt");
+        final Path txtPath = txtFile.toPath();
+        final File bmpFile = new File("test.bmp");
+        final Path bmpPath = bmpFile.toPath();
+        final File dirFile = new File("src/java");
+        final Path dirPath = dirFile.toPath();
+        assertTrue(listFilter.accept(txtFile));
+        assertFalse(listFilter.accept(bmpFile));
+        assertFalse(listFilter.accept(dirFile));
+        //
+        assertEquals(FileVisitResult.CONTINUE, listFilter.accept(txtFile.toPath(), null));
+        assertEquals(FileVisitResult.TERMINATE, listFilter.accept(bmpFile.toPath(), null));
+        assertEquals(FileVisitResult.TERMINATE, listFilter.accept(dirFile.toPath(), null));
+
+        assertTrue(listFilter.accept(txtFile.getParentFile(), txtFile.getName()));
+        assertFalse(listFilter.accept(bmpFile.getParentFile(), bmpFile.getName()));
+        assertFalse(listFilter.accept(dirFile.getParentFile(), dirFile.getName()));
+        //
+        assertEquals(FileVisitResult.CONTINUE, listFilter.accept(txtPath, null));
+        assertEquals(FileVisitResult.TERMINATE, listFilter.accept(bmpPath, null));
+        assertEquals(FileVisitResult.TERMINATE, listFilter.accept(dirPath, null));
+
+        assertThrows(NullPointerException.class, () -> new WildcardFileFilter((String) null));
+        assertThrows(NullPointerException.class, () -> new WildcardFileFilter((String[]) null));
+        assertThrows(NullPointerException.class, () -> new WildcardFileFilter((List<String>) null));
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/function/EraseTest.java b/src/test/java/org/apache/commons/io/function/EraseTest.java
new file mode 100644
index 0000000..cabaebb
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/EraseTest.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@code Erase}.
+ */
+class EraseTest {
+
+    private final AtomicInteger intRef = new AtomicInteger();
+    private final AtomicBoolean boolRef = new AtomicBoolean();
+
+    @Test
+    void testAcceptIOBiConsumerOfTUTU() {
+        Erase.accept((e, f) -> boolRef.set(intRef.compareAndSet(0, e)), 1, true);
+        assertEquals(1, intRef.get());
+        assertTrue(boolRef.get());
+        assertThrows(IOException.class, () -> Erase.accept(TestUtils.throwingIOBiConsumer(), null, 1));
+    }
+
+    @Test
+    void testAcceptIOConsumerOfTT() {
+        Erase.accept(e -> intRef.compareAndSet(0, e), 1);
+        assertEquals(1, intRef.get());
+        assertThrows(IOException.class, () -> Erase.accept(TestUtils.throwingIOConsumer(), 1));
+    }
+
+    @Test
+    void testApplyIOBiFunctionOfQsuperTQsuperUQextendsRTU() {
+        assertTrue(Erase.<Integer, Boolean, Boolean>apply((i, b) -> boolRef.compareAndSet(false, intRef.compareAndSet(0, i.intValue())), 1, Boolean.TRUE));
+        assertThrows(IOException.class, () -> Erase.apply(TestUtils.throwingIOBiFunction(), 1, Boolean.TRUE));
+    }
+
+    @Test
+    void testApplyIOFunctionOfQsuperTQextendsRT() {
+        assertTrue(Erase.<Integer, Boolean>apply(e -> intRef.compareAndSet(0, e), 1));
+        assertThrows(IOException.class, () -> Erase.apply(TestUtils.throwingIOFunction(), 1));
+    }
+
+    @Test
+    void testCompare() {
+        assertEquals(0, Erase.compare(String::compareTo, "A", "A"));
+        assertEquals(-1, Erase.compare(String::compareTo, "A", "B"));
+        assertEquals(1, Erase.compare(String::compareTo, "B", "A"));
+        assertThrows(IOException.class, () -> Erase.compare(TestUtils.throwingIOComparator(), null, null));
+    }
+
+    @Test
+    void testGet() {
+        assertEquals(0, Erase.get(() -> intRef.get()));
+        assertThrows(IOException.class, () -> Erase.get(TestUtils.throwingIOSupplier()));
+    }
+
+    @Test
+    void testRethrow() {
+        assertThrows(IOException.class, () -> Erase.rethrow(new IOException()));
+    }
+
+    @Test
+    void testRun() {
+        Erase.run(() -> intRef.set(1));
+        assertEquals(1, intRef.get());
+        assertThrows(IOException.class, () -> Erase.run(TestUtils.throwingIORunnable()));
+    }
+
+    @Test
+    void testTest() {
+        assertTrue(Erase.test(e -> intRef.compareAndSet(0, e), 1));
+        assertThrows(IOException.class, () -> Erase.test(TestUtils.throwingIOPredicate(), 1));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOBaseStreamTest.java b/src/test/java/org/apache/commons/io/function/IOBaseStreamTest.java
new file mode 100644
index 0000000..a77e55f
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOBaseStreamTest.java
@@ -0,0 +1,349 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.BaseStream;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOBaseStream}.
+ */
+public class IOBaseStreamTest {
+
+    /**
+     * Implements IOBaseStream with generics.
+     */
+    private static class IOBaseStreamFixture<T, S extends IOBaseStreamFixture<T, S, B>, B extends BaseStream<T, B>> implements IOBaseStream<T, S, B> {
+
+        private final B baseStream;
+
+        private IOBaseStreamFixture(final B baseStream) {
+            this.baseStream = baseStream;
+        }
+
+        @Override
+        public B unwrap() {
+            return baseStream;
+        }
+
+        @SuppressWarnings("unchecked") // We are this here
+        @Override
+        public S wrap(final B delegate) {
+            return delegate == baseStream ? (S) this : (S) new IOBaseStreamFixture<T, S, B>(delegate);
+        }
+
+    }
+
+    /**
+     * Implements IOBaseStream with a concrete type.
+     */
+    private static class IOBaseStreamPathFixture<B extends BaseStream<Path, B>> extends IOBaseStreamFixture<Path, IOBaseStreamPathFixture<B>, B> {
+
+        private IOBaseStreamPathFixture(final B baseStream) {
+            super(baseStream);
+        }
+
+        @Override
+        public IOBaseStreamPathFixture<B> wrap(final B delegate) {
+            return delegate == unwrap() ? this : new IOBaseStreamPathFixture<>(delegate);
+        }
+
+    }
+
+    private static class MyRuntimeException extends RuntimeException {
+
+        private static final long serialVersionUID = 1L;
+
+        public MyRuntimeException(final String message) {
+            super(message);
+        }
+
+    }
+
+    /** Sanity check */
+    private BaseStream<Path, ? extends BaseStream<Path, ?>> baseStream;
+
+    /** Generic version */
+    private IOBaseStreamFixture<Path, ? extends IOBaseStreamFixture<Path, ?, ?>, ?> ioBaseStream;
+
+    /** Concrete version */
+    private IOBaseStreamPathFixture<? extends BaseStream<Path, ?>> ioBaseStreamPath;
+
+    /** Adapter version */
+    private IOStream<Path> ioBaseStreamAdapter;
+
+    @BeforeEach
+    public void beforeEach() {
+        baseStream = createStreamOfPaths();
+        ioBaseStream = createIOBaseStream();
+        ioBaseStreamPath = createIOBaseStreamPath();
+        ioBaseStreamAdapter = createIOBaseStreamApapter();
+    }
+
+    private IOBaseStreamFixture<Path, ?, Stream<Path>> createIOBaseStream() {
+        return new IOBaseStreamFixture<>(createStreamOfPaths());
+    }
+
+    private IOStream<Path> createIOBaseStreamApapter() {
+        return IOStreamAdapter.adapt(createStreamOfPaths());
+    }
+
+    private IOBaseStreamPathFixture<Stream<Path>> createIOBaseStreamPath() {
+        return new IOBaseStreamPathFixture<>(createStreamOfPaths());
+    }
+
+    private Stream<Path> createStreamOfPaths() {
+        return Stream.of(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_B);
+    }
+
+    @Test
+    @AfterEach
+    public void testClose() {
+        baseStream.close();
+        ioBaseStream.close();
+        ioBaseStreamPath.close();
+        ioBaseStream.asBaseStream().close();
+        ioBaseStreamPath.asBaseStream().close();
+    }
+
+    @SuppressWarnings("resource") // @AfterEach
+    @Test
+    public void testIsParallel() {
+        assertFalse(baseStream.isParallel());
+        assertFalse(ioBaseStream.isParallel());
+        assertFalse(ioBaseStream.asBaseStream().isParallel());
+        assertFalse(ioBaseStreamPath.asBaseStream().isParallel());
+        assertFalse(ioBaseStreamPath.isParallel());
+    }
+
+    @SuppressWarnings("resource") // @AfterEach
+    @Test
+    public void testIteratorPathIO() throws IOException {
+        final AtomicReference<Path> ref = new AtomicReference<>();
+        ioBaseStream.iterator().forEachRemaining(e -> ref.set(e.toRealPath()));
+        assertEquals(TestConstants.ABS_PATH_B.toRealPath(), ref.get());
+        //
+        ioBaseStreamPath.asBaseStream().iterator().forEachRemaining(e -> ref.set(e.getFileName()));
+        assertEquals(TestConstants.ABS_PATH_B.getFileName(), ref.get());
+    }
+
+    @SuppressWarnings("resource") // @AfterEach
+    @Test
+    public void testIteratorSimple() throws IOException {
+        final AtomicInteger ref = new AtomicInteger();
+        baseStream.iterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(2, ref.get());
+        ioBaseStream.iterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(4, ref.get());
+        ioBaseStreamPath.asBaseStream().iterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(6, ref.get());
+    }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void testOnClose() {
+        // Stream
+        testOnClose(baseStream);
+        testOnClose(ioBaseStream.asBaseStream());
+        testOnClose(ioBaseStreamPath.asBaseStream());
+    }
+
+    @SuppressWarnings("resource")
+    private <T, S extends BaseStream<T, S>> void testOnClose(final BaseStream<T, S> stream) {
+        final AtomicReference<String> refA = new AtomicReference<>();
+        final AtomicReference<String> refB = new AtomicReference<>();
+        stream.onClose(() -> refA.set("A"));
+        stream.onClose(() -> {
+            throw new MyRuntimeException("B");
+        });
+        stream.onClose(() -> {
+            throw new MyRuntimeException("C");
+        });
+        stream.onClose(() -> refB.set("D"));
+        final MyRuntimeException e = assertThrows(MyRuntimeException.class, stream::close);
+        assertEquals("A", refA.get());
+        assertEquals("D", refB.get());
+        assertEquals("B", e.getMessage());
+        final Throwable[] suppressed = e.getSuppressed();
+        assertNotNull(suppressed);
+        assertEquals(1, suppressed.length);
+        assertEquals("C", suppressed[0].getMessage());
+    }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void testParallel() throws IOException {
+        final AtomicInteger ref = new AtomicInteger();
+        baseStream.parallel().iterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(2, ref.get());
+        ioBaseStream.parallel().iterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(4, ref.get());
+        final BaseStream<Path, ?> parallel = ioBaseStreamPath.asBaseStream().parallel();
+        parallel.iterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(6, ref.get());
+        assertTrue(parallel.isParallel());
+    }
+
+    @SuppressWarnings("resource") // @AfterEach
+    @Test
+    public void testParallelParallel() {
+        try (final IOBaseStream<?, ?, ?> stream = createIOBaseStream()) {
+            testParallelParallel(stream);
+        }
+        try (final IOBaseStream<?, ?, ?> stream = createIOBaseStreamPath()) {
+            testParallelParallel(stream);
+        }
+        try (final IOBaseStream<?, ?, ?> stream = createIOBaseStream()) {
+            testParallelParallel(stream);
+        }
+        try (final IOBaseStreamFixture<Path, ?, Stream<Path>> stream = createIOBaseStream()) {
+            testParallelParallel(stream.asBaseStream());
+        }
+    }
+
+    @SuppressWarnings("resource")
+    private void testParallelParallel(final BaseStream<?, ?> stream) {
+        final BaseStream<?, ?> seq = stream.sequential();
+        assertFalse(seq.isParallel());
+        final BaseStream<?, ?> p1 = seq.parallel();
+        assertTrue(p1.isParallel());
+        final BaseStream<?, ?> p2 = p1.parallel();
+        assertTrue(p1.isParallel());
+        assertSame(p1, p2);
+    }
+
+    @SuppressWarnings("resource")
+    private void testParallelParallel(final IOBaseStream<?, ?, ?> stream) {
+        final IOBaseStream<?, ?, ?> seq = stream.sequential();
+        assertFalse(seq.isParallel());
+        final IOBaseStream<?, ?, ?> p1 = seq.parallel();
+        assertTrue(p1.isParallel());
+        final IOBaseStream<?, ?, ?> p2 = p1.parallel();
+        assertTrue(p1.isParallel());
+        assertSame(p1, p2);
+    }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void testSequential() throws IOException {
+        final AtomicInteger ref = new AtomicInteger();
+        baseStream.sequential().iterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(2, ref.get());
+        ioBaseStream.sequential().iterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(4, ref.get());
+        ioBaseStreamPath.asBaseStream().sequential().iterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(6, ref.get());
+    }
+
+    @SuppressWarnings("resource") // @AfterEach
+    @Test
+    public void testSequentialSequential() {
+        try (final IOBaseStream<?, ?, ?> stream = createIOBaseStream()) {
+            testSequentialSequential(stream);
+        }
+        try (final IOBaseStream<?, ?, ?> stream = createIOBaseStreamPath()) {
+            testSequentialSequential(stream);
+        }
+        try (final IOBaseStream<?, ?, ?> stream = createIOBaseStream()) {
+            testSequentialSequential(stream.asBaseStream());
+        }
+    }
+
+    @SuppressWarnings("resource")
+    private void testSequentialSequential(final BaseStream<?, ?> stream) {
+        final BaseStream<?, ?> p = stream.parallel();
+        assertTrue(p.isParallel());
+        final BaseStream<?, ?> seq1 = p.sequential();
+        assertFalse(seq1.isParallel());
+        final BaseStream<?, ?> seq2 = seq1.sequential();
+        assertFalse(seq1.isParallel());
+        assertSame(seq1, seq2);
+    }
+
+    @SuppressWarnings("resource")
+    private void testSequentialSequential(final IOBaseStream<?, ?, ?> stream) {
+        final IOBaseStream<?, ?, ?> p = stream.parallel();
+        assertTrue(p.isParallel());
+        final IOBaseStream<?, ?, ?> seq1 = p.sequential();
+        assertFalse(seq1.isParallel());
+        final IOBaseStream<?, ?, ?> seq2 = seq1.sequential();
+        assertFalse(seq1.isParallel());
+        assertSame(seq1, seq2);
+    }
+
+    @SuppressWarnings("resource") // @AfterEach
+    @Test
+    public void testSpliterator() {
+        final AtomicInteger ref = new AtomicInteger();
+        baseStream.spliterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(2, ref.get());
+        ioBaseStream.spliterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(4, ref.get());
+        ioBaseStreamPath.asBaseStream().spliterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(6, ref.get());
+    }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void testUnordered() throws IOException {
+        final AtomicInteger ref = new AtomicInteger();
+        baseStream.unordered().iterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(2, ref.get());
+        ioBaseStream.unordered().iterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(4, ref.get());
+        ioBaseStreamPath.asBaseStream().unordered().iterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(6, ref.get());
+    }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void testUnwrap() {
+        final AtomicInteger ref = new AtomicInteger();
+        baseStream.iterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(2, ref.get());
+        ioBaseStream.unwrap().iterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(4, ref.get());
+        ioBaseStreamPath.asBaseStream().iterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(6, ref.get());
+    }
+
+    @Test
+    public void testWrap() {
+        final Stream<Path> stream = createStreamOfPaths();
+        @SuppressWarnings("resource")
+        final IOStream<Path> wrap = ioBaseStreamAdapter.wrap(stream);
+        assertNotNull(wrap);
+        assertEquals(stream, wrap.unwrap());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOBiConsumerTest.java b/src/test/java/org/apache/commons/io/function/IOBiConsumerTest.java
new file mode 100644
index 0000000..20c8ef4
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOBiConsumerTest.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOBiConsumer}.
+ */
+public class IOBiConsumerTest {
+
+    @Test
+    public void testAccept() throws IOException {
+        final AtomicReference<String> ref = new AtomicReference<>();
+        final IOBiConsumer<String, Integer> biConsumer = (s, i) -> ref.set(s + i);
+        biConsumer.accept("A", 1);
+        assertEquals("A1", ref.get());
+    }
+
+    @Test
+    public void testAndThen() throws IOException {
+        final AtomicReference<String> ref = new AtomicReference<>();
+        final IOBiConsumer<String, Integer> biConsumer1 = (s, i) -> ref.set(s + i);
+        final IOBiConsumer<String, Integer> biConsumer2 = (s, i) -> ref.set(ref.get() + i + s);
+        biConsumer1.andThen(biConsumer2).accept("B", 2);
+        assertEquals("B22B", ref.get());
+    }
+
+    @Test
+    public void testAsBiConsumer() {
+        final Map<String, Integer> map = new HashMap<>();
+        map.put("a", 1);
+        assertThrows(UncheckedIOException.class, () -> map.forEach(TestConstants.THROWING_IO_BI_CONSUMER.asBiConsumer()));
+        final AtomicReference<String> ref = new AtomicReference<>();
+        final IOBiConsumer<String, Integer> consumer1 = (t, u) -> ref.set(t + u);
+        map.forEach(consumer1.asBiConsumer());
+        assertEquals("a1", ref.get());
+    }
+
+    @Test
+    public void testNoopIOConsumer() throws IOException {
+        IOBiConsumer.noop().accept(null, null);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOBiFunctionTest.java b/src/test/java/org/apache/commons/io/function/IOBiFunctionTest.java
new file mode 100644
index 0000000..b854b4e
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOBiFunctionTest.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.io.file.PathUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOBiFunction}.
+ */
+public class IOBiFunctionTest {
+
+    @SuppressWarnings("unused")
+    private boolean not(final boolean value) throws IOException {
+        return !value;
+    }
+
+    /**
+     * Tests {@link IOBiFunction#andThen(IOFunction)}.
+     *
+     * @throws IOException thrown on test failure
+     */
+    @Test
+    public void testAndThenIOFunction() throws IOException {
+        final IOBiFunction<Path, LinkOption[], Boolean> isDirectory = Files::isDirectory;
+        final IOFunction<Boolean, Boolean> not = this::not;
+        assertEquals(true, isDirectory.apply(PathUtils.current(), PathUtils.EMPTY_LINK_OPTION_ARRAY));
+        final IOBiFunction<Path, LinkOption[], Boolean> andThen = isDirectory.andThen(not);
+        assertEquals(false, andThen.apply(PathUtils.current(), PathUtils.EMPTY_LINK_OPTION_ARRAY));
+    }
+
+    /**
+     * Tests {@link IOBiFunction#apply(Object, Object)}.
+     *
+     * @throws IOException thrown on test failure
+     */
+    @Test
+    public void testApply() throws IOException {
+        final IOBiFunction<Path, LinkOption[], Boolean> isDirectory = Files::isDirectory;
+        assertEquals(true, isDirectory.apply(PathUtils.current(), PathUtils.EMPTY_LINK_OPTION_ARRAY));
+    }
+
+    /**
+     * Tests {@link IOBiFunction#apply(Object, Object)}.
+     */
+    @Test
+    public void testApplyThrowsException() {
+        final IOBiFunction<Path, LinkOption[], Boolean> isDirectory = (t, u) -> {
+            throw new IOException("Boom!");
+        };
+        assertThrows(IOException.class, () -> isDirectory.apply(PathUtils.current(), PathUtils.EMPTY_LINK_OPTION_ARRAY));
+    }
+
+    @Test
+    public void testAsBiFunction() {
+        final Map<String, Long> map = new HashMap<>();
+        map.put("1", 0L);
+        final IOBiFunction<String, Long, Long> f = (t, u) -> Files.size(PathUtils.current());
+        map.computeIfPresent("1", f.asBiFunction());
+        assertNotEquals(0L, map.get("1"));
+    }
+
+    @Test
+    public void testNoopIOConsumer() throws IOException {
+        assertNull(IOBiFunction.noop().apply(null, null));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOBinaryOperatorStreamTest.java b/src/test/java/org/apache/commons/io/function/IOBinaryOperatorStreamTest.java
new file mode 100644
index 0000000..c395c61
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOBinaryOperatorStreamTest.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.function.BiFunction;
+import java.util.function.BinaryOperator;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.file.PathUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOBinaryOperator}.
+ */
+public class IOBinaryOperatorStreamTest {
+
+    private static final IOBinaryOperator<Path> MIN_BY_IO_BO = IOBinaryOperator.minBy(IOComparatorTest.REAL_PATH_COMP);
+    private static final BinaryOperator<Path> MIN_BY_BO = MIN_BY_IO_BO.asBinaryOperator();
+    private static final IOBinaryOperator<Path> MAX_BY_IO_BO = IOBinaryOperator.maxBy(IOComparatorTest.REAL_PATH_COMP);
+    private static final BinaryOperator<Path> MAX_BY_BO = MAX_BY_IO_BO.asBinaryOperator();
+    private static final IOBinaryOperator<Path> REAL_PATH_IO_BO = (t, u) -> t.toRealPath();
+    private static final BinaryOperator<Path> REAL_PATH_BO = REAL_PATH_IO_BO.asBinaryOperator();
+
+    @Test
+    public void testAsBinaryOperator() {
+        assertThrows(UncheckedIOException.class,
+            () -> Stream.of(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_A).reduce((TestUtils.<Path>throwingIOBinaryOperator()).asBinaryOperator()).get());
+        assertEquals(TestConstants.ABS_PATH_A, Stream.of(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_A).reduce(MAX_BY_BO).get());
+        assertEquals(TestConstants.ABS_PATH_A, Stream.of(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_A).reduce(MIN_BY_BO).get());
+    }
+
+    /**
+     */
+    @Test
+    public void testMaxBy() {
+        assertEquals(TestConstants.ABS_PATH_A, Stream.of(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_A).reduce(MAX_BY_BO).get());
+        // in-line lambda ok:
+        final IOBinaryOperator<Path> binIoOp = IOBinaryOperator.maxBy((t, u) -> t.toRealPath().compareTo(u));
+        final BiFunction<Path, Path, Path> asBiFunction = binIoOp.asBiFunction();
+        final BinaryOperator<Path> asBinaryOperator = binIoOp.asBinaryOperator();
+        assertEquals(TestConstants.ABS_PATH_B, asBiFunction.apply(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_B));
+        assertEquals(TestConstants.ABS_PATH_B, asBinaryOperator.apply(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_B));
+        //
+        assertEquals(TestConstants.ABS_PATH_A, Stream.of(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_A).reduce(asBinaryOperator).get());
+        assertEquals(TestConstants.ABS_PATH_B, Stream.of(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_B).reduce(asBinaryOperator).get());
+        assertEquals(TestConstants.ABS_PATH_B, Stream.of(TestConstants.ABS_PATH_B, TestConstants.ABS_PATH_A).reduce(asBinaryOperator).get());
+    }
+
+    @Test
+    public void testMinBy() {
+        assertEquals(TestConstants.ABS_PATH_A, Stream.of(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_A).reduce(MIN_BY_BO).get());
+        // in-line lambda ok:
+        final IOBinaryOperator<Path> binIoOp = IOBinaryOperator.minBy((t, u) -> t.toRealPath().compareTo(u));
+        final BiFunction<Path, Path, Path> asBiFunction = binIoOp.asBiFunction();
+        final BinaryOperator<Path> asBinaryOperator = binIoOp.asBinaryOperator();
+        assertEquals(TestConstants.ABS_PATH_A, asBiFunction.apply(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_B));
+        assertEquals(TestConstants.ABS_PATH_A, asBinaryOperator.apply(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_B));
+        //
+        assertEquals(TestConstants.ABS_PATH_A, Stream.of(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_A).reduce(asBinaryOperator).get());
+        assertEquals(TestConstants.ABS_PATH_A, Stream.of(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_B).reduce(asBinaryOperator).get());
+        assertEquals(TestConstants.ABS_PATH_A, Stream.of(TestConstants.ABS_PATH_B, TestConstants.ABS_PATH_A).reduce(asBinaryOperator).get());
+    }
+
+    @Test
+    public void testReduce() throws IOException {
+        // A silly example to pass in a IOBinaryOperator.
+        final Path current = PathUtils.current();
+        final Path expected = Files.list(current).reduce((t, u) -> {
+            try {
+                return t.toRealPath();
+            } catch (final IOException e) {
+                return fail(e);
+            }
+        }).get();
+        assertEquals(expected, Files.list(current).reduce(REAL_PATH_BO).get());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOComparatorTest.java b/src/test/java/org/apache/commons/io/function/IOComparatorTest.java
new file mode 100644
index 0000000..89b488b
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOComparatorTest.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOComparator}.
+ */
+public class IOComparatorTest {
+
+    /** {@link Files#size(Path)} throws IOException */
+    static final IOComparator<Path> PATH_SIZE_COMP = (final Path t, final Path u) -> Long.compare(Files.size(t), Files.size(u));
+
+    /** {@link Path#toRealPath(java.nio.file.LinkOption...)} throws IOException */
+    static final IOComparator<Path> REAL_PATH_COMP = (final Path t, final Path u) -> t.toRealPath().compareTo(u);
+
+    @Test
+    public void testAsComparator() {
+        assertEquals(0, REAL_PATH_COMP.asComparator().compare(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_A));
+        assertThrows(UncheckedIOException.class,
+            () -> TestConstants.THROWING_IO_COMPARATOR.asComparator().compare(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_B));
+    }
+
+    @Test
+    public void testCompareLong() throws IOException {
+        assertEquals(0, REAL_PATH_COMP.compare(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_A));
+    }
+
+    @Test
+    public void testComparePath() throws IOException {
+        assertEquals(0, PATH_SIZE_COMP.compare(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_A));
+    }
+
+    @Test
+    public void testThrowing() {
+        assertThrows(IOException.class, () -> TestConstants.THROWING_IO_COMPARATOR.compare(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_B));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOConsumerTest.java b/src/test/java/org/apache/commons/io/function/IOConsumerTest.java
new file mode 100644
index 0000000..5bfeb40
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOConsumerTest.java
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.StringReader;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.util.Arrays;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.IOExceptionList;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.file.PathUtils;
+import org.apache.commons.io.test.ThrowOnCloseReader;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOConsumer}.
+ */
+public class IOConsumerTest {
+
+    @Test
+    void testAccept() throws IOException {
+        IOConsumer.noop().accept(null);
+        IOConsumer.noop().accept(".");
+        Uncheck.accept(Files::size, PathUtils.current());
+        //
+        final AtomicReference<String> ref = new AtomicReference<>();
+        final IOConsumer<String> consumer = s -> ref.set(s + "1");
+        consumer.accept("A");
+        assertEquals("A1", ref.get());
+    }
+
+    @Test
+    void testAndThen() throws IOException {
+        final AtomicReference<String> ref = new AtomicReference<>();
+        final IOConsumer<String> consumer1 = s -> ref.set(s + "1");
+        final IOConsumer<String> consumer2 = s -> ref.set(ref.get() + "2" + s);
+        consumer1.andThen(consumer2).accept("B");
+        assertEquals("B12B", ref.get());
+    }
+
+    @Test
+    public void testAsConsumer() {
+        assertThrows(UncheckedIOException.class, () -> Optional.of("a").ifPresent(TestUtils.throwingIOConsumer().asConsumer()));
+        final AtomicReference<String> ref = new AtomicReference<>();
+        final IOConsumer<String> consumer1 = s -> ref.set(s + "1");
+        Optional.of("a").ifPresent(consumer1.asConsumer());
+        assertEquals("a1", ref.get());
+    }
+
+    @Test
+    public void testForAllArrayOf1() throws IOException {
+        IOConsumer.forAll(TestUtils.throwingIOConsumer(), (String[]) null);
+        IOConsumer.forAll(null, (String[]) null);
+        assertThrows(IOExceptionList.class, () -> IOConsumer.forAll(TestUtils.throwingIOConsumer(), new String[] {"1"}));
+        //
+        final AtomicReference<String> ref = new AtomicReference<>("0");
+        final IOConsumer<String> consumer1 = s -> ref.set(ref.get() + s);
+        IOConsumer.forAll(consumer1, new String[] {"1"});
+        assertEquals("01", ref.get());
+    }
+
+    @Test
+    public void testForAllArrayOf2() throws IOException {
+        IOConsumer.forAll(TestUtils.throwingIOConsumer(), (String[]) null);
+        IOConsumer.forAll(null, (String[]) null);
+        assertThrows(IOExceptionList.class, () -> IOConsumer.forAll(TestUtils.throwingIOConsumer(), new String[] {"1", "2"}));
+        //
+        final AtomicReference<String> ref = new AtomicReference<>("0");
+        final IOConsumer<String> consumer1 = s -> ref.set(ref.get() + s);
+        IOConsumer.forAll(consumer1, new String[] {"1", "2"});
+        assertEquals("012", ref.get());
+    }
+
+    @Test
+    public void testForAllIterableOf1() throws IOException {
+        IOConsumer.forAll(TestUtils.throwingIOConsumer(), (Iterable<Object>) null);
+        IOConsumer.forAll(null, (Iterable<Object>) null);
+        assertThrows(IOExceptionList.class, () -> IOConsumer.forAll(TestUtils.throwingIOConsumer(), Arrays.asList("1")));
+
+        final AtomicReference<String> ref = new AtomicReference<>("0");
+        final IOConsumer<String> consumer1 = s -> ref.set(ref.get() + s);
+        IOConsumer.forAll(consumer1, Arrays.asList("1"));
+        assertEquals("01", ref.get());
+    }
+
+    @Test
+    public void testForAllIterableOf2() throws IOException {
+        IOConsumer.forAll(TestUtils.throwingIOConsumer(), (Iterable<Object>) null);
+        IOConsumer.forAll(null, (Iterable<Object>) null);
+        assertThrows(IOExceptionList.class, () -> IOConsumer.forAll(TestUtils.throwingIOConsumer(), Arrays.asList("1", "2")));
+
+        final AtomicReference<String> ref = new AtomicReference<>("0");
+        final IOConsumer<String> consumer1 = s -> ref.set(ref.get() + s);
+        IOConsumer.forAll(consumer1, Arrays.asList("1", "2"));
+        assertEquals("012", ref.get());
+    }
+
+    @Test
+    public void testForAllStreamOf1() throws IOException {
+        IOConsumer.forAll(TestUtils.throwingIOConsumer(), (Stream<Object>) null);
+        IOConsumer.forAll(null, (Stream<Object>) null);
+        assertThrows(IOExceptionList.class, () -> IOConsumer.forAll(TestUtils.throwingIOConsumer(), Arrays.asList("1").stream()));
+
+        final AtomicReference<String> ref = new AtomicReference<>("0");
+        final IOConsumer<String> consumer1 = s -> ref.set(ref.get() + s);
+        IOConsumer.forAll(consumer1, Arrays.asList("1").stream());
+        assertEquals("01", ref.get());
+    }
+
+    @Test
+    public void testForAllStreamOf2() throws IOException {
+        IOConsumer.forAll(TestUtils.throwingIOConsumer(), (Stream<Object>) null);
+        IOConsumer.forAll(null, (Stream<Object>) null);
+        assertThrows(IOExceptionList.class, () -> IOConsumer.forAll(TestUtils.throwingIOConsumer(), Arrays.asList("1", "2").stream()));
+
+        final AtomicReference<String> ref = new AtomicReference<>("0");
+        final IOConsumer<String> consumer1 = s -> ref.set(ref.get() + s);
+        IOConsumer.forAll(consumer1, Arrays.asList("1", "2").stream());
+        assertEquals("012", ref.get());
+    }
+
+    @Test
+    public void testNoop() {
+        final Closeable nullCloseable = null;
+        final IOConsumer<IOException> noopConsumer = IOConsumer.noop(); // noop consumer doesn't throw
+        assertDoesNotThrow(() -> IOUtils.close(nullCloseable, noopConsumer));
+        assertDoesNotThrow(() -> IOUtils.close(new StringReader("s"), noopConsumer));
+        assertDoesNotThrow(() -> IOUtils.close(new ThrowOnCloseReader(new StringReader("s")), noopConsumer));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOFunctionTest.java b/src/test/java/org/apache/commons/io/function/IOFunctionTest.java
new file mode 100644
index 0000000..1032aee
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOFunctionTest.java
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.util.Optional;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.apache.commons.lang3.ArrayUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOFunction}.
+ */
+public class IOFunctionTest {
+
+    private static class Holder<T> {
+        T value;
+    }
+
+    @Test
+    public void testAndThenConsumer() throws IOException {
+        final Holder<Integer> holder = new Holder<>();
+        final IOFunction<InputStream, Integer> readByte = InputStream::read;
+        final Consumer<Integer> sinkInteger = i -> {
+            holder.value = i * i;
+        };
+        final IOConsumer<InputStream> productFunction = readByte.andThen(sinkInteger);
+
+        final InputStream is = new ByteArrayInputStream(new byte[] {2, 3});
+        productFunction.accept(is);
+        assertEquals(4, holder.value);
+        productFunction.accept(is);
+        assertEquals(9, holder.value);
+    }
+
+    @Test
+    public void testAndThenFunction() throws IOException {
+        final IOFunction<InputStream, Integer> readByte = InputStream::read;
+        final Function<Integer, Integer> squareInteger = i -> i * i;
+        final IOFunction<InputStream, Integer> productFunction = readByte.andThen(squareInteger);
+
+        final InputStream is = new ByteArrayInputStream(new byte[] {2, 3});
+        assertEquals(4, productFunction.apply(is));
+        assertEquals(9, productFunction.apply(is));
+    }
+
+    @Test
+    public void testAndThenIOConsumer() throws IOException {
+        final Holder<Integer> holder = new Holder<>();
+        final IOFunction<InputStream, Integer> readByte = InputStream::read;
+        final IOConsumer<Integer> sinkInteger = i -> {
+            holder.value = i * i;
+        };
+        final IOConsumer<InputStream> productFunction = readByte.andThen(sinkInteger);
+
+        final InputStream is = new ByteArrayInputStream(new byte[] {2, 3});
+        productFunction.accept(is);
+        assertEquals(4, holder.value);
+        productFunction.accept(is);
+        assertEquals(9, holder.value);
+    }
+
+    @Test
+    public void testAndThenIOFunction() throws IOException {
+        final IOFunction<InputStream, Integer> readByte = InputStream::read;
+        final IOFunction<Integer, Integer> squareInteger = i -> i * i;
+        final IOFunction<InputStream, Integer> productFunction = readByte.andThen(squareInteger);
+
+        final InputStream is = new ByteArrayInputStream(new byte[] {2, 3});
+        assertEquals(4, productFunction.apply(is));
+        assertEquals(9, productFunction.apply(is));
+    }
+
+    @Test
+    public void testApply() throws IOException {
+        final IOFunction<InputStream, Integer> readByte = InputStream::read;
+        final InputStream is = new ByteArrayInputStream(new byte[] {(byte) 0xa, (byte) 0xb, (byte) 0xc});
+        assertEquals(0xa, readByte.apply(is));
+        assertEquals(0xb, readByte.apply(is));
+        assertEquals(0xc, readByte.apply(is));
+        assertEquals(-1, readByte.apply(is));
+    }
+
+    @Test
+    public void testApplyThrowsException() {
+        final IOFunction<InputStream, Integer> throwException = function -> {
+            throw new IOException("Boom!");
+        };
+        assertThrows(IOException.class, () -> throwException.apply(new ByteArrayInputStream(ArrayUtils.EMPTY_BYTE_ARRAY)));
+    }
+
+    @Test
+    public void testAsFunction() {
+        assertThrows(UncheckedIOException.class, () -> Optional.of("a").map(TestConstants.THROWING_IO_FUNCTION.asFunction()).get());
+        assertEquals("a", Optional.of("a").map(IOFunction.identity().asFunction()).get());
+    }
+
+    @Test
+    public void testComposeFunction() throws IOException {
+        final Function<InputStream, Integer> alwaysSeven = is -> 7;
+        final IOFunction<Integer, Integer> squareInteger = i -> i * i;
+        final IOFunction<InputStream, Integer> productFunction = squareInteger.compose(alwaysSeven);
+
+        final InputStream is = new ByteArrayInputStream(new byte[] {2, 3});
+        assertEquals(49, productFunction.apply(is));
+        assertEquals(49, productFunction.apply(is));
+    }
+
+    @Test
+    public void testComposeIOFunction() throws IOException {
+        final IOFunction<InputStream, Integer> readByte = InputStream::read;
+        final IOFunction<Integer, Integer> squareInteger = i -> i * i;
+        final IOFunction<InputStream, Integer> productFunction = squareInteger.compose(readByte);
+
+        final InputStream is = new ByteArrayInputStream(new byte[] {2, 3});
+        assertEquals(4, productFunction.apply(is));
+        assertEquals(9, productFunction.apply(is));
+    }
+
+    @Test
+    public void testComposeIOSupplier() throws IOException {
+        final InputStream is = new ByteArrayInputStream(new byte[] {2, 3});
+
+        final IOSupplier<Integer> readByte = is::read;
+        final IOFunction<Integer, Integer> squareInteger = i -> i * i;
+        final IOSupplier<Integer> productFunction = squareInteger.compose(readByte);
+
+        assertEquals(4, productFunction.get());
+        assertEquals(9, productFunction.get());
+    }
+
+    @Test
+    public void testComposeSupplier() throws IOException {
+        final Supplier<Integer> alwaysNine = () -> 9;
+        final IOFunction<Integer, Integer> squareInteger = i -> i * i;
+        final IOSupplier<Integer> productFunction = squareInteger.compose(alwaysNine);
+
+        assertEquals(81, productFunction.get());
+        assertEquals(81, productFunction.get());
+    }
+
+    @Test
+    public void testIdentity() throws IOException {
+        assertEquals(IOFunction.identity(), IOFunction.identity());
+        final IOFunction<byte[], byte[]> identityFunction = IOFunction.identity();
+        final byte[] buf = new byte[] {(byte) 0xa, (byte) 0xb, (byte) 0xc};
+        assertEquals(buf, identityFunction.apply(buf));
+        assertArrayEquals(buf, identityFunction.apply(buf));
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOIntStream.java b/src/test/java/org/apache/commons/io/function/IOIntStream.java
new file mode 100644
index 0000000..961235e
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOIntStream.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.util.stream.IntStream;
+
+/**
+ * Placeholder for future possible development and makes sure we can extend IOBaseStream cleanly with proper generics.
+ */
+interface IOIntStream extends IOBaseStream<Integer, IOIntStream, IntStream> {
+    // Placeholder for future possible development.
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOIntStreamAdapter.java b/src/test/java/org/apache/commons/io/function/IOIntStreamAdapter.java
new file mode 100644
index 0000000..7ab20d2
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOIntStreamAdapter.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.util.stream.IntStream;
+
+/**
+ * Placeholder for future possible development and makes sure we can extend IOBaseStreamAdapter cleanly with proper
+ * generics.
+ */
+class IOIntStreamAdapter extends IOBaseStreamAdapter<Integer, IOIntStream, IntStream> implements IOIntStream {
+
+    static IOIntStream adapt(final IntStream stream) {
+        return new IOIntStreamAdapter(stream);
+    }
+
+    private IOIntStreamAdapter(final IntStream stream) {
+        super(stream);
+    }
+
+    @Override
+    public IOIntStream wrap(final IntStream delegate) {
+        return unwrap() == delegate ? this : IOIntStreamAdapter.adapt(delegate);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOIteratorAdapterTest.java b/src/test/java/org/apache/commons/io/function/IOIteratorAdapterTest.java
new file mode 100644
index 0000000..b298d72
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOIteratorAdapterTest.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.lang3.JavaVersion;
+import org.apache.commons.lang3.SystemUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@code IOIteratorAdapter}.
+ */
+public class IOIteratorAdapterTest {
+
+    private IOIteratorAdapter<Path> iterator;
+
+    @BeforeEach
+    public void beforeEach() {
+        iterator = IOIteratorAdapter.adapt(newPathList().iterator());
+    }
+
+    private List<Path> newPathList() {
+        return Arrays.asList(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_B);
+    }
+
+    @Test
+    public void testAdapt() throws IOException {
+        assertEquals(TestConstants.ABS_PATH_A, iterator.next());
+    }
+
+    @Test
+    public void testAsIterator() {
+        assertEquals(TestConstants.ABS_PATH_A, iterator.asIterator().next());
+    }
+
+    @Test
+    public void testForEachRemaining() throws IOException {
+        final List<Path> list = new ArrayList<>();
+        iterator.forEachRemaining(p -> list.add(p.toRealPath()));
+        assertFalse(iterator.hasNext());
+        assertEquals(newPathList(), list);
+    }
+
+    @Test
+    public void testHasNext() throws IOException {
+        assertTrue(iterator.hasNext());
+        iterator.forEachRemaining(Path::toRealPath);
+        assertFalse(iterator.hasNext());
+    }
+
+    @Test
+    public void testNext() throws IOException {
+        assertEquals(TestConstants.ABS_PATH_A, iterator.next());
+    }
+
+    @Test
+    public void testRemove() throws IOException {
+        final Class<? extends Exception> exClass = SystemUtils.isJavaVersionAtMost(JavaVersion.JAVA_1_8) ? IllegalStateException.class
+            : UnsupportedOperationException.class;
+        assertThrows(exClass, iterator::remove);
+        assertThrows(exClass, iterator::remove);
+        iterator.next();
+        assertThrows(UnsupportedOperationException.class, iterator::remove);
+        assertThrows(UnsupportedOperationException.class, iterator::remove);
+
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOIteratorTest.java b/src/test/java/org/apache/commons/io/function/IOIteratorTest.java
new file mode 100644
index 0000000..ca090dd
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOIteratorTest.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.commons.lang3.JavaVersion;
+import org.apache.commons.lang3.SystemUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOIterator}.
+ */
+public class IOIteratorTest {
+
+    private IOIterator<Path> iterator;
+
+    @BeforeEach
+    public void beforeEach() {
+        iterator = IOIterator.adapt(newPathList().iterator());
+    }
+
+    private List<Path> newPathList() {
+        return Arrays.asList(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_B);
+    }
+
+    @Test
+    public void testAdapt() throws IOException {
+        assertEquals(TestConstants.ABS_PATH_A, iterator.next());
+    }
+
+    @Test
+    public void testAsIterator() {
+        final Iterator<Path> asIterator = iterator.asIterator();
+        assertTrue(asIterator.hasNext());
+        assertEquals(TestConstants.ABS_PATH_A, asIterator.next());
+        assertThrows(UnsupportedOperationException.class, asIterator::remove);
+    }
+
+    @Test
+    public void testForEachRemaining() throws IOException {
+        final List<Path> list = new ArrayList<>();
+        iterator.forEachRemaining(p -> list.add(p.toRealPath()));
+        assertFalse(iterator.hasNext());
+        assertEquals(newPathList(), list);
+    }
+
+    @Test
+    public void testHasNext() throws IOException {
+        assertTrue(iterator.hasNext());
+        iterator.forEachRemaining(Path::toRealPath);
+        assertFalse(iterator.hasNext());
+    }
+
+    @Test
+    public void testNext() throws IOException {
+        assertEquals(TestConstants.ABS_PATH_A, iterator.next());
+    }
+
+    @Test
+    public void testRemove() throws IOException {
+        final Class<? extends Exception> exClass = SystemUtils.isJavaVersionAtMost(JavaVersion.JAVA_1_8) ? IllegalStateException.class
+            : UnsupportedOperationException.class;
+        assertThrows(exClass, iterator::remove);
+        assertThrows(exClass, iterator::remove);
+        iterator.next();
+        assertThrows(UnsupportedOperationException.class, iterator::remove);
+        assertThrows(UnsupportedOperationException.class, iterator::remove);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOPredicateTest.java b/src/test/java/org/apache/commons/io/function/IOPredicateTest.java
new file mode 100644
index 0000000..a5047a7
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOPredicateTest.java
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+/**
+ * Tests {@link IOPredicate}.
+ */
+public class IOPredicateTest {
+
+    /** Files::isHidden throws IOException. */
+    private static final IOPredicate<Path> IS_HIDDEN = Files::isHidden;
+
+    private static final Path PATH_FIXTURE = Paths.get("src/test/resources/org/apache/commons/io/abitmorethan16k.txt");
+
+    private static final Object THROWING_EQUALS = new Object() {
+        @Override
+        public boolean equals(final Object obj) {
+            throw Erase.rethrow(new IOException("Expected"));
+        }
+    };
+
+    private static final Predicate<Object> THROWING_UNCHECKED_PREDICATE = TestConstants.THROWING_IO_PREDICATE.asPredicate();
+
+    private void assertThrowsChecked(final Executable executable) {
+        assertThrows(IOException.class, executable);
+    }
+
+    private void assertThrowsUnchecked(final Executable executable) {
+        assertThrows(UncheckedIOException.class, executable);
+    }
+
+    @Test
+    public void testAndChecked() throws IOException {
+        assertFalse(IS_HIDDEN.and(IS_HIDDEN).test(PATH_FIXTURE));
+        assertTrue(IOPredicate.alwaysTrue().and(IOPredicate.alwaysTrue()).test(PATH_FIXTURE));
+        assertFalse(IOPredicate.alwaysFalse().and(IOPredicate.alwaysTrue()).test(PATH_FIXTURE));
+        assertFalse(IOPredicate.alwaysTrue().and(IOPredicate.alwaysFalse()).test(PATH_FIXTURE));
+        assertFalse(IOPredicate.alwaysFalse().and(IOPredicate.alwaysFalse()).test(PATH_FIXTURE));
+    }
+
+    @Test
+    public void testAndUnchecked() {
+        assertThrowsUnchecked(() -> THROWING_UNCHECKED_PREDICATE.and(THROWING_UNCHECKED_PREDICATE).test(PATH_FIXTURE));
+    }
+
+    @Test
+    public void testAsPredicate() throws IOException {
+        new ArrayList<>().removeIf(THROWING_UNCHECKED_PREDICATE);
+        final List<String> list = new ArrayList<>();
+        list.add("A");
+        list.add("B");
+        list.removeIf(Predicate.isEqual("A"));
+        assertFalse(list.contains("A"));
+        list.removeIf(IOPredicate.isEqual("B").asPredicate());
+        assertFalse(list.contains("B"));
+        assertFalse(IS_HIDDEN.test(PATH_FIXTURE));
+    }
+
+    @Test
+    public void testFalse() throws IOException {
+        assertFalse(Constants.IO_PREDICATE_FALSE.test("A"));
+        // Make sure we keep the argument type
+        final IOPredicate<String> alwaysFalse = IOPredicate.alwaysFalse();
+        assertFalse(alwaysFalse.test("A"));
+        assertEquals(IOPredicate.alwaysFalse(), IOPredicate.alwaysFalse());
+        assertSame(IOPredicate.alwaysFalse(), IOPredicate.alwaysFalse());
+    }
+
+    @Test
+    public void testIsEqualChecked() throws IOException {
+        assertThrowsChecked(() -> IOPredicate.isEqual(THROWING_EQUALS).test("B"));
+        assertFalse(IOPredicate.isEqual(null).test("A"));
+        assertTrue(IOPredicate.isEqual("B").test("B"));
+        assertFalse(IOPredicate.isEqual("A").test("B"));
+        assertFalse(IOPredicate.isEqual("B").test("A"));
+    }
+
+    @Test
+    public void testIsEqualUnchecked() {
+        assertThrowsUnchecked(() -> IOPredicate.isEqual(THROWING_EQUALS).asPredicate().test("B"));
+        assertFalse(IOPredicate.isEqual(null).asPredicate().test("A"));
+        assertTrue(IOPredicate.isEqual("B").asPredicate().test("B"));
+        assertFalse(IOPredicate.isEqual("A").asPredicate().test("B"));
+        assertFalse(IOPredicate.isEqual("B").asPredicate().test("A"));
+    }
+
+    @Test
+    public void testNegateChecked() throws IOException {
+        assertTrue(IS_HIDDEN.negate().test(PATH_FIXTURE));
+        assertFalse(IOPredicate.alwaysTrue().negate().test(PATH_FIXTURE));
+    }
+
+    @Test
+    public void testNegateUnchecked() {
+        assertTrue(IS_HIDDEN.negate().asPredicate().test(PATH_FIXTURE));
+        assertTrue(IS_HIDDEN.asPredicate().negate().test(PATH_FIXTURE));
+        assertThrowsUnchecked(() -> THROWING_UNCHECKED_PREDICATE.negate().test(PATH_FIXTURE));
+    }
+
+    @Test
+    public void testOrChecked() throws IOException {
+        assertFalse(IS_HIDDEN.or(IS_HIDDEN).test(PATH_FIXTURE));
+        assertTrue(IOPredicate.alwaysTrue().or(IOPredicate.alwaysFalse()).test(PATH_FIXTURE));
+        assertTrue(IOPredicate.alwaysFalse().or(IOPredicate.alwaysTrue()).test(PATH_FIXTURE));
+    }
+
+    @Test
+    public void testOrUnchecked() {
+        assertFalse(IS_HIDDEN.asPredicate().or(e -> false).test(PATH_FIXTURE));
+        assertThrowsUnchecked(() -> THROWING_UNCHECKED_PREDICATE.or(THROWING_UNCHECKED_PREDICATE).test(PATH_FIXTURE));
+    }
+
+    @Test
+    public void testTestChecked() throws IOException {
+        assertThrowsChecked(() -> TestConstants.THROWING_IO_PREDICATE.test(null));
+        assertTrue(Constants.IO_PREDICATE_TRUE.test("A"));
+    }
+
+    @Test
+    public void testTestUnchecked() {
+        assertThrowsUnchecked(() -> THROWING_UNCHECKED_PREDICATE.test(null));
+        assertTrue(Constants.IO_PREDICATE_TRUE.asPredicate().test("A"));
+    }
+
+    @Test
+    public void testTrue() throws IOException {
+        assertTrue(Constants.IO_PREDICATE_TRUE.test("A"));
+        // Make sure we keep the argument type
+        final IOPredicate<String> alwaysTrue = IOPredicate.alwaysTrue();
+        assertTrue(alwaysTrue.test("A"));
+        assertEquals(IOPredicate.alwaysTrue(), IOPredicate.alwaysTrue());
+        assertSame(IOPredicate.alwaysTrue(), IOPredicate.alwaysTrue());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOQuadFunctionTest.java b/src/test/java/org/apache/commons/io/function/IOQuadFunctionTest.java
new file mode 100644
index 0000000..252de59
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOQuadFunctionTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.math.BigInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOQuadFunction}.
+ */
+public class IOQuadFunctionTest {
+
+    /**
+     * Tests {@link IOQuadFunction#apply(Object, Object, Object, Object)}.
+     *
+     * @throws IOException thrown on test failure
+     */
+    @Test
+    public void testAccept() throws IOException {
+        final AtomicReference<Byte> ref1 = new AtomicReference<>();
+        final AtomicReference<Short> ref2 = new AtomicReference<>();
+        final AtomicReference<String> ref3 = new AtomicReference<>();
+        final AtomicReference<Long> ref4 = new AtomicReference<>();
+        final IOQuadFunction<AtomicReference<Byte>, AtomicReference<Short>, AtomicReference<String>, AtomicReference<Long>, String> quad = (t, u, v, w) -> {
+            ref1.set(Byte.valueOf("1"));
+            ref2.set(Short.valueOf((short) 1));
+            ref3.set("z");
+            ref4.set(Long.valueOf(2));
+            return "ABCD";
+        };
+        assertEquals("ABCD", quad.apply(ref1, ref2, ref3, ref4));
+        assertEquals(Byte.valueOf("1"), ref1.get());
+        assertEquals(Short.valueOf((short) 1), ref2.get());
+        assertEquals("z", ref3.get());
+        assertEquals(Long.valueOf(2), ref4.get());
+    }
+
+    /**
+     * Tests {@link IOTriFunction#andThen(IOFunction)}.
+     *
+     * @throws IOException thrown on test failure
+     */
+    @Test
+    public void testAndThenIOFunction() throws IOException {
+        final AtomicReference<Byte> ref1 = new AtomicReference<>();
+        final AtomicReference<Short> ref2 = new AtomicReference<>();
+        final AtomicReference<String> ref3 = new AtomicReference<>();
+        final AtomicReference<Long> ref4 = new AtomicReference<>();
+        final IOQuadFunction<AtomicReference<Byte>, AtomicReference<Short>, AtomicReference<String>, AtomicReference<Long>, String> quad = (t, u, v, w) -> {
+            ref1.set(Byte.valueOf("1"));
+            ref2.set(Short.valueOf((short) 1));
+            ref3.set("z");
+            ref4.set(Long.valueOf(2));
+            return "9";
+        };
+        final IOFunction<String, BigInteger> after = t -> {
+            ref1.set(Byte.valueOf("2"));
+            ref2.set(Short.valueOf((short) 2));
+            ref3.set("zz");
+            ref4.set(Long.valueOf(3));
+            return BigInteger.valueOf(Long.parseLong(t)).add(BigInteger.ONE);
+        };
+        assertEquals(BigInteger.TEN, quad.andThen(after).apply(ref1, ref2, ref3, ref4));
+        assertEquals(Byte.valueOf("2"), ref1.get());
+        assertEquals(Short.valueOf((short) 2), ref2.get());
+        assertEquals("zz", ref3.get());
+        assertEquals(Long.valueOf(3), ref4.get());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IORunnableTest.java b/src/test/java/org/apache/commons/io/function/IORunnableTest.java
new file mode 100644
index 0000000..4788f6e
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IORunnableTest.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.commons.io.file.PathUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IORunnable}.
+ */
+public class IORunnableTest {
+
+    /**
+     * Tests {@link IORunnable#run()}.
+     *
+     * @throws IOException thrown on test failure
+     */
+    @Test
+    public void testAccept() throws IOException {
+        final AtomicReference<String> ref = new AtomicReference<>();
+        final IORunnable runnable = () -> ref.set("A1");
+        runnable.run();
+        assertEquals("A1", ref.get());
+    }
+
+    @Test
+    public void testAsRunnable() throws Exception {
+        assertThrows(UncheckedIOException.class, () -> Executors.callable(TestConstants.THROWING_IO_RUNNABLE.asRunnable()).call());
+        final IORunnable runnable = () -> Files.size(PathUtils.current());
+        assertNull(Executors.callable(runnable.asRunnable()).call());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOSpliteratorTest.java b/src/test/java/org/apache/commons/io/function/IOSpliteratorTest.java
new file mode 100644
index 0000000..e970488
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOSpliteratorTest.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Spliterator;
+import java.util.TreeSet;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOSpliterator}.
+ */
+public class IOSpliteratorTest {
+
+    private IOSpliterator<Path> spliterator;
+
+    @BeforeEach
+    public void beforeEach() {
+        spliterator = IOSpliterator.adapt(newPathList().spliterator());
+    }
+
+    private List<Path> newPathList() {
+        return Arrays.asList(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_B);
+    }
+
+    @Test
+    public void testAdapt() {
+        spliterator = IOSpliterator.adapt(newPathList().spliterator());
+        assertEquals(2, spliterator.estimateSize());
+    }
+
+    @Test
+    public void testAsSpliterator() {
+        assertEquals(2, spliterator.estimateSize());
+        assertEquals(2, spliterator.asSpliterator().estimateSize());
+    }
+
+    @Test
+    public void testCharacteristics() {
+        assertEquals(spliterator.unwrap().characteristics(), spliterator.characteristics());
+        assertEquals(spliterator.unwrap().characteristics(), spliterator.asSpliterator().characteristics());
+    }
+
+    @Test
+    public void testEstimateSize() {
+        assertEquals(2, spliterator.estimateSize());
+        assertEquals(spliterator.unwrap().estimateSize(), spliterator.estimateSize());
+        assertEquals(spliterator.unwrap().estimateSize(), spliterator.asSpliterator().estimateSize());
+    }
+
+    @Test
+    public void testForEachRemaining() {
+        final List<Path> list = new ArrayList<>();
+        spliterator.forEachRemaining(list::add);
+        assertEquals(2, list.size());
+        assertEquals(newPathList(), list);
+    }
+
+    @Test
+    public void testForEachRemainingAsSpliterator() {
+        final List<Path> list = new ArrayList<>();
+        spliterator.asSpliterator().forEachRemaining(list::add);
+        assertEquals(2, list.size());
+        assertEquals(newPathList(), list);
+    }
+
+    @Test
+    public void testGetComparator() {
+        if (spliterator.hasCharacteristics(Spliterator.SORTED)) {
+            assertEquals(spliterator.unwrap().getComparator(), spliterator.getComparator());
+            assertEquals(spliterator.unwrap().getComparator(), spliterator.asSpliterator().getComparator());
+        } else {
+            assertThrows(IllegalStateException.class, () -> spliterator.unwrap().getComparator());
+            assertThrows(IllegalStateException.class, () -> spliterator.asSpliterator().getComparator());
+        }
+        final IOSpliterator<Path> adapted = IOSpliterator.adapt(new TreeSet<>(newPathList()).stream().sorted().spliterator());
+        final IOComparator<? super Path> comparator = adapted.getComparator();
+        assertNull(comparator);
+    }
+
+    @Test
+    public void testGetExactSizeIfKnown() {
+        assertEquals(2, spliterator.getExactSizeIfKnown());
+        assertEquals(spliterator.unwrap().getExactSizeIfKnown(), spliterator.getExactSizeIfKnown());
+        assertEquals(spliterator.unwrap().getExactSizeIfKnown(), spliterator.asSpliterator().getExactSizeIfKnown());
+    }
+
+    @Test
+    public void testHasCharacteristics() {
+        assertEquals(true, spliterator.hasCharacteristics(spliterator.characteristics()));
+        assertEquals(spliterator.unwrap().hasCharacteristics(spliterator.unwrap().characteristics()),
+            spliterator.hasCharacteristics(spliterator.characteristics()));
+        assertEquals(spliterator.unwrap().hasCharacteristics(spliterator.unwrap().characteristics()),
+            spliterator.asSpliterator().hasCharacteristics(spliterator.asSpliterator().characteristics()));
+    }
+
+    @Test
+    public void testTryAdvance() {
+        final AtomicReference<Path> ref = new AtomicReference<>();
+        assertTrue(spliterator.tryAdvance(ref::set));
+        assertEquals(TestConstants.ABS_PATH_A, ref.get());
+    }
+
+    @Test
+    public void testTrySplit() {
+        final IOSpliterator<Path> trySplit = spliterator.trySplit();
+        assertNotNull(trySplit);
+        assertTrue(spliterator.getExactSizeIfKnown() > 0);
+    }
+
+    @Test
+    public void testUnwrap() {
+        assertNotNull(spliterator.unwrap());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOStreamTest.java b/src/test/java/org/apache/commons/io/function/IOStreamTest.java
new file mode 100644
index 0000000..1080cc6
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOStreamTest.java
@@ -0,0 +1,577 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import java.util.stream.DoubleStream;
+import java.util.stream.IntStream;
+import java.util.stream.LongStream;
+import java.util.stream.Stream;
+
+import org.apache.commons.lang3.JavaVersion;
+import org.apache.commons.lang3.SystemUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOStream}.
+ */
+public class IOStreamTest {
+
+    private static final boolean AT_LEAST_JAVA_11 = SystemUtils.isJavaVersionAtLeast(JavaVersion.JAVA_11);
+    private static final boolean AT_LEAST_JAVA_17 = SystemUtils.isJavaVersionAtLeast(JavaVersion.JAVA_17);
+
+    private void compareAndSetIO(final AtomicReference<String> ref, final String expected, final String update) throws IOException {
+        TestUtils.compareAndSetThrowsIO(ref, expected, update);
+    }
+
+    private void compareAndSetRE(final AtomicReference<String> ref, final String expected, final String update) {
+        TestUtils.compareAndSetThrowsRE(ref, expected, update);
+    }
+
+    private void ioExceptionOnNull(final Object test) throws IOException {
+        if (test == null) {
+            throw new IOException("Unexpected");
+        }
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testAdapt() {
+        assertEquals(0, IOStream.adapt((Stream<?>) null).count());
+        assertEquals(0, IOStream.adapt(Stream.empty()).count());
+        assertEquals(1, IOStream.adapt(Stream.of("A")).count());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testAllMatch() throws IOException {
+        assertThrows(IOException.class, () -> IOStream.of("A", "B").allMatch(TestConstants.THROWING_IO_PREDICATE));
+        assertTrue(IOStream.of("A", "B").allMatch(IOPredicate.alwaysTrue()));
+        assertFalse(IOStream.of("A", "B").allMatch(IOPredicate.alwaysFalse()));
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testAnyMatch() throws IOException {
+        assertThrows(IOException.class, () -> IOStream.of("A", "B").anyMatch(TestConstants.THROWING_IO_PREDICATE));
+        assertTrue(IOStream.of("A", "B").anyMatch(IOPredicate.alwaysTrue()));
+        assertFalse(IOStream.of("A", "B").anyMatch(IOPredicate.alwaysFalse()));
+    }
+
+    @Test
+    public void testClose() {
+        IOStream.of("A", "B").close();
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testCollectCollectorOfQsuperTAR() {
+        // TODO IOCollector?
+        IOStream.of("A", "B").collect(Collectors.toList());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testCollectSupplierOfRBiConsumerOfRQsuperTBiConsumerOfRR() throws IOException {
+        // TODO Need an IOCollector?
+        IOStream.of("A", "B").collect(() -> "A", (t, u) -> {
+        }, (t, u) -> {
+        });
+        assertEquals("AB", Stream.of("A", "B").collect(StringBuilder::new, StringBuilder::append, StringBuilder::append).toString());
+        assertEquals("AB", IOStream.of("A", "B").collect(StringBuilder::new, StringBuilder::append, StringBuilder::append).toString());
+        // Exceptions
+        assertThrows(IOException.class, () -> IOStream.of("A", "B").collect(TestUtils.throwingIOSupplier(), (t, u) -> {
+        }, (t, u) -> {
+        }));
+        assertThrows(IOException.class, () -> IOStream.of("A", "B").collect(() -> "A", TestUtils.throwingIOBiConsumer(), (t, u) -> {
+        }));
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testCount() {
+        assertEquals(0, IOStream.of().count());
+        assertEquals(1, IOStream.of("A").count());
+        assertEquals(2, IOStream.of("A", "B").count());
+        assertEquals(3, IOStream.of("A", "B", "C").count());
+        assertEquals(3, IOStream.of("A", "A", "A").count());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testDistinct() {
+        assertEquals(0, IOStream.of().distinct().count());
+        assertEquals(1, IOStream.of("A").distinct().count());
+        assertEquals(2, IOStream.of("A", "B").distinct().count());
+        assertEquals(3, IOStream.of("A", "B", "C").distinct().count());
+        assertEquals(1, IOStream.of("A", "A", "A").distinct().count());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testEmpty() throws IOException {
+        assertEquals(0, Stream.empty().count());
+        assertEquals(0, IOStream.empty().count());
+        IOStream.empty().forEach(TestUtils.throwingIOConsumer());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testFilter() throws IOException {
+        IOStream.of("A").filter(TestConstants.THROWING_IO_PREDICATE);
+        // compile vs type
+        assertThrows(IOException.class, () -> IOStream.of("A").filter(TestConstants.THROWING_IO_PREDICATE).count());
+        // compile vs inline lambda
+        assertThrows(IOException.class, () -> IOStream.of("A").filter(e -> {
+            throw new IOException("Failure");
+        }).count());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testFindAny() throws IOException {
+        // compile vs type
+        assertThrows(IOException.class, () -> IOStream.of("A").filter(TestConstants.THROWING_IO_PREDICATE).findAny());
+        // compile vs inline lambda
+        assertThrows(IOException.class, () -> IOStream.of("A").filter(e -> {
+            throw new IOException("Failure");
+        }).findAny());
+
+        assertTrue(IOStream.of("A", "B").filter(IOPredicate.alwaysTrue()).findAny().isPresent());
+        assertFalse(IOStream.of("A", "B").filter(IOPredicate.alwaysFalse()).findAny().isPresent());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testFindFirst() throws IOException {
+        // compile vs type
+        assertThrows(IOException.class, () -> IOStream.of("A").filter(TestConstants.THROWING_IO_PREDICATE).findFirst());
+        // compile vs inline lambda
+        assertThrows(IOException.class, () -> IOStream.of("A").filter(e -> {
+            throw new IOException("Failure");
+        }).findAny());
+
+        assertTrue(IOStream.of("A", "B").filter(IOPredicate.alwaysTrue()).findFirst().isPresent());
+        assertFalse(IOStream.of("A", "B").filter(IOPredicate.alwaysFalse()).findFirst().isPresent());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testFlatMap() throws IOException {
+        assertEquals(Arrays.asList("A", "B", "C", "D"),
+                IOStream.of(IOStream.of("A", "B"), IOStream.of("C", "D")).flatMap(IOFunction.identity()).collect(Collectors.toList()));
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testFlatMapToDouble() throws IOException {
+        assertEquals('A' + 'B', IOStream.of("A", "B").flatMapToDouble(e -> DoubleStream.of(e.charAt(0))).sum());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testFlatMapToInt() throws IOException {
+        assertEquals('A' + 'B', IOStream.of("A", "B").flatMapToInt(e -> IntStream.of(e.charAt(0))).sum());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testFlatMapToLong() throws IOException {
+        assertEquals('A' + 'B', IOStream.of("A", "B").flatMapToLong(e -> LongStream.of(e.charAt(0))).sum());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testForEachIOConsumerOfQsuperT() throws IOException {
+        // compile vs type
+        assertThrows(IOException.class, () -> IOStream.of("A").forEach(TestUtils.throwingIOConsumer()));
+        // compile vs inlnine
+        assertThrows(IOException.class, () -> IOStream.of("A").forEach(e -> {
+            throw new IOException("Failure");
+        }));
+        assertThrows(IOException.class, () -> IOStream.of("A", "B").forEach(TestUtils.throwingIOConsumer()));
+        final StringBuilder sb = new StringBuilder();
+        IOStream.of("A", "B").forEachOrdered(sb::append);
+        assertEquals("AB", sb.toString());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testForaAllIOConsumer() throws IOException {
+        // compile vs type
+        assertThrows(IOException.class, () -> IOStream.of("A").forAll(TestUtils.throwingIOConsumer()));
+        // compile vs inlnine
+        assertThrows(IOException.class, () -> IOStream.of("A").forAll(e -> {
+            throw new IOException("Failure");
+        }));
+        assertThrows(IOException.class, () -> IOStream.of("A", "B").forAll(TestUtils.throwingIOConsumer()));
+        final StringBuilder sb = new StringBuilder();
+        IOStream.of("A", "B").forAll(sb::append);
+        assertEquals("AB", sb.toString());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testForaAllIOConsumerBiFunction() throws IOException {
+        // compile vs type
+        assertThrows(IOException.class, () -> IOStream.of("A").forAll(TestUtils.throwingIOConsumer(), (i, e) -> e));
+        // compile vs inlnine
+        assertThrows(IOException.class, () -> IOStream.of("A").forAll(e -> {
+            throw new IOException("Failure");
+        }, (i, e) -> e));
+        assertThrows(IOException.class, () -> IOStream.of("A", "B").forAll(TestUtils.throwingIOConsumer(), (i, e) -> e));
+        final StringBuilder sb = new StringBuilder();
+        IOStream.of("A", "B").forAll(sb::append, (i, e) -> e);
+        assertEquals("AB", sb.toString());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testForaAllIOConsumerBiFunctionNull() throws IOException {
+        // compile vs type
+        assertDoesNotThrow(() -> IOStream.of("A").forAll(TestUtils.throwingIOConsumer(), null));
+        // compile vs inlnine
+        assertDoesNotThrow(() -> IOStream.of("A").forAll(e -> {
+            throw new IOException("Failure");
+        }, null));
+        assertDoesNotThrow(() -> IOStream.of("A", "B").forAll(TestUtils.throwingIOConsumer(), null));
+        final StringBuilder sb = new StringBuilder();
+        IOStream.of("A", "B").forAll(sb::append, null);
+        assertEquals("AB", sb.toString());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testForEachOrdered() throws IOException {
+        // compile vs type
+        assertThrows(IOException.class, () -> IOStream.of("A").forEach(TestUtils.throwingIOConsumer()));
+        // compile vs inlnine
+        assertThrows(IOException.class, () -> IOStream.of("A").forEach(e -> {
+            throw new IOException("Failure");
+        }));
+        assertThrows(IOException.class, () -> IOStream.of("A", "B").forEach(TestUtils.throwingIOConsumer()));
+        final StringBuilder sb = new StringBuilder();
+        IOStream.of("A", "B").forEachOrdered(sb::append);
+        assertEquals("AB", sb.toString());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testIsParallel() {
+        assertFalse(IOStream.of("A", "B").isParallel());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testIterateException() throws IOException {
+        final IOStream<Long> stream = IOStream.iterate(1L, TestUtils.throwingIOUnaryOperator());
+        final IOIterator<Long> iterator = stream.iterator();
+        assertEquals(1L, iterator.next());
+        assertThrows(IOException.class, () -> iterator.next());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testIterateLong() throws IOException {
+        final IOStream<Long> stream = IOStream.iterate(1L, i -> i + 1);
+        final IOIterator<Long> iterator = stream.iterator();
+        assertEquals(1L, iterator.next());
+        assertEquals(2L, iterator.next());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testIterator() throws IOException {
+        final AtomicInteger ref = new AtomicInteger();
+        IOStream.of("A", "B").iterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(2, ref.get());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testLimit() {
+        assertEquals(1, IOStream.of("A", "B").limit(1).count());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testMap() throws IOException {
+        assertEquals(Arrays.asList("AC", "BC"), IOStream.of("A", "B").map(e -> e + "C").collect(Collectors.toList()));
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testMapToDouble() {
+        assertArrayEquals(new double[] { Double.parseDouble("1"), Double.parseDouble("2") }, IOStream.of("1", "2").mapToDouble(Double::parseDouble).toArray());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testMapToInt() {
+        assertArrayEquals(new int[] { 1, 2 }, IOStream.of("1", "2").mapToInt(Integer::parseInt).toArray());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testMapToLong() {
+        assertArrayEquals(new long[] { 1L, 2L }, IOStream.of("1", "2").mapToLong(Long::parseLong).toArray());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testMax() throws IOException {
+        assertEquals("B", IOStream.of("A", "B").max(String::compareTo).get());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testMin() throws IOException {
+        assertEquals("A", IOStream.of("A", "B").min(String::compareTo).get());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testNoneMatch() throws IOException {
+        assertThrows(IOException.class, () -> IOStream.of("A", "B").noneMatch(TestConstants.THROWING_IO_PREDICATE));
+        assertFalse(IOStream.of("A", "B").noneMatch(IOPredicate.alwaysTrue()));
+        assertTrue(IOStream.of("A", "B").noneMatch(IOPredicate.alwaysFalse()));
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testOfArray() {
+        assertEquals(0, IOStream.of((String[]) null).count());
+        assertEquals(0, IOStream.of().count());
+        assertEquals(2, IOStream.of("A", "B").count());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testOfOne() {
+        assertEquals(1, IOStream.of("A").count());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testOfIterable() {
+        assertEquals(0, IOStream.of((Iterable<?>) null).count());
+        assertEquals(0, IOStream.of(Collections.emptyList()).count());
+        assertEquals(0, IOStream.of(Collections.emptySet()).count());
+        assertEquals(0, IOStream.of(Collections.emptySortedSet()).count());
+        assertEquals(1, IOStream.of(Arrays.asList("a")).count());
+        assertEquals(2, IOStream.of(Arrays.asList("a", "b")).count());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testOnClose() throws IOException {
+        assertThrows(IOException.class, () -> IOStream.of("A").onClose(TestConstants.THROWING_IO_RUNNABLE).close());
+        final AtomicReference<String> ref = new AtomicReference<>();
+        IOStream.of("A").onClose(() -> compareAndSetIO(ref, null, "new1")).close();
+        assertEquals("new1", ref.get());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testOnCloseMultipleHandlers() throws IOException {
+        //
+        final AtomicReference<String> ref = new AtomicReference<>();
+        // Sanity check
+        ref.set(null);
+        final RuntimeException thrownRE = assertThrows(RuntimeException.class, () -> {
+            // @formatter:off
+            final Stream<String> stream = Stream.of("A")
+                .onClose(() -> compareAndSetRE(ref, null, "new1"))
+                .onClose(() -> TestConstants.throwRuntimeException("Failure 2"));
+            // @formatter:on
+            stream.close();
+        });
+        assertEquals("new1", ref.get());
+        assertEquals("Failure 2", thrownRE.getMessage());
+        assertEquals(0, thrownRE.getSuppressed().length);
+        // Test
+        ref.set(null);
+        final IOException thrownIO = assertThrows(IOException.class, () -> {
+            // @formatter:off
+            final IOStream<String> stream = IOStream.of("A")
+                .onClose(() -> compareAndSetIO(ref, null, "new1"))
+                .onClose(() -> TestConstants.throwIOException("Failure 2"));
+            // @formatter:on
+            stream.close();
+        });
+        assertEquals("new1", ref.get());
+        assertEquals("Failure 2", thrownIO.getMessage());
+        assertEquals(0, thrownIO.getSuppressed().length);
+        //
+        final IOException thrownB = assertThrows(IOException.class, () -> {
+            // @formatter:off
+            final IOStream<String> stream = IOStream.of("A")
+                .onClose(TestConstants.throwIOException("Failure 1"))
+                .onClose(TestConstants.throwIOException("Failure 2"));
+            // @formatter:on
+            stream.close();
+        });
+        assertEquals("Failure 1", thrownB.getMessage());
+        assertEquals(0, thrownB.getSuppressed().length);
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testParallel() {
+        assertEquals(2, IOStream.of("A", "B").parallel().count());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testPeek() throws IOException {
+        final AtomicReference<String> ref = new AtomicReference<>();
+        // Stream sanity check
+        assertEquals(1, Stream.of("A").peek(e -> compareAndSetRE(ref, null, e)).count());
+        // TODO Resolve, abstract or document these differences?
+        assertEquals(AT_LEAST_JAVA_11 ? null : "A", ref.get());
+        if (AT_LEAST_JAVA_11) {
+            assertEquals(1, IOStream.of("B").peek(e -> compareAndSetRE(ref, null, e)).count());
+            assertEquals(1, IOStream.of("B").peek(e -> compareAndSetIO(ref, null, e)).count());
+            assertEquals(null, ref.get());
+        } else {
+            // Java 8
+            assertThrows(RuntimeException.class, () -> IOStream.of("B").peek(e -> compareAndSetRE(ref, null, e)).count());
+            assertThrows(IOException.class, () -> IOStream.of("B").peek(e -> compareAndSetIO(ref, null, e)).count());
+            assertEquals("A", ref.get());
+        }
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testReduceBinaryOperatorOfT() throws IOException {
+        assertEquals("AB", IOStream.of("A", "B").reduce((t, u) -> t + u).get());
+        assertEquals(TestConstants.ABS_PATH_A.toRealPath(),
+                IOStream.of(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_B).reduce((t, u) -> t.toRealPath()).get());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testReduceTBinaryOperatorOfT() throws IOException {
+        assertEquals("_AB", IOStream.of("A", "B").reduce("_", (t, u) -> t + u));
+        assertEquals(TestConstants.ABS_PATH_A.toRealPath(),
+                IOStream.of(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_B).reduce(TestConstants.ABS_PATH_A, (t, u) -> t.toRealPath()));
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testReduceUBiFunctionOfUQsuperTUBinaryOperatorOfU() throws IOException {
+        assertEquals("_AB", IOStream.of("A", "B").reduce("_", (t, u) -> t + u, (t, u) -> t + u));
+        assertEquals(TestConstants.ABS_PATH_A.toRealPath(), IOStream.of(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_B).reduce(TestConstants.ABS_PATH_A,
+                (t, u) -> t.toRealPath(), (t, u) -> u.toRealPath()));
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testSequential() {
+        assertEquals(2, IOStream.of("A", "B").sequential().count());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testSkip() throws IOException {
+        final AtomicReference<String> ref = new AtomicReference<>();
+        assertEquals(1, Stream.of("A", "B").skip(1).peek(e -> compareAndSetRE(ref, null, e)).count());
+        // TODO Resolve, abstract or document these differences?
+        assertEquals(AT_LEAST_JAVA_17 ? null : "B", ref.get());
+        if (AT_LEAST_JAVA_17) {
+            assertEquals(1, IOStream.of("C", "D").skip(1).peek(e -> compareAndSetRE(ref, null, e)).count());
+            assertEquals(1, IOStream.of("C", "D").skip(1).peek(e -> compareAndSetIO(ref, null, e)).count());
+            assertNull(ref.get());
+        } else if (AT_LEAST_JAVA_11) {
+            assertThrows(RuntimeException.class, () -> IOStream.of("C", "D").skip(1).peek(e -> compareAndSetRE(ref, null, e)).count());
+            assertThrows(IOException.class, () -> IOStream.of("C", "D").skip(1).peek(e -> compareAndSetIO(ref, null, e)).count());
+            assertEquals("B", ref.get());
+        } else {
+            assertThrows(RuntimeException.class, () -> IOStream.of("C", "D").skip(1).peek(e -> compareAndSetRE(ref, null, e)).count());
+            assertThrows(IOException.class, () -> IOStream.of("C", "D").skip(1).peek(e -> compareAndSetIO(ref, null, e)).count());
+            assertEquals("B", ref.get());
+        }
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testSorted() throws IOException {
+        assertEquals(Arrays.asList("A", "B", "C", "D"), IOStream.of("D", "A", "B", "C").sorted().collect(Collectors.toList()));
+        assertEquals(Arrays.asList("A", "B", "C", "D"), IOStream.of("D", "A", "B", "C").sorted().peek(this::ioExceptionOnNull).collect(Collectors.toList()));
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testSortedComparatorOfQsuperT() throws IOException {
+        assertEquals(Arrays.asList("A", "B", "C", "D"), IOStream.of("D", "A", "B", "C").sorted(String::compareTo).collect(Collectors.toList()));
+        assertEquals(Arrays.asList("A", "B", "C", "D"),
+                IOStream.of("D", "A", "B", "C").sorted(String::compareTo).peek(this::ioExceptionOnNull).collect(Collectors.toList()));
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testSpliterator() {
+        final AtomicInteger ref = new AtomicInteger();
+        IOStream.of("A", "B").spliterator().forEachRemaining(e -> ref.incrementAndGet());
+        assertEquals(2, ref.get());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testToArray() {
+        assertArrayEquals(new String[] { "A", "B" }, IOStream.of("A", "B").toArray());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testToArrayIntFunctionOfA() {
+        assertArrayEquals(new String[] { "A", "B" }, IOStream.of("A", "B").toArray(String[]::new));
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testUnordered() {
+        // Sanity check
+        assertArrayEquals(new String[] { "A", "B" }, Stream.of("A", "B").unordered().toArray());
+        // Test
+        assertArrayEquals(new String[] { "A", "B" }, IOStream.of("A", "B").unordered().toArray());
+    }
+
+    @SuppressWarnings("resource") // custom stream not recognized by compiler warning machinery
+    @Test
+    public void testUnwrap() {
+        final Stream<String> unwrap = IOStream.of("A", "B").unwrap();
+        assertNotNull(unwrap);
+        assertEquals(2, unwrap.count());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOSupplierTest.java b/src/test/java/org/apache/commons/io/function/IOSupplierTest.java
new file mode 100644
index 0000000..4b18f48
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOSupplierTest.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOSupplier}.
+ */
+public class IOSupplierTest {
+
+    private AtomicReference<String> ref1;
+
+    private String getThrows(final IOSupplier<String> supplier) throws IOException {
+        return supplier.get();
+    }
+
+    private String getThrowsNone(final IOSupplier<String> supplier) {
+        return supplier.asSupplier().get();
+    }
+
+    @BeforeEach
+    public void initEach() {
+        ref1 = new AtomicReference<>();
+    }
+
+    @Test
+    public void testAsSupplier() {
+        assertThrows(UncheckedIOException.class, () -> TestConstants.THROWING_IO_SUPPLIER.asSupplier().get());
+        assertEquals("new1", getThrowsNone(() -> TestUtils.compareAndSetThrowsIO(ref1, "new1")));
+        assertEquals("new1", ref1.get());
+        assertNotEquals(TestConstants.THROWING_IO_SUPPLIER.asSupplier(), TestConstants.THROWING_IO_SUPPLIER.asSupplier());
+    }
+
+    @Test
+    public void testGet() throws IOException {
+        assertThrows(IOException.class, () -> TestConstants.THROWING_IO_SUPPLIER.get());
+        assertThrows(IOException.class, () -> {
+            throw new IOException();
+        });
+        assertEquals("new1", getThrows(() -> TestUtils.compareAndSetThrowsIO(ref1, "new1")));
+        assertEquals("new1", ref1.get());
+    }
+
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/commons/io/function/IOTriConsumerTest.java b/src/test/java/org/apache/commons/io/function/IOTriConsumerTest.java
new file mode 100644
index 0000000..48db770
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOTriConsumerTest.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOTriConsumer}.
+ */
+public class IOTriConsumerTest {
+
+    @Test
+    public void testAccept() throws IOException {
+        final AtomicReference<String> ref = new AtomicReference<>();
+        final IOTriConsumer<String, Integer, Character> consumer = (s, i, b) -> ref.set(s + i + b);
+        consumer.accept("A", 1, 'b');
+        assertEquals("A1b", ref.get());
+    }
+
+    @Test
+    public void testAndThen() throws IOException {
+        final AtomicReference<String> ref = new AtomicReference<>();
+        final IOTriConsumer<String, Integer, Character> consumer1 = (s, i, b) -> ref.set(s + i + b);
+        final IOTriConsumer<String, Integer, Character> consumer2 = (s, i, b) -> ref.set(ref.get() + b + i + s);
+        consumer1.andThen(consumer2).accept("B", 2, 'b');
+        assertEquals("B2bb2B", ref.get());
+    }
+
+    @Test
+    public void testNoop() throws IOException {
+        IOTriConsumer.noop().accept(null, null, null);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOTriFunctionTest.java b/src/test/java/org/apache/commons/io/function/IOTriFunctionTest.java
new file mode 100644
index 0000000..36f5df4
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOTriFunctionTest.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.math.BigInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOTriFunction}.
+ */
+public class IOTriFunctionTest {
+
+    /**
+     * Tests {@link IOTriFunction#apply(Object, Object, Object)}.
+     *
+     * @throws IOException thrown on test failure
+     */
+    @Test
+    public void testAccept() throws IOException {
+        final AtomicReference<Character> ref1 = new AtomicReference<>();
+        final AtomicReference<Short> ref2 = new AtomicReference<>();
+        final AtomicReference<String> ref3 = new AtomicReference<>();
+        final IOTriFunction<AtomicReference<Character>, AtomicReference<Short>, AtomicReference<String>, String> tri = (t, u, v) -> {
+            ref1.set(Character.valueOf('a'));
+            ref2.set(Short.valueOf((short) 1));
+            ref3.set("z");
+            return "ABC";
+        };
+        assertEquals("ABC", tri.apply(ref1, ref2, ref3));
+        assertEquals(Character.valueOf('a'), ref1.get());
+        assertEquals(Short.valueOf((short) 1), ref2.get());
+        assertEquals("z", ref3.get());
+    }
+
+    /**
+     * Tests {@link IOTriFunction#andThen(IOFunction)}.
+     *
+     * @throws IOException thrown on test failure
+     */
+    @Test
+    public void testAndThenIOFunction() throws IOException {
+        final AtomicReference<Character> ref1 = new AtomicReference<>();
+        final AtomicReference<Short> ref2 = new AtomicReference<>();
+        final AtomicReference<String> ref3 = new AtomicReference<>();
+        final IOTriFunction<AtomicReference<Character>, AtomicReference<Short>, AtomicReference<String>, String> tri = (t, u, v) -> {
+            ref1.set(Character.valueOf('a'));
+            ref2.set(Short.valueOf((short) 1));
+            ref3.set("z");
+            return "9";
+        };
+        final IOFunction<String, BigInteger> after = t -> {
+            ref1.set(Character.valueOf('b'));
+            ref2.set(Short.valueOf((short) 2));
+            ref3.set("zz");
+            return BigInteger.valueOf(Long.parseLong(t)).add(BigInteger.ONE);
+        };
+        assertEquals(BigInteger.TEN, tri.andThen(after).apply(ref1, ref2, ref3));
+        assertEquals(Character.valueOf('b'), ref1.get());
+        assertEquals(Short.valueOf((short) 2), ref2.get());
+        assertEquals("zz", ref3.get());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/IOUnaryOperatorTest.java b/src/test/java/org/apache/commons/io/function/IOUnaryOperatorTest.java
new file mode 100644
index 0000000..11dbae0
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/IOUnaryOperatorTest.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link IOUnaryOperator}.
+ */
+public class IOUnaryOperatorTest {
+
+    @Test
+    public void testAsUnaryOperator() {
+        final List<Path> list = Arrays.asList(TestConstants.ABS_PATH_A, TestConstants.ABS_PATH_A);
+        final IOUnaryOperator<Path> throwingIOUnaryOperator = TestUtils.throwingIOUnaryOperator();
+        assertThrows(UncheckedIOException.class, () -> list.replaceAll(throwingIOUnaryOperator.asUnaryOperator()));
+        assertEquals("a", Optional.of("a").map(IOUnaryOperator.identity().asUnaryOperator()).get());
+        assertEquals("a", Optional.of("a").map(IOUnaryOperator.identity().asFunction()).get());
+    }
+
+    @Test
+    public void testIdentity() throws IOException {
+        assertEquals(IOUnaryOperator.identity(), IOUnaryOperator.identity());
+        final IOUnaryOperator<byte[]> identityFunction = IOUnaryOperator.identity();
+        final byte[] buf = {(byte) 0xa, (byte) 0xb, (byte) 0xc};
+        assertEquals(buf, identityFunction.apply(buf));
+        assertArrayEquals(buf, identityFunction.apply(buf));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/PathBaseStream.java b/src/test/java/org/apache/commons/io/function/PathBaseStream.java
new file mode 100644
index 0000000..174d112
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/PathBaseStream.java
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.nio.file.Path;
+import java.util.stream.BaseStream;
+
+/**
+ * Test fixture.
+ */
+interface PathBaseStream extends BaseStream<Path, PathBaseStream> {
+    // empty
+}
diff --git a/src/test/java/org/apache/commons/io/function/PathStream.java b/src/test/java/org/apache/commons/io/function/PathStream.java
new file mode 100644
index 0000000..ba2b7f3
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/PathStream.java
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.nio.file.Path;
+import java.util.stream.Stream;
+
+/**
+ * Test fixture.
+ */
+interface PathStream extends Stream<Path> {
+    // empty
+}
diff --git a/src/test/java/org/apache/commons/io/function/TestConstants.java b/src/test/java/org/apache/commons/io/function/TestConstants.java
new file mode 100644
index 0000000..5f0581c
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/TestConstants.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.function.Predicate;
+
+/**
+ * Test fixtures for this package.
+ */
+class TestConstants {
+
+    static final Path ABS_PATH_A = Paths.get("LICENSE.txt").toAbsolutePath();
+
+    static final Path ABS_PATH_B = Paths.get("NOTICE.txt").toAbsolutePath();
+
+    static IOBiConsumer<Object, Object> THROWING_IO_BI_CONSUMER = (t, u) -> throwIOException();
+
+    static IOBiFunction<Object, Object, Object> THROWING_IO_BI_FUNCTION = (t, u) -> throwIOException();
+
+    static IOBinaryOperator<?> THROWING_IO_BINARY_OPERATOR = (t, u) -> throwIOException();
+
+    static IOComparator<Object> THROWING_IO_COMPARATOR = (t, u) -> throwIOException();
+
+    static IOConsumer<Object> THROWING_IO_CONSUMER = t -> throwIOException();
+
+    static IOFunction<Object, Object> THROWING_IO_FUNCTION = t -> throwIOException();
+
+    static IOPredicate<Object> THROWING_IO_PREDICATE = t -> throwIOException();
+
+    static IOQuadFunction<Object, Object, Object, Object, Object> THROWING_IO_QUAD_FUNCTION = (t, u, v, w) -> throwIOException();
+
+    static IORunnable THROWING_IO_RUNNABLE = () -> throwIOException();
+
+    static IOSupplier<Object> THROWING_IO_SUPPLIER = () -> throwIOException();
+
+    static IOTriConsumer<Object, Object, Object> THROWING_IO_TRI_CONSUMER = (t, u, v) -> throwIOException();
+
+    static IOTriFunction<Object, Object, Object, Object> THROWING_IO_TRI_FUNCTION = (t, u, v) -> throwIOException();
+
+    static IOUnaryOperator<?> THROWING_IO_UNARY_OPERATOR = t -> throwIOException();
+
+    static Predicate<Object> THROWING_PREDICATE = t -> {
+        throw new UncheckedIOException(new IOException("Failure"));
+    };
+
+    static <T> T throwIOException() throws IOException {
+        return throwIOException("Failure");
+    }
+
+    static <T> T throwIOException(final String message) throws IOException {
+        throw new IOException(message);
+    }
+
+    static <T> T throwRuntimeException(final String message) {
+        throw new RuntimeException(message);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/TestUtils.java b/src/test/java/org/apache/commons/io/function/TestUtils.java
new file mode 100644
index 0000000..9b6f3aa
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/TestUtils.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicReference;
+
+class TestUtils {
+
+    static <T> T compareAndSetThrowsIO(final AtomicReference<T> ref, final T update) throws IOException {
+        return compareAndSetThrowsIO(ref, null, update);
+    }
+
+    static <T> T compareAndSetThrowsIO(final AtomicReference<T> ref, final T expected, final T update) throws IOException {
+        if (!ref.compareAndSet(expected, update)) {
+            throw new IOException("Unexpected");
+        }
+        return ref.get(); // same as update
+    }
+
+    static <T> T compareAndSetThrowsRE(final AtomicReference<T> ref, final T expected, final T update) {
+        if (!ref.compareAndSet(expected, update)) {
+            throw new RuntimeException("Unexpected");
+        }
+        return ref.get(); // same as update
+    }
+
+    @SuppressWarnings("unchecked")
+    static <T, U> IOBiConsumer<T, U> throwingIOBiConsumer() {
+        return (IOBiConsumer<T, U>) TestConstants.THROWING_IO_BI_CONSUMER;
+    }
+
+    @SuppressWarnings("unchecked")
+    static <T, U, V> IOBiFunction<T, U, V> throwingIOBiFunction() {
+        return (IOBiFunction<T, U, V>) TestConstants.THROWING_IO_BI_FUNCTION;
+    }
+
+    @SuppressWarnings("unchecked")
+    static <T> IOBinaryOperator<T> throwingIOBinaryOperator() {
+        return (IOBinaryOperator<T>) TestConstants.THROWING_IO_BINARY_OPERATOR;
+    }
+
+    @SuppressWarnings("unchecked")
+    static <T> IOComparator<T> throwingIOComparator() {
+        return (IOComparator<T>) TestConstants.THROWING_IO_COMPARATOR;
+    }
+
+    @SuppressWarnings("unchecked")
+    static <T> IOConsumer<T> throwingIOConsumer() {
+        return (IOConsumer<T>) TestConstants.THROWING_IO_CONSUMER;
+    }
+
+    @SuppressWarnings("unchecked")
+    static <T, U> IOFunction<T, U> throwingIOFunction() {
+        return (IOFunction<T, U>) TestConstants.THROWING_IO_FUNCTION;
+    }
+
+    @SuppressWarnings("unchecked")
+    static <T> IOPredicate<T> throwingIOPredicate() {
+        return (IOPredicate<T>) TestConstants.THROWING_IO_PREDICATE;
+    }
+
+    static IORunnable throwingIORunnable() {
+        return TestConstants.THROWING_IO_RUNNABLE;
+    }
+
+    @SuppressWarnings("unchecked")
+    static <T> IOSupplier<T> throwingIOSupplier() {
+        return (IOSupplier<T>) TestConstants.THROWING_IO_SUPPLIER;
+    }
+
+    @SuppressWarnings("unchecked")
+    static <T> IOUnaryOperator<T> throwingIOUnaryOperator() {
+        return (IOUnaryOperator<T>) TestConstants.THROWING_IO_UNARY_OPERATOR;
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/function/UncheckTest.java b/src/test/java/org/apache/commons/io/function/UncheckTest.java
new file mode 100644
index 0000000..ee73abc
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/function/UncheckTest.java
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link Uncheck}.
+ */
+public class UncheckTest {
+
+    private AtomicReference<String> ref1;
+    private AtomicReference<String> ref2;
+    private AtomicReference<String> ref3;
+
+    private AtomicReference<String> ref4;
+
+    @BeforeEach
+    public void initEach() {
+        ref1 = new AtomicReference<>();
+        ref2 = new AtomicReference<>();
+        ref3 = new AtomicReference<>();
+        ref4 = new AtomicReference<>();
+    }
+
+    @Test
+    public void testAcceptIOBiConsumerOfTUTU() {
+        assertThrows(UncheckedIOException.class, () -> Uncheck.accept((t, u) -> {
+            throw new IOException();
+        }, null, null));
+        assertThrows(UncheckedIOException.class, () -> Uncheck.accept(TestConstants.THROWING_IO_BI_CONSUMER, null, null));
+        Uncheck.accept((t, u) -> {
+            TestUtils.compareAndSetThrowsIO(ref1, t);
+            TestUtils.compareAndSetThrowsIO(ref2, u);
+        }, "new1", "new2");
+        assertEquals("new1", ref1.get());
+        assertEquals("new2", ref2.get());
+    }
+
+    @Test
+    public void testAcceptIOConsumerOfTT() {
+        assertThrows(UncheckedIOException.class, () -> Uncheck.accept(t -> {
+            throw new IOException();
+        }, null));
+        assertThrows(UncheckedIOException.class, () -> Uncheck.accept(TestUtils.throwingIOConsumer(), null));
+        Uncheck.accept(t -> TestUtils.compareAndSetThrowsIO(ref1, t), "new1");
+        assertEquals("new1", ref1.get());
+    }
+
+    @Test
+    public void testAcceptIOTriConsumerOfTUVTUV() {
+        assertThrows(UncheckedIOException.class, () -> Uncheck.accept((t, u, v) -> {
+            throw new IOException();
+        }, null, null, null));
+        assertThrows(UncheckedIOException.class, () -> Uncheck.accept(TestConstants.THROWING_IO_TRI_CONSUMER, null, null, null));
+        Uncheck.accept((t, u, v) -> {
+            TestUtils.compareAndSetThrowsIO(ref1, t);
+            TestUtils.compareAndSetThrowsIO(ref2, u);
+            TestUtils.compareAndSetThrowsIO(ref3, v);
+        }, "new1", "new2", "new3");
+        assertEquals("new1", ref1.get());
+        assertEquals("new2", ref2.get());
+        assertEquals("new3", ref3.get());
+    }
+
+    @Test
+    public void testApplyIOBiFunctionOfTURTU() {
+        assertThrows(UncheckedIOException.class, () -> Uncheck.apply((t, u) -> {
+            throw new IOException();
+        }, null, null));
+        assertThrows(UncheckedIOException.class, () -> Uncheck.apply(TestConstants.THROWING_IO_BI_FUNCTION, null, null));
+        assertEquals("new0", Uncheck.apply((t, u) -> {
+            TestUtils.compareAndSetThrowsIO(ref1, t);
+            TestUtils.compareAndSetThrowsIO(ref2, u);
+            return "new0";
+        }, "new1", "new2"));
+        assertEquals("new1", ref1.get());
+        assertEquals("new2", ref2.get());
+    }
+
+    @Test
+    public void testApplyIOFunctionOfTRT() {
+        assertThrows(UncheckedIOException.class, () -> Uncheck.apply(t -> {
+            throw new IOException();
+        }, null));
+        assertThrows(UncheckedIOException.class, () -> Uncheck.apply(TestConstants.THROWING_IO_FUNCTION, null));
+        Uncheck.apply(t -> TestUtils.compareAndSetThrowsIO(ref1, t), "new1");
+        assertEquals("new1", ref1.get());
+    }
+
+    @Test
+    public void testApplyIOQuadFunctionOfTUVWRTUVW() {
+        assertThrows(UncheckedIOException.class, () -> Uncheck.apply((t, u, v, w) -> {
+            throw new IOException();
+        }, null, null, null, null));
+        assertThrows(UncheckedIOException.class, () -> Uncheck.apply(TestConstants.THROWING_IO_QUAD_FUNCTION, null, null, null, null));
+        assertEquals("new0", Uncheck.apply((t, u, v, w) -> {
+            TestUtils.compareAndSetThrowsIO(ref1, t);
+            TestUtils.compareAndSetThrowsIO(ref2, u);
+            TestUtils.compareAndSetThrowsIO(ref3, v);
+            TestUtils.compareAndSetThrowsIO(ref4, w);
+            return "new0";
+        }, "new1", "new2", "new3", "new4"));
+        assertEquals("new1", ref1.get());
+        assertEquals("new2", ref2.get());
+        assertEquals("new3", ref3.get());
+        assertEquals("new4", ref4.get());
+    }
+
+    @Test
+    public void testApplyIOTriFunctionOfTUVRTUV() {
+        assertThrows(UncheckedIOException.class, () -> Uncheck.apply((t, u, v) -> {
+            throw new IOException();
+        }, null, null, null));
+        assertThrows(UncheckedIOException.class, () -> Uncheck.apply(TestConstants.THROWING_IO_TRI_FUNCTION, null, null, null));
+        assertEquals("new0", Uncheck.apply((t, u, v) -> {
+            TestUtils.compareAndSetThrowsIO(ref1, t);
+            TestUtils.compareAndSetThrowsIO(ref2, u);
+            TestUtils.compareAndSetThrowsIO(ref3, v);
+            return "new0";
+        }, "new1", "new2", "new3"));
+        assertEquals("new1", ref1.get());
+        assertEquals("new2", ref2.get());
+        assertEquals("new3", ref3.get());
+    }
+
+    @Test
+    public void testGet() {
+        assertThrows(UncheckedIOException.class, () -> Uncheck.get(() -> {
+            throw new IOException();
+        }));
+        assertThrows(UncheckedIOException.class, () -> Uncheck.get(TestConstants.THROWING_IO_SUPPLIER));
+        assertEquals("new1", Uncheck.get(() -> TestUtils.compareAndSetThrowsIO(ref1, "new1")));
+        assertEquals("new1", ref1.get());
+    }
+
+    @Test
+    public void testRun() {
+        assertThrows(UncheckedIOException.class, () -> Uncheck.run(() -> {
+            throw new IOException();
+        }));
+        assertThrows(UncheckedIOException.class, () -> Uncheck.run(TestConstants.THROWING_IO_RUNNABLE));
+        Uncheck.run(() -> TestUtils.compareAndSetThrowsIO(ref1, "new1"));
+        assertEquals("new1", ref1.get());
+    }
+
+    @Test
+    public void testTest() {
+        assertThrows(UncheckedIOException.class, () -> Uncheck.test(t -> {
+            throw new IOException();
+        }, null));
+        assertThrows(UncheckedIOException.class, () -> Uncheck.test(TestConstants.THROWING_IO_PREDICATE, null));
+        assertTrue(Uncheck.test(t -> TestUtils.compareAndSetThrowsIO(ref1, t).equals(t), "new1"));
+        assertEquals("new1", ref1.get());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/AbstractInputStreamTest.java b/src/test/java/org/apache/commons/io/input/AbstractInputStreamTest.java
new file mode 100644
index 0000000..b868478
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/AbstractInputStreamTest.java
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.RandomUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests functionality of {@link BufferedFileChannelInputStream}.
+ * <p>
+ * This class was ported and adapted from Apache Spark commit 933dc6cb7b3de1d8ccaf73d124d6eb95b947ed19 where it was
+ * called {@code GenericFileInputStreamSuite}.
+ * </p>
+ */
+public abstract class AbstractInputStreamTest {
+
+    private byte[] randomBytes;
+
+    protected Path inputFile;
+
+    protected InputStream[] inputStreams;
+
+    @BeforeEach
+    public void setUp() throws IOException {
+        // Create a byte array of size 2 MB with random bytes
+        randomBytes = RandomUtils.nextBytes(2 * 1024 * 1024);
+        inputFile = Files.createTempFile("temp-file", ".tmp");
+        Files.write(inputFile, randomBytes);
+    }
+
+    @AfterEach
+    public void tearDown() throws IOException {
+        Files.delete(inputFile);
+        IOUtils.close(inputStreams);
+    }
+
+    @Test
+    public void testBytesSkipped() throws IOException {
+        for (final InputStream inputStream : inputStreams) {
+            assertEquals(1024, inputStream.skip(1024));
+            for (int i = 1024; i < randomBytes.length; i++) {
+                assertEquals(randomBytes[i], (byte) inputStream.read());
+            }
+        }
+    }
+
+    @Test
+    public void testBytesSkippedAfterEOF() throws IOException {
+        for (final InputStream inputStream : inputStreams) {
+            assertEquals(randomBytes.length, inputStream.skip(randomBytes.length + 1));
+            assertEquals(-1, inputStream.read());
+        }
+    }
+
+    @Test
+    public void testBytesSkippedAfterRead() throws IOException {
+        for (final InputStream inputStream : inputStreams) {
+            for (int i = 0; i < 1024; i++) {
+                assertEquals(randomBytes[i], (byte) inputStream.read());
+            }
+            assertEquals(1024, inputStream.skip(1024));
+            for (int i = 2048; i < randomBytes.length; i++) {
+                assertEquals(randomBytes[i], (byte) inputStream.read());
+            }
+        }
+    }
+
+    @Test
+    public void testNegativeBytesSkippedAfterRead() throws IOException {
+        for (final InputStream inputStream : inputStreams) {
+            for (int i = 0; i < 1024; i++) {
+                assertEquals(randomBytes[i], (byte) inputStream.read());
+            }
+            // Skipping negative bytes should essential be a no-op
+            assertEquals(0, inputStream.skip(-1));
+            assertEquals(0, inputStream.skip(-1024));
+            assertEquals(0, inputStream.skip(Long.MIN_VALUE));
+            assertEquals(1024, inputStream.skip(1024));
+            for (int i = 2048; i < randomBytes.length; i++) {
+                assertEquals(randomBytes[i], (byte) inputStream.read());
+            }
+        }
+    }
+
+    @Test
+    public void testReadMultipleBytes() throws IOException {
+        for (final InputStream inputStream : inputStreams) {
+            final byte[] readBytes = new byte[8 * 1024];
+            int i = 0;
+            while (i < randomBytes.length) {
+                final int read = inputStream.read(readBytes, 0, 8 * 1024);
+                for (int j = 0; j < read; j++) {
+                    assertEquals(randomBytes[i], readBytes[j]);
+                    i++;
+                }
+            }
+        }
+    }
+
+    @Test
+    public void testReadOneByte() throws IOException {
+        for (final InputStream inputStream : inputStreams) {
+            for (final byte randomByte : randomBytes) {
+                assertEquals(randomByte, (byte) inputStream.read());
+            }
+        }
+    }
+
+    @Test
+    public void testReadPastEOF() throws IOException {
+        final InputStream is = inputStreams[0];
+        final byte[] buf = new byte[1024];
+        int read;
+        while ((read = is.read(buf, 0, buf.length)) != -1) {
+            // empty
+        }
+
+        final int readAfterEOF = is.read(buf, 0, buf.length);
+        assertEquals(-1, readAfterEOF);
+    }
+
+    @Test
+    public void testSkipFromFileChannel() throws IOException {
+        for (final InputStream inputStream : inputStreams) {
+            // Since the buffer is smaller than the skipped bytes, this will guarantee
+            // we skip from underlying file channel.
+            assertEquals(1024, inputStream.skip(1024));
+            for (int i = 1024; i < 2048; i++) {
+                assertEquals(randomBytes[i], (byte) inputStream.read());
+            }
+            assertEquals(256, inputStream.skip(256));
+            assertEquals(256, inputStream.skip(256));
+            assertEquals(512, inputStream.skip(512));
+            for (int i = 3072; i < randomBytes.length; i++) {
+                assertEquals(randomBytes[i], (byte) inputStream.read());
+            }
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/AutoCloseInputStreamTest.java b/src/test/java/org/apache/commons/io/input/AutoCloseInputStreamTest.java
new file mode 100644
index 0000000..455db05
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/AutoCloseInputStreamTest.java
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link AutoCloseInputStream}.
+ */
+public class AutoCloseInputStreamTest {
+
+    private byte[] data;
+
+    private AutoCloseInputStream stream;
+
+    private boolean closed;
+
+    @BeforeEach
+    public void setUp() {
+        data = new byte[] {'x', 'y', 'z'};
+        stream = new AutoCloseInputStream(new ByteArrayInputStream(data) {
+            @Override
+            public void close() {
+                closed = true;
+            }
+        });
+        closed = false;
+    }
+
+    @Test
+    public void testClose() throws IOException {
+        stream.close();
+        assertTrue(closed, "closed");
+        assertEquals(-1, stream.read(), "read()");
+    }
+
+    @Test
+    public void testFinalize() throws Throwable {
+        stream.finalize();
+        assertTrue(closed, "closed");
+        assertEquals(-1, stream.read(), "read()");
+    }
+
+    @Test
+    public void testRead() throws IOException {
+        for (final byte element : data) {
+            assertEquals(element, stream.read(), "read()");
+            assertFalse(closed, "closed");
+        }
+        assertEquals(-1, stream.read(), "read()");
+        assertTrue(closed, "closed");
+    }
+
+    @Test
+    public void testReadBuffer() throws IOException {
+        final byte[] b = new byte[data.length * 2];
+        int total = 0;
+        for (int n = 0; n != -1; n = stream.read(b)) {
+            assertFalse(closed, "closed");
+            for (int i = 0; i < n; i++) {
+                assertEquals(data[total + i], b[i], "read(b)");
+            }
+            total += n;
+        }
+        assertEquals(data.length, total, "read(b)");
+        assertTrue(closed, "closed");
+        assertEquals(-1, stream.read(b), "read(b)");
+    }
+
+    @Test
+    public void testReadBufferOffsetLength() throws IOException {
+        final byte[] b = new byte[data.length * 2];
+        int total = 0;
+        for (int n = 0; n != -1; n = stream.read(b, total, b.length - total)) {
+            assertFalse(closed, "closed");
+            total += n;
+        }
+        assertEquals(data.length, total, "read(b, off, len)");
+        for (int i = 0; i < data.length; i++) {
+            assertEquals(data[i], b[i], "read(b, off, len)");
+        }
+        assertTrue(closed, "closed");
+        assertEquals(-1, stream.read(b, 0, b.length), "read(b, off, len)");
+    }
+
+    @Test
+    public void testResetBeforeEnd() throws IOException {
+        final String inputStr = "1234";
+        final AutoCloseInputStream inputStream = new AutoCloseInputStream(new ByteArrayInputStream(inputStr.getBytes()));
+        inputStream.mark(1);
+        assertEquals('1', inputStream.read());
+        inputStream.reset();
+        assertEquals('1', inputStream.read());
+        assertEquals('2', inputStream.read());
+        inputStream.reset();
+        assertEquals('1', inputStream.read());
+        assertEquals('2', inputStream.read());
+        assertEquals('3', inputStream.read());
+        inputStream.reset();
+        assertEquals('1', inputStream.read());
+        assertEquals('2', inputStream.read());
+        assertEquals('3', inputStream.read());
+        assertEquals('4', inputStream.read());
+        inputStream.reset();
+        assertEquals('1', inputStream.read());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/BOMInputStreamTest.java b/src/test/java/org/apache/commons/io/input/BOMInputStreamTest.java
new file mode 100644
index 0000000..cc79dc5
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/BOMInputStreamTest.java
@@ -0,0 +1,749 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeFalse;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Reader;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+
+import org.apache.commons.io.ByteOrderMark;
+import org.junit.jupiter.api.Test;
+import org.w3c.dom.Document;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+import org.xml.sax.SAXParseException;
+
+/**
+ * Test case for {@link BOMInputStream}.
+ *
+ */
+@SuppressWarnings("ResultOfMethodCallIgnored")
+public class BOMInputStreamTest {
+
+    /**
+     *  A mock InputStream that expects {@code close()} to be called.
+     */
+    private static class ExpectCloseInputStream extends InputStream {
+        private boolean _closeCalled;
+
+        public void assertCloseCalled() {
+            assertTrue(_closeCalled);
+        }
+
+        @Override
+        public void close() throws IOException {
+            _closeCalled = true;
+        }
+
+        @Override
+        public int read() throws IOException {
+            return -1;
+        }
+    }
+
+    private void assertData(final byte[] expected, final byte[] actual, final int len) {
+        assertEquals(expected.length, len, "length");
+        for (int ii = 0; ii < expected.length; ii++) {
+            assertEquals(expected[ii], actual[ii], "byte " + ii);
+        }
+    }
+
+    /**
+     *  Creates the underlying data stream, with or without BOM.
+     */
+    private InputStream createUtf16BeDataStream(final byte[] baseData, final boolean addBOM) {
+        byte[] data = baseData;
+        if (addBOM) {
+            data = new byte[baseData.length + 2];
+            data[0] = (byte) 0xFE;
+            data[1] = (byte) 0xFF;
+            System.arraycopy(baseData, 0, data, 2, baseData.length);
+        }
+        return new ByteArrayInputStream(data);
+    }
+
+    /**
+     *  Creates the underlying data stream, with or without BOM.
+     */
+    private InputStream createUtf16LeDataStream(final byte[] baseData, final boolean addBOM) {
+        byte[] data = baseData;
+        if (addBOM) {
+            data = new byte[baseData.length + 2];
+            data[0] = (byte) 0xFF;
+            data[1] = (byte) 0xFE;
+            System.arraycopy(baseData, 0, data, 2, baseData.length);
+        }
+        return new ByteArrayInputStream(data);
+    }
+
+    /**
+     *  Creates the underlying data stream, with or without BOM.
+     */
+    private InputStream createUtf32BeDataStream(final byte[] baseData, final boolean addBOM) {
+        byte[] data = baseData;
+        if (addBOM) {
+            data = new byte[baseData.length + 4];
+            data[0] = 0;
+            data[1] = 0;
+            data[2] = (byte) 0xFE;
+            data[3] = (byte) 0xFF;
+            System.arraycopy(baseData, 0, data, 4, baseData.length);
+        }
+        return new ByteArrayInputStream(data);
+    }
+
+    /**
+     *  Creates the underlying data stream, with or without BOM.
+     */
+    private InputStream createUtf32LeDataStream(final byte[] baseData, final boolean addBOM) {
+        byte[] data = baseData;
+        if (addBOM) {
+            data = new byte[baseData.length + 4];
+            data[0] = (byte) 0xFF;
+            data[1] = (byte) 0xFE;
+            data[2] = 0;
+            data[3] = 0;
+            System.arraycopy(baseData, 0, data, 4, baseData.length);
+        }
+        return new ByteArrayInputStream(data);
+    }
+
+    /**
+     *  Creates the underlying data stream, with or without BOM.
+     */
+    private InputStream createUtf8DataStream(final byte[] baseData, final boolean addBOM) {
+        byte[] data = baseData;
+        if (addBOM) {
+            data = new byte[baseData.length + 3];
+            data[0] = (byte) 0xEF;
+            data[1] = (byte) 0xBB;
+            data[2] = (byte) 0xBF;
+            System.arraycopy(baseData, 0, data, 3, baseData.length);
+        }
+        return new ByteArrayInputStream(data);
+    }
+
+    private boolean doesSaxSupportCharacterSet(final String charSetName) throws ParserConfigurationException, SAXException, IOException {
+        final DocumentBuilder documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
+        try (StringInputStream byteStream = new StringInputStream("<?xml version=\"1.0\" encoding=\"" + charSetName + "\"?><Z/>", charSetName)) {
+            final InputSource is = new InputSource(byteStream);
+            is.setEncoding(charSetName);
+            documentBuilder.parse(is);
+        } catch (final SAXParseException e) {
+            if (e.getMessage().contains(charSetName)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private boolean jvmAndSaxBothSupportCharset(final String charSetName) throws ParserConfigurationException, SAXException, IOException {
+        return Charset.isSupported(charSetName) &&  doesSaxSupportCharacterSet(charSetName);
+    }
+
+    private void parseXml(final InputStream in) throws SAXException, IOException, ParserConfigurationException {
+        final Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new InputSource(in));
+        assertNotNull(doc);
+        assertEquals("X", doc.getFirstChild().getNodeName());
+    }
+
+    private void parseXml(final Reader in) throws SAXException, IOException, ParserConfigurationException {
+        final Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new InputSource(in));
+        assertNotNull(doc);
+        assertEquals("X", doc.getFirstChild().getNodeName());
+    }
+
+    private void readBOMInputStreamTwice(final String resource) throws Exception {
+        try (InputStream inputStream = this.getClass().getResourceAsStream(resource)) {
+            assertNotNull(inputStream);
+            try (BOMInputStream bomInputStream = new BOMInputStream(inputStream)) {
+                bomInputStream.mark(1000000);
+
+                this.readFile(bomInputStream);
+                bomInputStream.reset();
+                this.readFile(bomInputStream);
+                inputStream.close();
+            }
+        }
+    }
+
+    private void readFile(final BOMInputStream bomInputStream) throws Exception {
+        int bytes;
+        final byte[] bytesFromStream = new byte[100];
+        do {
+            bytes = bomInputStream.read(bytesFromStream);
+        } while (bytes > 0);
+    }
+
+    @Test
+    public void skipReturnValueWithBom() throws IOException {
+        final byte[] baseData = { (byte) 0x31, (byte) 0x32, (byte) 0x33 };
+        try (BOMInputStream is1 = new BOMInputStream(createUtf8DataStream(baseData, true))) {
+            assertEquals(2, is1.skip(2));
+            assertEquals((byte) 0x33, is1.read());
+        }
+    }
+
+    @Test
+    public void skipReturnValueWithoutBom() throws IOException {
+        final byte[] baseData = { (byte) 0x31, (byte) 0x32, (byte) 0x33 };
+        try (BOMInputStream is2 = new BOMInputStream(createUtf8DataStream(baseData, false))) {
+            assertEquals(2, is2.skip(2)); // IO-428
+            assertEquals((byte) 0x33, is2.read());
+        }
+    }
+
+    @Test
+    public void testAvailableWithBOM() throws Exception {
+        final byte[] data = { 'A', 'B', 'C', 'D' };
+        try (InputStream in = new BOMInputStream(createUtf8DataStream(data, true))) {
+            assertEquals(7, in.available());
+        }
+    }
+
+    @Test
+    public void testAvailableWithoutBOM() throws Exception {
+        final byte[] data = { 'A', 'B', 'C', 'D' };
+        try (InputStream in = new BOMInputStream(createUtf8DataStream(data, false))) {
+            assertEquals(4, in.available());
+        }
+    }
+
+    @Test
+    // this is here for coverage
+    public void testClose() throws Exception {
+        try (ExpectCloseInputStream del = new ExpectCloseInputStream()) {
+            try (InputStream in = new BOMInputStream(del)) {
+                // nothing
+            }
+            del.assertCloseCalled();
+        }
+    }
+
+    @Test
+    public void testEmptyBufferWithBOM() throws Exception {
+        final byte[] data = {};
+        try (InputStream in = new BOMInputStream(createUtf8DataStream(data, true))) {
+            final byte[] buf = new byte[1024];
+            assertEquals(-1, in.read(buf));
+        }
+    }
+
+    @Test
+    public void testEmptyBufferWithoutBOM() throws Exception {
+        final byte[] data = {};
+        try (InputStream in = new BOMInputStream(createUtf8DataStream(data, false))) {
+            final byte[] buf = new byte[1024];
+            assertEquals(-1, in.read(buf));
+        }
+    }
+
+    @Test
+    public void testGetBOMFirstThenRead() throws Exception {
+        final byte[] data = { 'A', 'B', 'C' };
+        try (BOMInputStream in = new BOMInputStream(createUtf8DataStream(data, true))) {
+            assertEquals(ByteOrderMark.UTF_8, in.getBOM(), "getBOM");
+            assertTrue(in.hasBOM(), "hasBOM()");
+            assertTrue(in.hasBOM(ByteOrderMark.UTF_8), "hasBOM(UTF-8)");
+            assertEquals('A', in.read());
+            assertEquals('B', in.read());
+            assertEquals('C', in.read());
+            assertEquals(-1, in.read());
+        }
+    }
+
+    @Test
+    public void testGetBOMFirstThenReadInclude() throws Exception {
+        final byte[] data = { 'A', 'B', 'C' };
+        try (BOMInputStream in = new BOMInputStream(createUtf8DataStream(data, true), true)) {
+            assertTrue(in.hasBOM(), "hasBOM()");
+            assertTrue(in.hasBOM(ByteOrderMark.UTF_8), "hasBOM(UTF-8)");
+            assertEquals(ByteOrderMark.UTF_8, in.getBOM(), "getBOM");
+            assertEquals(0xEF, in.read());
+            assertEquals(0xBB, in.read());
+            assertEquals(0xBF, in.read());
+            assertEquals('A', in.read());
+            assertEquals('B', in.read());
+            assertEquals('C', in.read());
+            assertEquals(-1, in.read());
+        }
+    }
+
+    @Test
+    public void testLargeBufferWithBOM() throws Exception {
+        final byte[] data = { 'A', 'B', 'C' };
+        try (InputStream in = new BOMInputStream(createUtf8DataStream(data, true))) {
+            final byte[] buf = new byte[1024];
+            assertData(data, buf, in.read(buf));
+        }
+    }
+
+    @Test
+    public void testLargeBufferWithoutBOM() throws Exception {
+        final byte[] data = { 'A', 'B', 'C' };
+        try (InputStream in = new BOMInputStream(createUtf8DataStream(data, false))) {
+            final byte[] buf = new byte[1024];
+            assertData(data, buf, in.read(buf));
+        }
+    }
+
+    @Test
+    public void testLeadingNonBOMBufferedRead() throws Exception {
+        final byte[] data = { (byte) 0xEF, (byte) 0xAB, (byte) 0xCD };
+        try (InputStream in = new BOMInputStream(createUtf8DataStream(data, false))) {
+            final byte[] buf = new byte[1024];
+            assertData(data, buf, in.read(buf));
+        }
+    }
+
+    @Test
+    public void testLeadingNonBOMSingleRead() throws Exception {
+        final byte[] data = { (byte) 0xEF, (byte) 0xAB, (byte) 0xCD };
+        try (InputStream in = new BOMInputStream(createUtf8DataStream(data, false))) {
+            assertEquals(0xEF, in.read());
+            assertEquals(0xAB, in.read());
+            assertEquals(0xCD, in.read());
+            assertEquals(-1, in.read());
+        }
+    }
+
+    @Test
+    public void testMarkResetAfterReadWithBOM() throws Exception {
+        final byte[] data = { 'A', 'B', 'C', 'D' };
+        try (InputStream in = new BOMInputStream(createUtf8DataStream(data, true))) {
+            assertTrue(in.markSupported());
+
+            in.read();
+            in.mark(10);
+
+            in.read();
+            in.read();
+            in.reset();
+            assertEquals('B', in.read());
+        }
+    }
+
+    @Test
+    public void testMarkResetAfterReadWithoutBOM() throws Exception {
+        final byte[] data = { 'A', 'B', 'C', 'D' };
+        try (InputStream in = new BOMInputStream(createUtf8DataStream(data, false))) {
+            assertTrue(in.markSupported());
+
+            in.read();
+            in.mark(10);
+
+            in.read();
+            in.read();
+            in.reset();
+            assertEquals('B', in.read());
+        }
+    }
+
+    @Test
+    public void testMarkResetBeforeReadWithBOM() throws Exception {
+        final byte[] data = { 'A', 'B', 'C', 'D' };
+        try (InputStream in = new BOMInputStream(createUtf8DataStream(data, true))) {
+            assertTrue(in.markSupported());
+
+            in.mark(10);
+
+            in.read();
+            in.read();
+            in.reset();
+            assertEquals('A', in.read());
+        }
+    }
+
+    @Test
+    public void testMarkResetBeforeReadWithoutBOM() throws Exception {
+        final byte[] data = { 'A', 'B', 'C', 'D' };
+        try (InputStream in = new BOMInputStream(createUtf8DataStream(data, false))) {
+            assertTrue(in.markSupported());
+
+            in.mark(10);
+
+            in.read();
+            in.read();
+            in.reset();
+            assertEquals('A', in.read());
+        }
+    }
+
+    @Test
+    public void testNoBoms() throws Exception {
+        final byte[] data = { 'A', 'B', 'C' };
+        assertThrows(IllegalArgumentException.class, () -> new BOMInputStream(createUtf8DataStream(data, true), false, (ByteOrderMark[])null).close());
+        assertThrows(IllegalArgumentException.class, () -> new BOMInputStream(createUtf8DataStream(data, true), false, new ByteOrderMark[0]).close());
+    }
+
+    @Test
+    public void testReadEmpty() throws Exception {
+        final byte[] data = {};
+        try (BOMInputStream in = new BOMInputStream(createUtf8DataStream(data, false))) {
+            assertEquals(-1, in.read());
+            assertFalse(in.hasBOM(), "hasBOM()");
+            assertFalse(in.hasBOM(ByteOrderMark.UTF_8), "hasBOM(UTF-8)");
+            assertNull(in.getBOM(), "getBOM");
+        }
+    }
+
+    @Test
+    public void testReadSmall() throws Exception {
+        final byte[] data = { 'A', 'B' };
+        try (BOMInputStream in = new BOMInputStream(createUtf8DataStream(data, false))) {
+            assertEquals('A', in.read());
+            assertEquals('B', in.read());
+            assertEquals(-1, in.read());
+            assertFalse(in.hasBOM(), "hasBOM()");
+            assertFalse(in.hasBOM(ByteOrderMark.UTF_8), "hasBOM(UTF-8)");
+            assertNull(in.getBOM(), "getBOM");
+        }
+    }
+
+    @Test
+    public void testReadTwiceWithBOM() throws Exception {
+        this.readBOMInputStreamTwice("/org/apache/commons/io/testfileBOM.xml");
+    }
+
+    @Test
+    public void testReadTwiceWithoutBOM() throws Exception {
+        this.readBOMInputStreamTwice("/org/apache/commons/io/testfileNoBOM.xml");
+    }
+
+    @Test
+    public void testReadWithBOMInclude() throws Exception {
+        final byte[] data = { 'A', 'B', 'C' };
+        try (BOMInputStream in = new BOMInputStream(createUtf8DataStream(data, true), true)) {
+            assertEquals(0xEF, in.read());
+            assertEquals(0xBB, in.read());
+            assertEquals(0xBF, in.read());
+            assertEquals('A', in.read());
+            assertEquals('B', in.read());
+            assertEquals('C', in.read());
+            assertEquals(-1, in.read());
+            assertTrue(in.hasBOM(), "hasBOM()");
+            assertTrue(in.hasBOM(ByteOrderMark.UTF_8), "hasBOM(UTF-8)");
+            assertEquals(ByteOrderMark.UTF_8, in.getBOM(), "getBOM");
+        }
+    }
+
+    @Test
+    public void testReadWithBOMUtf16Be() throws Exception {
+        final byte[] data = "ABC".getBytes(StandardCharsets.UTF_16BE);
+        try (BOMInputStream in = new BOMInputStream(createUtf16BeDataStream(data, true), ByteOrderMark.UTF_16BE)) {
+            assertEquals(0, in.read());
+            assertEquals('A', in.read());
+            assertEquals(0, in.read());
+            assertEquals('B', in.read());
+            assertEquals(0, in.read());
+            assertEquals('C', in.read());
+            assertEquals(-1, in.read());
+            assertTrue(in.hasBOM(), "hasBOM()");
+            assertTrue(in.hasBOM(ByteOrderMark.UTF_16BE), "hasBOM(UTF-16BE)");
+            assertEquals(ByteOrderMark.UTF_16BE, in.getBOM(), "getBOM");
+            assertThrows(IllegalArgumentException.class, () -> in.hasBOM(ByteOrderMark.UTF_16LE));
+        }
+    }
+
+    @Test
+    public void testReadWithBOMUtf16Le() throws Exception {
+        final byte[] data = "ABC".getBytes(StandardCharsets.UTF_16LE);
+        try (BOMInputStream in = new BOMInputStream(createUtf16LeDataStream(data, true), ByteOrderMark.UTF_16LE)) {
+            assertEquals('A', in.read());
+            assertEquals(0, in.read());
+            assertEquals('B', in.read());
+            assertEquals(0, in.read());
+            assertEquals('C', in.read());
+            assertEquals(0, in.read());
+            assertEquals(-1, in.read());
+            assertTrue(in.hasBOM(), "hasBOM()");
+            assertTrue(in.hasBOM(ByteOrderMark.UTF_16LE), "hasBOM(UTF-16LE)");
+            assertEquals(ByteOrderMark.UTF_16LE, in.getBOM(), "getBOM");
+            assertThrows(IllegalArgumentException.class, () -> in.hasBOM(ByteOrderMark.UTF_16BE));
+        }
+    }
+
+    @Test
+    public void testReadWithBOMUtf32Be() throws Exception {
+        assumeTrue(Charset.isSupported("UTF_32BE"));
+        final byte[] data = "ABC".getBytes("UTF_32BE");
+        try (BOMInputStream in = new BOMInputStream(createUtf32BeDataStream(data, true),
+                ByteOrderMark.UTF_32BE)) {
+            assertEquals(0, in.read());
+            assertEquals(0, in.read());
+            assertEquals(0, in.read());
+            assertEquals('A', in.read());
+            assertEquals(0, in.read());
+            assertEquals(0, in.read());
+            assertEquals(0, in.read());
+            assertEquals('B', in.read());
+            assertEquals(0, in.read());
+            assertEquals(0, in.read());
+            assertEquals(0, in.read());
+            assertEquals('C', in.read());
+            assertEquals(-1, in.read());
+            assertTrue(in.hasBOM(), "hasBOM()");
+            assertTrue(in.hasBOM(ByteOrderMark.UTF_32BE), "hasBOM(UTF-32BE)");
+            assertEquals(ByteOrderMark.UTF_32BE, in.getBOM(), "getBOM");
+            assertThrows(IllegalArgumentException.class, () -> in.hasBOM(ByteOrderMark.UTF_32LE));
+        }
+    }
+
+    @Test
+    public void testReadWithBOMUtf32Le() throws Exception {
+        assumeTrue(Charset.isSupported("UTF_32LE"));
+        final byte[] data = "ABC".getBytes("UTF_32LE");
+        try (BOMInputStream in = new BOMInputStream(createUtf32LeDataStream(data, true),
+                ByteOrderMark.UTF_32LE)) {
+            assertEquals('A', in.read());
+            assertEquals(0, in.read());
+            assertEquals(0, in.read());
+            assertEquals(0, in.read());
+            assertEquals('B', in.read());
+            assertEquals(0, in.read());
+            assertEquals(0, in.read());
+            assertEquals(0, in.read());
+            assertEquals('C', in.read());
+            assertEquals(0, in.read());
+            assertEquals(0, in.read());
+            assertEquals(0, in.read());
+            assertEquals(-1, in.read());
+            assertTrue(in.hasBOM(), "hasBOM()");
+            assertTrue(in.hasBOM(ByteOrderMark.UTF_32LE), "hasBOM(UTF-32LE)");
+            assertEquals(ByteOrderMark.UTF_32LE, in.getBOM(), "getBOM");
+            assertThrows(IllegalArgumentException.class, () -> in.hasBOM(ByteOrderMark.UTF_32BE));
+        }
+    }
+
+    @Test
+    public void testReadWithBOMUtf8() throws Exception {
+        final byte[] data = "ABC".getBytes(StandardCharsets.UTF_8);
+        try (BOMInputStream in = new BOMInputStream(createUtf8DataStream(data, true), ByteOrderMark.UTF_8)) {
+            assertEquals('A', in.read());
+            assertEquals('B', in.read());
+            assertEquals('C', in.read());
+            assertEquals(-1, in.read());
+            assertTrue(in.hasBOM(), "hasBOM()");
+            assertTrue(in.hasBOM(ByteOrderMark.UTF_8), "hasBOM(UTF-8)");
+            assertEquals(ByteOrderMark.UTF_8, in.getBOM(), "getBOM");
+            assertThrows(IllegalArgumentException.class, () -> in.hasBOM(ByteOrderMark.UTF_16BE));
+        }
+    }
+
+    @Test
+    public void testReadWithMultipleBOM() throws Exception {
+        final byte[] data = { 'A', 'B', 'C' };
+        try (BOMInputStream in = new BOMInputStream(createUtf8DataStream(data, true), ByteOrderMark.UTF_16BE,
+                ByteOrderMark.UTF_8)) {
+            assertEquals('A', in.read());
+            assertEquals('B', in.read());
+            assertEquals('C', in.read());
+            assertEquals(-1, in.read());
+            assertTrue(in.hasBOM(), "hasBOM()");
+            assertTrue(in.hasBOM(ByteOrderMark.UTF_8), "hasBOM(UTF-8)");
+            assertFalse(in.hasBOM(ByteOrderMark.UTF_16BE), "hasBOM(UTF-16BE)");
+            assertEquals(ByteOrderMark.UTF_8, in.getBOM(), "getBOM");
+        }
+    }
+
+    @Test
+    public void testReadWithoutBOM() throws Exception {
+        final byte[] data = { 'A', 'B', 'C' };
+        try (BOMInputStream in = new BOMInputStream(createUtf8DataStream(data, false))) {
+            assertEquals('A', in.read());
+            assertEquals('B', in.read());
+            assertEquals('C', in.read());
+            assertEquals(-1, in.read());
+            assertFalse(in.hasBOM(), "hasBOM()");
+            assertFalse(in.hasBOM(ByteOrderMark.UTF_8), "hasBOM(UTF-8)");
+            assertNull(in.getBOM(), "getBOM");
+        }
+    }
+
+    @Test
+    public void testReadXmlWithBOMUcs2() throws Exception {
+        assumeFalse(System.getProperty("java.vendor").contains("IBM"), "This test does not pass on some IBM VMs xml parsers");
+
+        // UCS-2 is BE.
+        assumeTrue(Charset.isSupported("ISO-10646-UCS-2"));
+        final byte[] data = "<?xml version=\"1.0\" encoding=\"ISO-10646-UCS-2\"?><X/>".getBytes("ISO-10646-UCS-2");
+        try (BOMInputStream in = new BOMInputStream(createUtf16BeDataStream(data, true), ByteOrderMark.UTF_16BE)) {
+            parseXml(in);
+        }
+        parseXml(createUtf16BeDataStream(data, true));
+    }
+
+    @Test
+    public void testReadXmlWithBOMUcs4() throws Exception {
+        // UCS-4 is BE or LE?
+        // Hm: ISO-10646-UCS-4 is not supported on Oracle 1.6.0_31
+        assumeTrue(Charset.isSupported("ISO-10646-UCS-4"));
+        final byte[] data = "<?xml version=\"1.0\" encoding=\"ISO-10646-UCS-4\"?><X/>".getBytes("ISO-10646-UCS-4");
+        // XML parser does not know what to do with UTF-32
+        try (BOMInputStream in = new BOMInputStream(createUtf32BeDataStream(data, true), ByteOrderMark.UTF_32BE)) {
+            parseXml(in);
+            // XML parser does not know what to do with UTF-32
+            assumeTrue(jvmAndSaxBothSupportCharset("UTF_32LE"), "JVM and SAX need to support UTF_32LE for this");
+        }
+        parseXml(createUtf32BeDataStream(data, true));
+    }
+
+    @Test
+    public void testReadXmlWithBOMUtf16Be() throws Exception {
+        final byte[] data = "<?xml version=\"1.0\" encoding=\"UTF-16BE\"?><X/>".getBytes(StandardCharsets.UTF_16BE);
+        try (BOMInputStream in = new BOMInputStream(createUtf16BeDataStream(data, true), ByteOrderMark.UTF_16BE)) {
+            parseXml(in);
+        }
+        parseXml(createUtf16BeDataStream(data, true));
+    }
+
+    @Test
+    public void testReadXmlWithBOMUtf16Le() throws Exception {
+        final byte[] data = "<?xml version=\"1.0\" encoding=\"UTF-16LE\"?><X/>".getBytes(StandardCharsets.UTF_16LE);
+        try (BOMInputStream in = new BOMInputStream(createUtf16LeDataStream(data, true), ByteOrderMark.UTF_16LE)) {
+            parseXml(in);
+        }
+        parseXml(createUtf16LeDataStream(data, true));
+    }
+
+    @Test
+    public void testReadXmlWithBOMUtf32Be() throws Exception {
+        assumeTrue(jvmAndSaxBothSupportCharset("UTF_32BE"), "JVM and SAX need to support UTF_32BE for this");
+        final byte[] data = "<?xml version=\"1.0\" encoding=\"UTF-32BE\"?><X/>".getBytes("UTF_32BE");
+        try (BOMInputStream in = new BOMInputStream(createUtf32BeDataStream(data, true), ByteOrderMark.UTF_32BE)) {
+            parseXml(in);
+        }
+        // XML parser does not know what to do with UTF-32, so we warp the input stream with a XmlStreamReader
+        try (XmlStreamReader in = new XmlStreamReader(createUtf32BeDataStream(data, true))) {
+            parseXml(in);
+        }
+    }
+
+    @Test
+    public void testReadXmlWithBOMUtf32Le() throws Exception {
+        assumeTrue(jvmAndSaxBothSupportCharset("UTF_32LE"), "JVM and SAX need to support UTF_32LE for this");
+        final byte[] data = "<?xml version=\"1.0\" encoding=\"UTF-32LE\"?><X/>".getBytes("UTF_32LE");
+        try (BOMInputStream in = new BOMInputStream(createUtf32LeDataStream(data, true), ByteOrderMark.UTF_32LE)) {
+            parseXml(in);
+        }
+        // XML parser does not know what to do with UTF-32, so we warp the input stream with a XmlStreamReader
+        try (XmlStreamReader in = new XmlStreamReader(createUtf32LeDataStream(data, true))) {
+            parseXml(in);
+        }
+    }
+
+    @Test
+    public void testReadXmlWithBOMUtf8() throws Exception {
+        final byte[] data = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><X/>".getBytes(StandardCharsets.UTF_8);
+        try (BOMInputStream in = new BOMInputStream(createUtf8DataStream(data, true))) {
+            parseXml(in);
+        }
+        parseXml(createUtf8DataStream(data, true));
+    }
+
+
+    @Test
+    public void testReadXmlWithoutBOMUtf32Be() throws Exception {
+        assumeTrue(jvmAndSaxBothSupportCharset("UTF_32BE"), "JVM and SAX need to support UTF_32BE for this");
+        final byte[] data = "<?xml version=\"1.0\" encoding=\"UTF_32BE\"?><X/>".getBytes("UTF_32BE");
+        try (BOMInputStream in = new BOMInputStream(createUtf32BeDataStream(data, false))) {
+            parseXml(in);
+        }
+        parseXml(createUtf32BeDataStream(data, false));
+    }
+
+    @Test
+    public void testReadXmlWithoutBOMUtf32Le() throws Exception {
+        assumeTrue(jvmAndSaxBothSupportCharset("UTF_32LE"), "JVM and SAX need to support UTF_32LE for this");
+        final byte[] data = "<?xml version=\"1.0\" encoding=\"UTF-32LE\"?><X/>".getBytes("UTF_32LE");
+        try (BOMInputStream in = new BOMInputStream(createUtf32LeDataStream(data, false))) {
+            parseXml(in);
+        }
+        parseXml(createUtf32BeDataStream(data, false));
+    }
+
+    @Test
+    public void testSkipWithBOM() throws Exception {
+        final byte[] data = { 'A', 'B', 'C', 'D' };
+        try (InputStream in = new BOMInputStream(createUtf8DataStream(data, true))) {
+            in.skip(2L);
+            assertEquals('C', in.read());
+        }
+    }
+
+    @Test
+    public void testSkipWithoutBOM() throws Exception {
+        final byte[] data = { 'A', 'B', 'C', 'D' };
+        try (InputStream in = new BOMInputStream(createUtf8DataStream(data, false))) {
+            in.skip(2L);
+            assertEquals('C', in.read());
+        }
+    }
+
+    @Test
+    public void testSmallBufferWithBOM() throws Exception {
+        final byte[] data = { 'A', 'B', 'C' };
+        try (InputStream in = new BOMInputStream(createUtf8DataStream(data, true))) {
+            final byte[] buf = new byte[1024];
+            assertData(new byte[] { 'A', 'B' }, buf, in.read(buf, 0, 2));
+            assertData(new byte[] { 'C' }, buf, in.read(buf, 0, 2));
+        }
+    }
+
+    @Test
+    public void testSmallBufferWithoutBOM() throws Exception {
+        final byte[] data = { 'A', 'B', 'C' };
+        try (InputStream in = new BOMInputStream(createUtf8DataStream(data, false))) {
+            final byte[] buf = new byte[1024];
+            assertData(new byte[] { 'A', 'B' }, buf, in.read(buf, 0, 2));
+            assertData(new byte[] { 'C' }, buf, in.read(buf, 0, 2));
+        }
+    }
+
+    @Test
+    // make sure that our support code works as expected
+    public void testSupportCode() throws Exception {
+        try (InputStream in = createUtf8DataStream(new byte[] { 'A', 'B' }, true)) {
+            final byte[] buf = new byte[1024];
+            final int len = in.read(buf);
+            assertEquals(5, len);
+            assertEquals(0xEF, buf[0] & 0xFF);
+            assertEquals(0xBB, buf[1] & 0xFF);
+            assertEquals(0xBF, buf[2] & 0xFF);
+            assertEquals('A', buf[3] & 0xFF);
+            assertEquals('B', buf[4] & 0xFF);
+
+            assertData(new byte[] { (byte) 0xEF, (byte) 0xBB, (byte) 0xBF, 'A', 'B' }, buf, len);
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/BoundedInputStreamTest.java b/src/test/java/org/apache/commons/io/input/BoundedInputStreamTest.java
new file mode 100644
index 0000000..ebe409b
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/BoundedInputStreamTest.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayInputStream;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link BoundedInputStream}.
+ *
+ */
+public class BoundedInputStreamTest {
+
+    private void compare(final String msg, final byte[] expected, final byte[] actual) {
+        assertEquals(expected.length, actual.length, msg + " length");
+        for (int i = 0; i < expected.length; i++) {
+            assertEquals(expected[i], actual[i], msg + " byte[" + i + "]");
+        }
+    }
+
+    @Test
+    public void testReadArray() throws Exception {
+
+        BoundedInputStream bounded;
+        final byte[] helloWorld = "Hello World".getBytes();
+        final byte[] hello = "Hello".getBytes();
+
+        bounded = new BoundedInputStream(new ByteArrayInputStream(helloWorld));
+        compare("limit = -1", helloWorld, IOUtils.toByteArray(bounded));
+
+        bounded = new BoundedInputStream(new ByteArrayInputStream(helloWorld), 0);
+        compare("limit = 0", IOUtils.EMPTY_BYTE_ARRAY, IOUtils.toByteArray(bounded));
+
+        bounded = new BoundedInputStream(new ByteArrayInputStream(helloWorld), helloWorld.length);
+        compare("limit = length", helloWorld, IOUtils.toByteArray(bounded));
+
+        bounded = new BoundedInputStream(new ByteArrayInputStream(helloWorld), helloWorld.length + 1);
+        compare("limit > length", helloWorld, IOUtils.toByteArray(bounded));
+
+        bounded = new BoundedInputStream(new ByteArrayInputStream(helloWorld), helloWorld.length - 6);
+        compare("limit < length", hello, IOUtils.toByteArray(bounded));
+    }
+
+    @Test
+    public void testReadSingle() throws Exception {
+        BoundedInputStream bounded;
+        final byte[] helloWorld = "Hello World".getBytes();
+        final byte[] hello = "Hello".getBytes();
+
+        // limit = length
+        bounded = new BoundedInputStream(new ByteArrayInputStream(helloWorld), helloWorld.length);
+        for (int i = 0; i < helloWorld.length; i++) {
+            assertEquals(helloWorld[i], bounded.read(), "limit = length byte[" + i + "]");
+        }
+        assertEquals(-1, bounded.read(), "limit = length end");
+
+        // limit > length
+        bounded = new BoundedInputStream(new ByteArrayInputStream(helloWorld), helloWorld.length + 1);
+        for (int i = 0; i < helloWorld.length; i++) {
+            assertEquals(helloWorld[i], bounded.read(), "limit > length byte[" + i + "]");
+        }
+        assertEquals(-1, bounded.read(), "limit > length end");
+
+        // limit < length
+        bounded = new BoundedInputStream(new ByteArrayInputStream(helloWorld), hello.length);
+        for (int i = 0; i < hello.length; i++) {
+            assertEquals(hello[i], bounded.read(), "limit < length byte[" + i + "]");
+        }
+        assertEquals(-1, bounded.read(), "limit < length end");
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/BoundedReaderTest.java b/src/test/java/org/apache/commons/io/input/BoundedReaderTest.java
new file mode 100644
index 0000000..4120439
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/BoundedReaderTest.java
@@ -0,0 +1,245 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTimeout;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.LineNumberReader;
+import java.io.Reader;
+import java.io.StringReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.file.TempFile;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link BoundedReader}.
+ */
+public class BoundedReaderTest {
+
+    private static final Duration TIMEOUT = Duration.ofSeconds(10);
+
+    private static final String STRING_END_NO_EOL = "0\n1\n2";
+
+    private static final String STRING_END_EOL = "0\n1\n2\n";
+
+    private final Reader sr = new BufferedReader(new StringReader("01234567890"));
+
+    private final Reader shortReader = new BufferedReader(new StringReader("01"));
+
+    @Test
+    public void closeTest() throws IOException {
+        final AtomicBoolean closed = new AtomicBoolean(false);
+        try (Reader sr = new BufferedReader(new StringReader("01234567890")) {
+            @Override
+            public void close() throws IOException {
+                closed.set(true);
+                super.close();
+            }
+        }) {
+
+            try (BoundedReader mr = new BoundedReader(sr, 3)) {
+                // nothing
+            }
+        }
+        assertTrue(closed.get());
+    }
+
+    @Test
+    public void markReset() throws IOException {
+        try (BoundedReader mr = new BoundedReader(sr, 3)) {
+            mr.mark(3);
+            mr.read();
+            mr.read();
+            mr.read();
+            mr.reset();
+            mr.read();
+            mr.read();
+            mr.read();
+            assertEquals(-1, mr.read());
+        }
+    }
+
+    @Test
+    public void markResetFromOffset1() throws IOException {
+        try (BoundedReader mr = new BoundedReader(sr, 3)) {
+            mr.mark(3);
+            mr.read();
+            mr.read();
+            mr.read();
+            assertEquals(-1, mr.read());
+            mr.reset();
+            mr.mark(1);
+            mr.read();
+            assertEquals(-1, mr.read());
+        }
+    }
+
+    @Test
+    public void markResetMarkMore() throws IOException {
+        try (BoundedReader mr = new BoundedReader(sr, 3)) {
+            mr.mark(4);
+            mr.read();
+            mr.read();
+            mr.read();
+            mr.reset();
+            mr.read();
+            mr.read();
+            mr.read();
+            assertEquals(-1, mr.read());
+        }
+    }
+
+    @Test
+    public void markResetWithMarkOutsideBoundedReaderMax() throws IOException {
+        try (BoundedReader mr = new BoundedReader(sr, 3)) {
+            mr.mark(4);
+            mr.read();
+            mr.read();
+            mr.read();
+            assertEquals(-1, mr.read());
+        }
+    }
+
+    @Test
+    public void markResetWithMarkOutsideBoundedReaderMaxAndInitialOffset() throws IOException {
+        try (BoundedReader mr = new BoundedReader(sr, 3)) {
+            mr.read();
+            mr.mark(3);
+            mr.read();
+            mr.read();
+            assertEquals(-1, mr.read());
+        }
+    }
+
+    @Test
+    public void readMulti() throws IOException {
+        try (BoundedReader mr = new BoundedReader(sr, 3)) {
+            final char[] cbuf = new char[4];
+            Arrays.fill(cbuf, 'X');
+            final int read = mr.read(cbuf, 0, 4);
+            assertEquals(3, read);
+            assertEquals('0', cbuf[0]);
+            assertEquals('1', cbuf[1]);
+            assertEquals('2', cbuf[2]);
+            assertEquals('X', cbuf[3]);
+        }
+    }
+
+    @Test
+    public void readMultiWithOffset() throws IOException {
+        try (BoundedReader mr = new BoundedReader(sr, 3)) {
+            final char[] cbuf = new char[4];
+            Arrays.fill(cbuf, 'X');
+            final int read = mr.read(cbuf, 1, 2);
+            assertEquals(2, read);
+            assertEquals('X', cbuf[0]);
+            assertEquals('0', cbuf[1]);
+            assertEquals('1', cbuf[2]);
+            assertEquals('X', cbuf[3]);
+        }
+    }
+
+    @Test
+    public void readTillEnd() throws IOException {
+        try (BoundedReader mr = new BoundedReader(sr, 3)) {
+            mr.read();
+            mr.read();
+            mr.read();
+            assertEquals(-1, mr.read());
+        }
+    }
+
+    @Test
+    public void shortReader() throws IOException {
+        try (BoundedReader mr = new BoundedReader(shortReader, 3)) {
+            mr.read();
+            mr.read();
+            assertEquals(-1, mr.read());
+        }
+    }
+
+    @Test
+    public void skipTest() throws IOException {
+        try (BoundedReader mr = new BoundedReader(sr, 3)) {
+            mr.skip(2);
+            mr.read();
+            assertEquals(-1, mr.read());
+        }
+    }
+
+    private void testLineNumberReader(final Reader source) throws IOException {
+        try (LineNumberReader reader = new LineNumberReader(new BoundedReader(source, 10_000_000))) {
+            while (reader.readLine() != null) {
+                // noop
+            }
+        }
+    }
+
+    public void testLineNumberReaderAndFileReaderLastLine(final String data) throws IOException {
+        try (TempFile path = TempFile.create(getClass().getSimpleName(), ".txt")) {
+            final File file = path.toFile();
+            FileUtils.write(file, data, StandardCharsets.ISO_8859_1);
+            try (Reader source = Files.newBufferedReader(file.toPath())) {
+                testLineNumberReader(source);
+            }
+        }
+    }
+
+    @Test
+    public void testLineNumberReaderAndFileReaderLastLineEolNo() {
+        assertTimeout(TIMEOUT, () -> testLineNumberReaderAndFileReaderLastLine(STRING_END_NO_EOL));
+    }
+
+    @Test
+    public void testLineNumberReaderAndFileReaderLastLineEolYes() {
+        assertTimeout(TIMEOUT, () -> testLineNumberReaderAndFileReaderLastLine(STRING_END_EOL));
+    }
+
+    @Test
+    public void testLineNumberReaderAndStringReaderLastLineEolNo() {
+        assertTimeout(TIMEOUT, () -> testLineNumberReader(new StringReader(STRING_END_NO_EOL)));
+    }
+
+    @Test
+    public void testLineNumberReaderAndStringReaderLastLineEolYes() {
+        assertTimeout(TIMEOUT, () -> testLineNumberReader(new StringReader(STRING_END_EOL)));
+    }
+
+    @Test
+    public void testReadBytesEOF() {
+        assertTimeout(TIMEOUT, () -> {
+            final BoundedReader mr = new BoundedReader(sr, 3);
+            try (BufferedReader br = new BufferedReader(mr)) {
+                br.readLine();
+                br.readLine();
+            }
+        });
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/BrokenInputStreamTest.java b/src/test/java/org/apache/commons/io/input/BrokenInputStreamTest.java
new file mode 100644
index 0000000..82ad3f5
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/BrokenInputStreamTest.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link BrokenInputStream}.
+ */
+public class BrokenInputStreamTest {
+
+    private IOException exception;
+
+    private InputStream stream;
+
+    @BeforeEach
+    public void setUp() {
+        exception = new IOException("test exception");
+        stream = new BrokenInputStream(exception);
+    }
+
+    @Test
+    public void testAvailable() {
+        assertEquals(exception, assertThrows(IOException.class, () -> stream.available()));
+    }
+
+    @Test
+    public void testClose() {
+        assertEquals(exception, assertThrows(IOException.class, () -> stream.close()));
+    }
+
+    @Test
+    public void testRead() {
+        assertEquals(exception, assertThrows(IOException.class, () -> stream.read()));
+        assertEquals(exception, assertThrows(IOException.class, () -> stream.read(new byte[1])));
+        assertEquals(exception, assertThrows(IOException.class, () -> stream.read(new byte[1], 0, 1)));
+    }
+
+    @Test
+    public void testReset() {
+        assertEquals(exception, assertThrows(IOException.class, () -> stream.reset()));
+    }
+
+    @Test
+    public void testSkip() {
+        assertEquals(exception, assertThrows(IOException.class, () -> stream.skip(1)));
+    }
+
+    @Test
+    public void testTryWithResources() {
+        final IOException thrown = assertThrows(IOException.class, () -> {
+            try (InputStream newStream = new BrokenInputStream()) {
+                newStream.read();
+            }
+        });
+        assertEquals("Broken input stream", thrown.getMessage());
+
+        final Throwable[] suppressed = thrown.getSuppressed();
+        assertEquals(1, suppressed.length);
+        assertEquals(IOException.class, suppressed[0].getClass());
+        assertEquals("Broken input stream", suppressed[0].getMessage());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/BrokenReaderTest.java b/src/test/java/org/apache/commons/io/input/BrokenReaderTest.java
new file mode 100644
index 0000000..0bd9751
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/BrokenReaderTest.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.Reader;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link BrokenReader}.
+ */
+public class BrokenReaderTest {
+
+    private IOException exception;
+
+    private Reader brokenReader;
+
+    @BeforeEach
+    public void setUp() {
+        exception = new IOException("test exception");
+        brokenReader = new BrokenReader(exception);
+    }
+
+    @Test
+    public void testClose() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenReader.close()));
+    }
+
+    @Test
+    public void testInstance() {
+        assertNotNull(BrokenReader.INSTANCE);
+    }
+
+    @Test
+    public void testMark() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenReader.mark(1)));
+    }
+
+    @Test
+    public void testRead() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenReader.read()));
+    }
+
+    @Test
+    public void testReadCharArray() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenReader.read(new char[1])));
+    }
+
+    @Test
+    public void testReadCharArrayIndexed() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenReader.read(new char[1], 0, 1)));
+    }
+
+    @Test
+    public void testReady() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenReader.ready()));
+    }
+
+    @Test
+    public void testReset() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenReader.reset()));
+    }
+
+    @Test
+    public void testSkip() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenReader.skip(1)));
+    }
+
+    @Test
+    public void testTryWithResources() {
+        final IOException thrown = assertThrows(IOException.class, () -> {
+            try (Reader newReader = new BrokenReader()) {
+                newReader.read();
+            }
+        });
+        assertEquals("Broken reader", thrown.getMessage());
+
+        final Throwable[] suppressed = thrown.getSuppressed();
+        assertEquals(1, suppressed.length);
+        assertEquals(IOException.class, suppressed[0].getClass());
+        assertEquals("Broken reader", suppressed[0].getMessage());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/BufferedFileChannelInputStreamTest.java b/src/test/java/org/apache/commons/io/input/BufferedFileChannelInputStreamTest.java
new file mode 100644
index 0000000..90ab736
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/BufferedFileChannelInputStreamTest.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.junit.jupiter.api.BeforeEach;
+
+/**
+ * Tests functionality of {@link BufferedFileChannelInputStream}.
+ *
+ * This class was ported and adapted from Apache Spark commit 933dc6cb7b3de1d8ccaf73d124d6eb95b947ed19 where it was
+ * called {@code BufferedFileChannelInputStreamSuite}.
+ */
+public class BufferedFileChannelInputStreamTest extends AbstractInputStreamTest {
+
+    @SuppressWarnings("resource")
+    @Override
+    @BeforeEach
+    public void setUp() throws IOException {
+        super.setUp();
+        // @formatter:off
+        inputStreams = new InputStream[] {
+            new BufferedFileChannelInputStream(inputFile), // default
+            new BufferedFileChannelInputStream(inputFile, 123) // small, unaligned buffer
+        };
+        //@formatter:on
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/ByteBufferCleanerTest.java b/src/test/java/org/apache/commons/io/input/ByteBufferCleanerTest.java
new file mode 100644
index 0000000..c09d155
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/ByteBufferCleanerTest.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.ByteBuffer;
+
+import org.apache.commons.lang3.RandomUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@code ByteBufferCleaner}.
+ */
+public class ByteBufferCleanerTest {
+
+    @Test
+    void testCleanEmpty() {
+        final ByteBuffer buffer = ByteBuffer.allocateDirect(10);
+        // There is no way verify that the buffer has been cleaned up, we are just verifying that
+        // clean() doesn't blow up
+        ByteBufferCleaner.clean(buffer);
+    }
+
+    @Test
+    void testCleanFull() {
+        final ByteBuffer buffer = ByteBuffer.allocateDirect(10);
+        buffer.put(RandomUtils.nextBytes(10), 0, 10);
+        // There is no way verify that the buffer has been cleaned up, we are just verifying that
+        // clean() doesn't blow up
+        ByteBufferCleaner.clean(buffer);
+    }
+
+    @Test
+    void testSupported() {
+        assertTrue(ByteBufferCleaner.isSupported(), "ByteBufferCleaner does not work on this platform, please investigate and fix");
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/CharSequenceInputStreamTest.java b/src/test/java/org/apache/commons/io/input/CharSequenceInputStreamTest.java
new file mode 100644
index 0000000..e7f47b3
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/CharSequenceInputStreamTest.java
@@ -0,0 +1,442 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.Random;
+import java.util.Set;
+
+import org.apache.commons.io.Charsets;
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+public class CharSequenceInputStreamTest {
+
+    private static final String UTF_16 = StandardCharsets.UTF_16.name();
+    private static final String UTF_8 = StandardCharsets.UTF_8.name();
+    private static final String ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+    private static final String LARGE_TEST_STRING;
+
+    private static final String TEST_STRING = "\u00e0 peine arriv\u00e9s nous entr\u00e2mes dans sa chambre";
+
+    static {
+        final StringBuilder buffer = new StringBuilder();
+        for (int i = 0; i < 100; i++) {
+            buffer.append(TEST_STRING);
+        }
+        LARGE_TEST_STRING = buffer.toString();
+    }
+
+    private final Random random = new Random();
+
+    private int checkAvail(final InputStream is, final int min) throws Exception {
+        final int available = is.available();
+        assertTrue(available >= min, "avail should be >= " + min + ", but was " + available);
+        return available;
+    }
+
+    private Set<String> getRequiredCharsetNames() {
+        return Charsets.requiredCharsets().keySet();
+    }
+
+    private boolean isAvailabilityTestableForCharset(final String csName) {
+        return Charset.forName(csName).canEncode()
+                && !"COMPOUND_TEXT".equalsIgnoreCase(csName) && !"x-COMPOUND_TEXT".equalsIgnoreCase(csName)
+                && !isOddBallLegacyCharsetThatDoesNotSupportFrenchCharacters(csName);
+    }
+
+    private boolean isOddBallLegacyCharsetThatDoesNotSupportFrenchCharacters(final String csName) {
+        return "x-IBM1388".equalsIgnoreCase(csName) ||
+                "ISO-2022-CN".equalsIgnoreCase(csName) ||
+                "ISO-2022-JP".equalsIgnoreCase(csName) ||
+                "Shift_JIS".equalsIgnoreCase(csName);
+    }
+
+    @Test
+    public void testAvailable() throws Exception {
+        for (final String csName : Charset.availableCharsets().keySet()) {
+            // prevent java.lang.UnsupportedOperationException at sun.nio.cs.ext.ISO2022_CN.newEncoder.
+            // also try and avoid the following exception
+//            java.lang.UnsupportedOperationException: null
+//            at java.nio.CharBuffer.array(CharBuffer.java:940)
+//            at sun.nio.cs.ext.COMPOUND_TEXT_Encoder.encodeLoop(COMPOUND_TEXT_Encoder.java:75)
+//            at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:544)
+//            at org.apache.commons.io.input.CharSequenceInputStream.fillBuffer(CharSequenceInputStream.java:120)
+//            at org.apache.commons.io.input.CharSequenceInputStream.read(CharSequenceInputStream.java:151)
+//            at org.apache.commons.io.input.CharSequenceInputStreamTest.testAvailableRead(CharSequenceInputStreamTest.java:412)
+//            at org.apache.commons.io.input.CharSequenceInputStreamTest.testAvailable(CharSequenceInputStreamTest.java:424)
+
+            try {
+                if (isAvailabilityTestableForCharset(csName)) {
+                    testAvailableSkip(csName);
+                    testAvailableRead(csName);
+                }
+            } catch (final UnsupportedOperationException e){
+                fail("Operation not supported for " + csName);
+            }
+        }
+    }
+
+    private void testAvailableRead(final String csName) throws Exception {
+        final String input = "test";
+        try (InputStream r = new CharSequenceInputStream(input, csName)) {
+            int available = checkAvail(r, input.length());
+            assertEquals(available - 1, r.skip(available - 1)); // skip all but one
+            available = checkAvail(r, 1);
+            final byte[] buff = new byte[available];
+            assertEquals(available, r.read(buff, 0, available));
+        }
+    }
+
+    private void testAvailableSkip(final String csName) throws Exception {
+        final String input = "test";
+        try (InputStream r = new CharSequenceInputStream(input, csName)) {
+            int available = checkAvail(r, input.length());
+            assertEquals(available - 1, r.skip(available - 1)); // skip all but one
+            available = checkAvail(r, 1);
+            assertEquals(1, r.skip(1));
+            available = checkAvail(r, 0);
+        }
+    }
+
+    private void testBufferedRead(final String testString, final String charsetName) throws IOException {
+        final byte[] expected = testString.getBytes(charsetName);
+        try (InputStream in = new CharSequenceInputStream(testString, charsetName, 512)) {
+            final byte[] buffer = new byte[128];
+            int offset = 0;            while (true) {
+                int bufferOffset = random.nextInt(64);
+                final int bufferLength = random.nextInt(64);
+                int read = in.read(buffer, bufferOffset, bufferLength);
+                if (read == -1) {
+                    assertEquals(expected.length, offset, "EOF: offset should equal length for charset " + charsetName);
+                    break;
+                }
+                assertTrue(read <= bufferLength, "Read " + read + " <= " + bufferLength);
+                while (read > 0) {
+                    assertTrue(offset < expected.length,
+                            "offset for " + charsetName + " " + offset + " < " + expected.length);
+                    assertEquals(expected[offset], buffer[bufferOffset], "bytes should agree for " + charsetName);
+                    offset++;
+                    bufferOffset++;
+                    read--;
+                }
+            }
+        }
+    }
+
+    //    Unfortunately checking canEncode does not seem to work for all charsets:
+//    testBufferedRead_AvailableCharset(org.apache.commons.io.input.CharSequenceInputStreamTest)  Time elapsed: 0.682 sec  <<< ERROR!
+//    java.lang.UnsupportedOperationException: null
+//        at java.nio.CharBuffer.array(CharBuffer.java:940)
+//        at sun.nio.cs.ext.COMPOUND_TEXT_Encoder.encodeLoop(COMPOUND_TEXT_Encoder.java:75)
+//        at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:544)
+//        at org.apache.commons.io.input.CharSequenceInputStream.fillBuffer(CharSequenceInputStream.java:111)
+    @Test
+    public void testBufferedRead_AvailableCharset() throws IOException {
+        for (final String csName : Charset.availableCharsets().keySet()) {
+            // prevent java.lang.UnsupportedOperationException at sun.nio.cs.ext.ISO2022_CN.newEncoder.
+            if (isAvailabilityTestableForCharset(csName)) {
+                testBufferedRead(TEST_STRING, csName);
+            }
+        }
+    }
+
+    @Test
+    public void testBufferedRead_RequiredCharset() throws IOException {
+        for (final String csName : getRequiredCharsetNames()) {
+            testBufferedRead(TEST_STRING, csName);
+        }
+    }
+
+    @Test
+    public void testBufferedRead_UTF8() throws IOException {
+        testBufferedRead(TEST_STRING, UTF_8);
+    }
+
+    private void testCharsetMismatchInfiniteLoop(final String csName) throws IOException {
+        // Input is UTF-8 bytes: 0xE0 0xB2 0xA0
+        final char[] inputChars = { (char) 0xE0, (char) 0xB2, (char) 0xA0 };
+        final Charset charset = Charset.forName(csName); // infinite loop for US-ASCII, UTF-8 OK
+        try (InputStream stream = new CharSequenceInputStream(new String(inputChars), charset, 512)) {
+            IOUtils.toCharArray(stream, charset);
+        }
+    }
+
+    @Test
+    public void testCharsetMismatchInfiniteLoop_RequiredCharsets() throws IOException {
+        for (final String csName : getRequiredCharsetNames()) {
+            testCharsetMismatchInfiniteLoop(csName);
+        }
+    }
+
+    // Test is broken if readFirst > 0
+    // This is because the initial read fills the buffer from the CharSequence
+    // so data1 gets the first buffer full; data2 will get the next buffer full
+    private void testIO_356(final int bufferSize, final int dataSize, final int readFirst, final String csName) throws Exception {
+        final byte[] data1;
+        final byte[] data2;
+        try (CharSequenceInputStream is = new CharSequenceInputStream(ALPHABET, csName, bufferSize)) {
+            for (int i = 0; i < readFirst; i++) {
+                final int ch = is.read();
+                assertNotEquals(-1, ch);
+            }
+
+            is.mark(dataSize);
+
+            data1 = new byte[dataSize];
+            final int readCount1 = is.read(data1);
+            assertEquals(dataSize, readCount1);
+
+            is.reset(); // should allow data to be re-read
+
+            data2 = new byte[dataSize];
+            final int readCount2 = is.read(data2);
+            assertEquals(dataSize, readCount2);
+        }
+
+        // data buffers should be identical
+        assertArrayEquals(data1, data2, "bufferSize=" + bufferSize + " dataSize=" + dataSize);
+    }
+
+    @Test
+    public void testIO_356_B10_D10_S0_UTF16() throws Exception {
+        testIO_356(10, 10, 0, UTF_16);
+    }
+
+    @Test
+    public void testIO_356_B10_D10_S0_UTF8() throws Exception {
+        testIO_356(10, 10, 0, UTF_8);
+    }
+
+    @Test
+    public void testIO_356_B10_D10_S1_UTF8() throws Exception {
+        testIO_356(10, 10, 1, UTF_8);
+    }
+
+    @Test
+    public void testIO_356_B10_D10_S2_UTF8() throws Exception {
+        testIO_356(10, 10, 2, UTF_8);
+    }
+
+    @Test
+    public void testIO_356_B10_D13_S0_UTF8() throws Exception {
+        testIO_356(10, 13, 0, UTF_8);
+    }
+
+    @Test
+    public void testIO_356_B10_D13_S1_UTF8() throws Exception {
+        testIO_356(10, 13, 1, UTF_8);
+    }
+
+    @Test
+    public void testIO_356_B10_D20_S0_UTF8() throws Exception {
+        testIO_356(10, 20, 0, UTF_8);
+    }
+
+    private void testIO_356_Loop(final String csName, final int maxBytesPerChar) throws Exception {
+        for (int bufferSize = maxBytesPerChar; bufferSize <= 10; bufferSize++) {
+            for (int dataSize = 1; dataSize <= 20; dataSize++) {
+                testIO_356(bufferSize, dataSize, 0, csName);
+            }
+        }
+    }
+
+    @Test
+    public void testIO_356_Loop_UTF16() throws Exception {
+        final Charset charset = StandardCharsets.UTF_16;
+        testIO_356_Loop(charset.displayName(), (int) ReaderInputStream.minBufferSize(charset.newEncoder()));
+    }
+
+    @Test
+    public void testIO_356_Loop_UTF8() throws Exception {
+        final Charset charset = StandardCharsets.UTF_8;
+        testIO_356_Loop(charset.displayName(), (int) ReaderInputStream.minBufferSize(charset.newEncoder()));
+    }
+
+    @Test
+    public void testLargeBufferedRead_RequiredCharsets() throws IOException {
+        for (final String csName : getRequiredCharsetNames()) {
+            testBufferedRead(LARGE_TEST_STRING, csName);
+        }
+    }
+
+    @Test
+    public void testLargeBufferedRead_UTF8() throws IOException {
+        testBufferedRead(LARGE_TEST_STRING, UTF_8);
+    }
+
+    @Test
+    public void testLargeSingleByteRead_RequiredCharsets() throws IOException {
+        for (final String csName : getRequiredCharsetNames()) {
+            testSingleByteRead(LARGE_TEST_STRING, csName);
+        }
+    }
+
+    @Test
+    public void testLargeSingleByteRead_UTF8() throws IOException {
+        testSingleByteRead(LARGE_TEST_STRING, UTF_8);
+    }
+
+    // This test is broken for charsets that don't create a single byte for each char
+    private void testMarkReset(final String csName) throws Exception {
+        try (InputStream r = new CharSequenceInputStream("test", csName)) {
+            assertEquals(2, r.skip(2));
+            r.mark(0);
+            assertEquals('s', r.read(), csName);
+            assertEquals('t', r.read(), csName);
+            assertEquals(-1, r.read(), csName);
+            r.reset();
+            assertEquals('s', r.read(), csName);
+            assertEquals('t', r.read(), csName);
+            assertEquals(-1, r.read(), csName);
+            r.reset();
+            r.reset();
+        }
+    }
+
+    @Test
+    @Disabled // Test broken for charsets that create multiple bytes for a single char
+    public void testMarkReset_RequiredCharsets() throws Exception {
+        for (final String csName : getRequiredCharsetNames()) {
+            testMarkReset(csName);
+        }
+    }
+
+    @Test
+    public void testMarkReset_USASCII() throws Exception {
+        testMarkReset("US-ASCII");
+    }
+
+    @Test
+    public void testMarkReset_UTF8() throws Exception {
+        testMarkReset(UTF_8);
+    }
+
+    @Test
+    public void testMarkSupported() throws Exception {
+        try (InputStream r = new CharSequenceInputStream("test", UTF_8)) {
+            assertTrue(r.markSupported());
+        }
+    }
+
+    @Test
+    public void testNullCharset() throws IOException {
+        try (CharSequenceInputStream in = new CharSequenceInputStream("A", (Charset) null)) {
+            IOUtils.toByteArray(in);
+            assertEquals(Charset.defaultCharset(), in.getCharsetEncoder().charset());
+        }
+    }
+
+    @Test
+    public void testNullCharsetName() throws IOException {
+        try (CharSequenceInputStream in = new CharSequenceInputStream("A", (String) null)) {
+            IOUtils.toByteArray(in);
+            assertEquals(Charset.defaultCharset(), in.getCharsetEncoder().charset());
+        }
+    }
+
+    private void testReadZero(final String csName) throws Exception {
+        try (InputStream r = new CharSequenceInputStream("test", csName)) {
+            final byte[] bytes = new byte[30];
+            assertEquals(0, r.read(bytes, 0, 0));
+        }
+    }
+
+    @Test
+    public void testReadZero_EmptyString() throws Exception {
+        try (InputStream r = new CharSequenceInputStream("", UTF_8)) {
+            final byte[] bytes = new byte[30];
+            assertEquals(0, r.read(bytes, 0, 0));
+        }
+    }
+
+    @Test
+    public void testReadZero_RequiredCharsets() throws Exception {
+        for (final String csName : getRequiredCharsetNames()) {
+            testReadZero(csName);
+        }
+    }
+
+    private void testSingleByteRead(final String testString, final String charsetName) throws IOException {
+        final byte[] bytes = testString.getBytes(charsetName);
+        try (InputStream in = new CharSequenceInputStream(testString, charsetName, 512)) {
+            for (final byte b : bytes) {
+                final int read = in.read();
+                assertTrue(read >= 0, "read " + read + " >=0 ");
+                assertTrue(read <= 255, "read " + read + " <= 255");
+                assertEquals(b, (byte) read, "Should agree with input");
+            }
+            assertEquals(-1, in.read());
+        }
+    }
+
+    @Test
+    public void testSingleByteRead_RequiredCharsets() throws IOException {
+        for (final String csName : getRequiredCharsetNames()) {
+            testSingleByteRead(TEST_STRING, csName);
+        }
+    }
+
+    @Test
+    public void testSingleByteRead_UTF16() throws IOException {
+        testSingleByteRead(TEST_STRING, UTF_16);
+    }
+
+    @Test
+    public void testSingleByteRead_UTF8() throws IOException {
+        testSingleByteRead(TEST_STRING, UTF_8);
+    }
+
+    // This is broken for charsets that don't map each char to a byte
+    private void testSkip(final String csName) throws Exception {
+        try (InputStream r = new CharSequenceInputStream("test", csName)) {
+            assertEquals(1, r.skip(1));
+            assertEquals(2, r.skip(2));
+            assertEquals('t', r.read(), csName);
+            r.skip(100);
+            assertEquals(-1, r.read(), csName);
+        }
+    }
+
+    @Test
+    @Disabled // test is broken for charsets that generate multiple bytes per char.
+    public void testSkip_RequiredCharsets() throws Exception {
+        for (final String csName : getRequiredCharsetNames()) {
+            testSkip(csName);
+        }
+    }
+
+    @Test
+    public void testSkip_USASCII() throws Exception {
+        testSkip("US-ASCII");
+    }
+
+    @Test
+    public void testSkip_UTF8() throws Exception {
+        testSkip(UTF_8);
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/CharSequenceReaderTest.java b/src/test/java/org/apache/commons/io/input/CharSequenceReaderTest.java
new file mode 100644
index 0000000..12296f3
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/CharSequenceReaderTest.java
@@ -0,0 +1,315 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Reader;
+import java.nio.CharBuffer;
+import java.util.Arrays;
+
+import org.apache.commons.io.TestResources;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test case for {@link CharSequenceReader}.
+ *
+ */
+public class CharSequenceReaderTest {
+    private static final char NONE = (new char[1])[0];
+
+    private void checkArray(final char[] expected, final char[] actual) {
+        for (int i = 0; i < expected.length; i++) {
+            assertEquals(expected[i], actual[i], "Compare[" +i + "]");
+        }
+    }
+
+    private void checkRead(final Reader reader, final String expected) throws IOException {
+        for (int i = 0; i < expected.length(); i++) {
+            assertEquals(expected.charAt(i), (char)reader.read(), "Read[" + i + "] of '" + expected + "'");
+        }
+    }
+
+    @Test
+    public void testClose() throws IOException {
+        final Reader reader = new CharSequenceReader("FooBar");
+        checkRead(reader, "Foo");
+        reader.close();
+        checkRead(reader, "Foo");
+
+        final Reader subReader = new CharSequenceReader("xFooBarx", 1, 7);
+        checkRead(subReader, "Foo");
+        subReader.close();
+        checkRead(subReader, "Foo");
+    }
+
+    @Test
+    public void testConstructor() {
+        assertThrows(IllegalArgumentException.class, () -> new CharSequenceReader("FooBar", -1, 6),
+                "Expected exception not thrown for negative start.");
+        assertThrows(IllegalArgumentException.class, () -> new CharSequenceReader("FooBar", 1, 0),
+                "Expected exception not thrown for end before start.");
+    }
+
+    @Test
+    public void testMark() throws IOException {
+        try (Reader reader = new CharSequenceReader("FooBar")) {
+            checkRead(reader, "Foo");
+            reader.mark(0);
+            checkRead(reader, "Bar");
+            reader.reset();
+            checkRead(reader, "Bar");
+            reader.close();
+            checkRead(reader, "Foo");
+            reader.reset();
+            checkRead(reader, "Foo");
+        }
+        try (Reader subReader = new CharSequenceReader("xFooBarx", 1, 7)) {
+            checkRead(subReader, "Foo");
+            subReader.mark(0);
+            checkRead(subReader, "Bar");
+            subReader.reset();
+            checkRead(subReader, "Bar");
+            subReader.close();
+            checkRead(subReader, "Foo");
+            subReader.reset();
+            checkRead(subReader, "Foo");
+        }
+    }
+
+    @Test
+    public void testMarkSupported() throws Exception {
+        try (Reader reader = new CharSequenceReader("FooBar")) {
+            assertTrue(reader.markSupported());
+        }
+    }
+
+    @Test
+    public void testRead() throws IOException {
+        final String value = "Foo";
+        testRead(value);
+        testRead(new StringBuilder(value));
+        testRead(new StringBuffer(value));
+        testRead(CharBuffer.wrap(value));
+    }
+
+    private void testRead(final CharSequence charSequence) throws IOException {
+        try (Reader reader = new CharSequenceReader(charSequence)) {
+            assertEquals('F', reader.read());
+            assertEquals('o', reader.read());
+            assertEquals('o', reader.read());
+            assertEquals(-1, reader.read());
+            assertEquals(-1, reader.read());
+        }
+        try (Reader reader = new CharSequenceReader(charSequence, 1, 5)) {
+            assertEquals('o', reader.read());
+            assertEquals('o', reader.read());
+            assertEquals(-1, reader.read());
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testReadCharArray() throws IOException {
+        final String value = "FooBar";
+        testReadCharArray(value);
+        testReadCharArray(new StringBuilder(value));
+        testReadCharArray(new StringBuffer(value));
+        testReadCharArray(CharBuffer.wrap(value));
+    }
+
+    private void testReadCharArray(final CharSequence charSequence) throws IOException {
+        try (Reader reader = new CharSequenceReader(charSequence)) {
+            char[] chars = new char[2];
+            assertEquals(2, reader.read(chars));
+            checkArray(new char[] { 'F', 'o' }, chars);
+            chars = new char[3];
+            assertEquals(3, reader.read(chars));
+            checkArray(new char[] { 'o', 'B', 'a' }, chars);
+            chars = new char[3];
+            assertEquals(1, reader.read(chars));
+            checkArray(new char[] { 'r', NONE, NONE }, chars);
+            assertEquals(-1, reader.read(chars));
+        }
+        try (Reader reader = new CharSequenceReader(charSequence, 1, 5)) {
+            char[] chars = new char[2];
+            assertEquals(2, reader.read(chars));
+            checkArray(new char[] { 'o', 'o' }, chars);
+            chars = new char[3];
+            assertEquals(2, reader.read(chars));
+            checkArray(new char[] { 'B', 'a', NONE }, chars);
+            chars = new char[3];
+            assertEquals(-1, reader.read(chars));
+            checkArray(new char[] { NONE, NONE, NONE }, chars);
+            assertEquals(-1, reader.read(chars));
+        }
+    }
+
+    @Test
+    public void testReadCharArrayPortion() throws IOException {
+        final String value = "FooBar";
+        testReadCharArrayPortion(value);
+        testReadCharArrayPortion(new StringBuilder(value));
+        testReadCharArrayPortion(new StringBuffer(value));
+        testReadCharArrayPortion(CharBuffer.wrap(value));
+    }
+
+    private void testReadCharArrayPortion(final CharSequence charSequence) throws IOException {
+        final char[] chars = new char[10];
+        try (Reader reader = new CharSequenceReader(charSequence)) {
+            assertEquals(3, reader.read(chars, 3, 3));
+            checkArray(new char[] { NONE, NONE, NONE, 'F', 'o', 'o' }, chars);
+            assertEquals(3, reader.read(chars, 0, 3));
+            checkArray(new char[] { 'B', 'a', 'r', 'F', 'o', 'o', NONE }, chars);
+            assertEquals(-1, reader.read(chars));
+        }
+        Arrays.fill(chars, NONE);
+        try (Reader reader = new CharSequenceReader(charSequence, 1, 5)) {
+            assertEquals(2, reader.read(chars, 3, 2));
+            checkArray(new char[] { NONE, NONE, NONE, 'o', 'o', NONE }, chars);
+            assertEquals(2, reader.read(chars, 0, 3));
+            checkArray(new char[] { 'B', 'a', NONE, 'o', 'o', NONE }, chars);
+            assertEquals(-1, reader.read(chars));
+        }
+    }
+
+    @Test
+    public void testReady() throws IOException {
+        final Reader reader = new CharSequenceReader("FooBar");
+        assertTrue(reader.ready());
+        reader.skip(3);
+        assertTrue(reader.ready());
+        checkRead(reader, "Bar");
+        assertFalse(reader.ready());
+        reader.reset();
+        assertTrue(reader.ready());
+        reader.skip(2);
+        assertTrue(reader.ready());
+        reader.skip(10);
+        assertFalse(reader.ready());
+        reader.close();
+        assertTrue(reader.ready());
+        reader.skip(20);
+        assertFalse(reader.ready());
+
+        final Reader subReader = new CharSequenceReader("xFooBarx", 1, 7);
+        assertTrue(subReader.ready());
+        subReader.skip(3);
+        assertTrue(subReader.ready());
+        checkRead(subReader, "Bar");
+        assertFalse(subReader.ready());
+        subReader.reset();
+        assertTrue(subReader.ready());
+        subReader.skip(2);
+        assertTrue(subReader.ready());
+        subReader.skip(10);
+        assertFalse(subReader.ready());
+        subReader.close();
+        assertTrue(subReader.ready());
+        subReader.skip(20);
+        assertFalse(subReader.ready());
+    }
+
+    @Test
+    public void testSerialization() throws IOException, ClassNotFoundException {
+        /*
+         * File CharSequenceReader.bin contains a CharSequenceReader that was serialized before
+         * the start and end fields were added. Its CharSequence is "FooBar".
+         * This part of the test will test that adding the fields does not break any existing
+         * serialized CharSequenceReaders.
+         */
+        try (ObjectInputStream ois = new ObjectInputStream(TestResources.getInputStream("CharSequenceReader.bin"))) {
+            final CharSequenceReader reader = (CharSequenceReader) ois.readObject();
+            assertEquals('F', reader.read());
+            assertEquals('o', reader.read());
+            assertEquals('o', reader.read());
+            assertEquals('B', reader.read());
+            assertEquals('a', reader.read());
+            assertEquals('r', reader.read());
+            assertEquals(-1, reader.read());
+            assertEquals(-1, reader.read());
+        }
+
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+            final CharSequenceReader reader = new CharSequenceReader("xFooBarx", 1, 7);
+            oos.writeObject(reader);
+        }
+        try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) {
+            final CharSequenceReader reader = (CharSequenceReader) ois.readObject();
+            assertEquals('F', reader.read());
+            assertEquals('o', reader.read());
+            assertEquals('o', reader.read());
+            assertEquals('B', reader.read());
+            assertEquals('a', reader.read());
+            assertEquals('r', reader.read());
+            assertEquals(-1, reader.read());
+            assertEquals(-1, reader.read());
+            reader.reset();
+            assertEquals('F', reader.read());
+            assertEquals('o', reader.read());
+            assertEquals('o', reader.read());
+            assertEquals('B', reader.read());
+            assertEquals('a', reader.read());
+            assertEquals('r', reader.read());
+            assertEquals(-1, reader.read());
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testSkip() throws IOException {
+        final Reader reader = new CharSequenceReader("FooBar");
+        assertEquals(3, reader.skip(3));
+        checkRead(reader, "Bar");
+        assertEquals(0, reader.skip(3));
+        reader.reset();
+        assertEquals(2, reader.skip(2));
+        assertEquals(4, reader.skip(10));
+        assertEquals(0, reader.skip(1));
+        reader.close();
+        assertEquals(6, reader.skip(20));
+        assertEquals(-1, reader.read());
+
+        final Reader subReader = new CharSequenceReader("xFooBarx", 1, 7);
+        assertEquals(3, subReader.skip(3));
+        checkRead(subReader, "Bar");
+        assertEquals(0, subReader.skip(3));
+        subReader.reset();
+        assertEquals(2, subReader.skip(2));
+        assertEquals(4, subReader.skip(10));
+        assertEquals(0, subReader.skip(1));
+        subReader.close();
+        assertEquals(6, subReader.skip(20));
+        assertEquals(-1, subReader.read());
+    }
+
+    @Test
+    @SuppressWarnings("resource") // don't really need to close CharSequenceReader here
+    public void testToString() {
+        assertEquals("FooBar", new CharSequenceReader("FooBar").toString());
+        assertEquals("FooBar", new CharSequenceReader("xFooBarx", 1, 7).toString());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/CharacterFilterReaderIntPredicateTest.java b/src/test/java/org/apache/commons/io/input/CharacterFilterReaderIntPredicateTest.java
new file mode 100644
index 0000000..fba9c4a
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/CharacterFilterReaderIntPredicateTest.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.function.IntPredicate;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.output.StringBuilderWriter;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link CharacterFilterReader} with an {@link IntPredicate}.
+ */
+public class CharacterFilterReaderIntPredicateTest {
+
+    @Test
+    public void testInputSize0FilterAll() throws IOException {
+        final StringReader input = new StringReader(StringUtils.EMPTY);
+        try (CharacterFilterReader reader = new CharacterFilterReader(input, ch -> true)) {
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize1FilterAll() throws IOException {
+        try (StringReader input = new StringReader("a");
+                CharacterFilterReader reader = new CharacterFilterReader(input, ch -> true)) {
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize2FilterAll() throws IOException {
+        final StringReader input = new StringReader("aa");
+        try (CharacterFilterReader reader = new CharacterFilterReader(input, ch -> true)) {
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize2FilterFirst() throws IOException {
+        final StringReader input = new StringReader("ab");
+        try (CharacterFilterReader reader = new CharacterFilterReader(input, ch -> ch == 'a')) {
+            assertEquals('b', reader.read());
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize2FilterLast() throws IOException {
+        final StringReader input = new StringReader("ab");
+        try (CharacterFilterReader reader = new CharacterFilterReader(input, ch -> ch == 'b')) {
+            assertEquals('a', reader.read());
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize5FilterWhitespace() throws IOException {
+        final StringReader input = new StringReader(" a b ");
+        try (CharacterFilterReader reader = new CharacterFilterReader(input, Character::isWhitespace)) {
+            assertEquals('a', reader.read());
+            assertEquals('b', reader.read());
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testReadIntoBuffer() throws IOException {
+        final StringReader input = new StringReader("ababcabcd");
+        try (CharacterFilterReader reader = new CharacterFilterReader(input, ch -> ch == 'b')) {
+            final char[] buff = new char[9];
+            final int charCount = reader.read(buff);
+            assertEquals(6, charCount);
+            assertEquals("aacacd", new String(buff, 0, charCount));
+        }
+    }
+
+    @Test
+    public void testReadIntoBufferFilterWhitespace() throws IOException {
+        final StringReader input = new StringReader(" a b a b c a b c d ");
+        try (CharacterFilterReader reader = new CharacterFilterReader(input, Character::isWhitespace)) {
+            final char[] buff = new char[19];
+            final int charCount = reader.read(buff);
+            assertEquals(9, charCount);
+            assertEquals("ababcabcd", new String(buff, 0, charCount));
+        }
+    }
+
+    @Test
+    public void testReadUsingReader() throws IOException {
+        final StringReader input = new StringReader("ababcabcd");
+        try (StringBuilderWriter output = new StringBuilderWriter();
+                CharacterFilterReader reader = new CharacterFilterReader(input, ch -> ch == 'b')) {
+            IOUtils.copy(reader, output);
+            assertEquals("aacacd", output.toString());
+        }
+    }
+
+    @Test
+    public void testReadUsingReaderFilterWhitespace() throws IOException {
+        final StringReader input = new StringReader(" a b a b c a b c d ");
+        try (StringBuilderWriter output = new StringBuilderWriter();
+                CharacterFilterReader reader = new CharacterFilterReader(input, Character::isWhitespace)) {
+            IOUtils.copy(reader, output);
+            assertEquals("ababcabcd", output.toString());
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/commons/io/input/CharacterFilterReaderTest.java b/src/test/java/org/apache/commons/io/input/CharacterFilterReaderTest.java
new file mode 100644
index 0000000..cea2c6c
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/CharacterFilterReaderTest.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.time.Duration;
+import java.util.HashSet;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.output.StringBuilderWriter;
+import org.junit.jupiter.api.Test;
+
+public class CharacterFilterReaderTest {
+
+    private static final String STRING_FIXTURE = "ababcabcd";
+
+    @Test
+    public void testInputSize0FilterSize1() throws IOException {
+        final StringReader input = new StringReader("");
+        final HashSet<Integer> codePoints = new HashSet<>();
+        codePoints.add(Integer.valueOf('a'));
+        try (CharacterFilterReader reader = new CharacterFilterReader(input, 'A')) {
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize1FilterSize1() throws IOException {
+        try (StringReader input = new StringReader("a");
+            CharacterFilterReader reader = new CharacterFilterReader(input, 'a')) {
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize2FilterSize1FilterAll() throws IOException {
+        final StringReader input = new StringReader("aa");
+        try (CharacterFilterReader reader = new CharacterFilterReader(input, 'a')) {
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize2FilterSize1FilterFirst() throws IOException {
+        final StringReader input = new StringReader("ab");
+        try (CharacterFilterReader reader = new CharacterFilterReader(input, 'a')) {
+            assertEquals('b', reader.read());
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize2FilterSize1FilterLast() throws IOException {
+        final StringReader input = new StringReader("ab");
+        try (CharacterFilterReader reader = new CharacterFilterReader(input, 'b')) {
+            assertEquals('a', reader.read());
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testReadFilteringEOF() {
+        final StringReader input = new StringReader(STRING_FIXTURE);
+        assertTimeoutPreemptively(Duration.ofMillis(500), () -> {
+            try (StringBuilderWriter output = new StringBuilderWriter();
+                CharacterFilterReader reader = new CharacterFilterReader(input, EOF)) {
+                int c;
+                while ((c = reader.read()) != EOF) {
+                    output.write(c);
+                }
+                assertEquals(STRING_FIXTURE, output.toString());
+            }
+        });
+    }
+
+    @Test
+    public void testReadIntoBuffer() throws IOException {
+        final StringReader input = new StringReader(STRING_FIXTURE);
+        try (CharacterFilterReader reader = new CharacterFilterReader(input, 'b')) {
+            final char[] buff = new char[9];
+            final int charCount = reader.read(buff);
+            assertEquals(6, charCount);
+            assertEquals("aacacd", new String(buff, 0, charCount));
+        }
+    }
+
+    @Test
+    public void testReadUsingReader() throws IOException {
+        final StringReader input = new StringReader(STRING_FIXTURE);
+        try (StringBuilderWriter output = new StringBuilderWriter();
+            CharacterFilterReader reader = new CharacterFilterReader(input, 'b')) {
+            IOUtils.copy(reader, output);
+            assertEquals("aacacd", output.toString());
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/CharacterSetFilterReaderTest.java b/src/test/java/org/apache/commons/io/input/CharacterSetFilterReaderTest.java
new file mode 100644
index 0000000..1e14f3f
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/CharacterSetFilterReaderTest.java
@@ -0,0 +1,155 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.time.Duration;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.apache.commons.io.output.StringBuilderWriter;
+import org.junit.jupiter.api.Test;
+
+public class CharacterSetFilterReaderTest {
+
+    private static final String STRING_FIXTURE = "ab";
+
+    @Test
+    public void testInputSize0FilterSize0() throws IOException {
+        final StringReader input = new StringReader("");
+        try (CharacterSetFilterReader reader = new CharacterSetFilterReader(input, new HashSet<>(0))) {
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize0FilterSize1() throws IOException {
+        final StringReader input = new StringReader("");
+        final HashSet<Integer> codePoints = new HashSet<>();
+        codePoints.add(Integer.valueOf('a'));
+        try (CharacterSetFilterReader reader = new CharacterSetFilterReader(input, codePoints)) {
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize0NullFilter() throws IOException {
+        final StringReader input = new StringReader("");
+        try (CharacterSetFilterReader reader = new CharacterSetFilterReader(input, (Set<Integer>) null)) {
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize1FilterSize1() throws IOException {
+        try (StringReader input = new StringReader("a")) {
+            final HashSet<Integer> codePoints = new HashSet<>();
+            codePoints.add(Integer.valueOf('a'));
+            try (CharacterSetFilterReader reader = new CharacterSetFilterReader(input, codePoints)) {
+                assertEquals(-1, reader.read());
+            }
+        }
+    }
+
+    @Test
+    public void testInputSize2FilterSize1FilterAll() throws IOException {
+        final StringReader input = new StringReader("aa");
+        final HashSet<Integer> codePoints = new HashSet<>();
+        codePoints.add(Integer.valueOf('a'));
+        try (CharacterSetFilterReader reader = new CharacterSetFilterReader(input, codePoints)) {
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize2FilterSize1FilterFirst() throws IOException {
+        final StringReader input = new StringReader(STRING_FIXTURE);
+        final HashSet<Integer> codePoints = new HashSet<>();
+        codePoints.add(Integer.valueOf('a'));
+        try (CharacterSetFilterReader reader = new CharacterSetFilterReader(input, codePoints)) {
+            assertEquals('b', reader.read());
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize2FilterSize1FilterLast() throws IOException {
+        final StringReader input = new StringReader(STRING_FIXTURE);
+        final HashSet<Integer> codePoints = new HashSet<>();
+        codePoints.add(Integer.valueOf('b'));
+        try (CharacterSetFilterReader reader = new CharacterSetFilterReader(input, codePoints)) {
+            assertEquals('a', reader.read());
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize2FilterSize2FilterFirst() throws IOException {
+        final StringReader input = new StringReader(STRING_FIXTURE);
+        final HashSet<Integer> codePoints = new HashSet<>();
+        codePoints.add(Integer.valueOf('a'));
+        codePoints.add(Integer.valueOf('y'));
+        try (CharacterSetFilterReader reader = new CharacterSetFilterReader(input, codePoints)) {
+            assertEquals('b', reader.read());
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize2FilterSize2FilterLast() throws IOException {
+        final StringReader input = new StringReader(STRING_FIXTURE);
+        final HashSet<Integer> codePoints = new HashSet<>();
+        codePoints.add(Integer.valueOf('x'));
+        codePoints.add(Integer.valueOf('b'));
+        try (CharacterSetFilterReader reader = new CharacterSetFilterReader(input, codePoints)) {
+            assertEquals('a', reader.read());
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testInputSize2FilterSize2FilterNone() throws IOException {
+        final StringReader input = new StringReader(STRING_FIXTURE);
+        final HashSet<Integer> codePoints = new HashSet<>();
+        codePoints.add(Integer.valueOf('x'));
+        codePoints.add(Integer.valueOf('y'));
+        try (CharacterSetFilterReader reader = new CharacterSetFilterReader(input, codePoints)) {
+            assertEquals('a', reader.read());
+            assertEquals('b', reader.read());
+        }
+    }
+
+    @Test
+    public void testReadFilteringEOF() {
+        final StringReader input = new StringReader(STRING_FIXTURE);
+        assertTimeoutPreemptively(Duration.ofMillis(500), () -> {
+            try (StringBuilderWriter output = new StringBuilderWriter();
+                CharacterSetFilterReader reader = new CharacterSetFilterReader(input, EOF)) {
+                int c;
+                while ((c = reader.read()) != EOF) {
+                    output.write(c);
+                }
+                assertEquals(STRING_FIXTURE, output.toString());
+            }
+        });
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/CircularInputStreamTest.java b/src/test/java/org/apache/commons/io/input/CircularInputStreamTest.java
new file mode 100644
index 0000000..db8bb37
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/CircularInputStreamTest.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link CircularInputStream}.
+ */
+public class CircularInputStreamTest {
+
+    private void assertStreamOutput(final byte[] toCycle, final byte[] expected) throws IOException {
+        final byte[] actual = new byte[expected.length];
+
+        try (InputStream infStream = createInputStream(toCycle, -1)) {
+            final int actualReadBytes = infStream.read(actual);
+
+            assertArrayEquals(expected, actual);
+            assertEquals(expected.length, actualReadBytes);
+        }
+    }
+
+    private InputStream createInputStream(final byte[] repeatContent, final long targetByteCount) {
+        return new CircularInputStream(repeatContent, targetByteCount);
+    }
+
+    @Test
+    public void testContainsEofInputSize0() {
+        assertThrows(IllegalArgumentException.class, () -> createInputStream(new byte[] { -1 }, 0));
+    }
+
+    @Test
+    public void testCount0() throws IOException {
+        try (InputStream in = createInputStream(new byte[] { 1, 2 }, 0)) {
+            assertEquals(IOUtils.EOF, in.read());
+        }
+    }
+
+    @Test
+    public void testCount0InputSize0() {
+        assertThrows(IllegalArgumentException.class, () -> createInputStream(new byte[] {}, 0));
+    }
+
+    @Test
+    public void testCount0InputSize1() throws IOException {
+        try (InputStream in = createInputStream(new byte[] { 1 }, 0)) {
+            assertEquals(IOUtils.EOF, in.read());
+        }
+    }
+
+    @Test
+    public void testCount1InputSize1() throws IOException {
+        try (InputStream in = createInputStream(new byte[] { 1 }, 1)) {
+            assertEquals(1, in.read());
+            assertEquals(IOUtils.EOF, in.read());
+        }
+    }
+
+    @Test
+    public void testCycleBytes() throws IOException {
+        final byte[] input = { 1, 2 };
+        final byte[] expected = { 1, 2, 1, 2, 1 };
+
+        assertStreamOutput(input, expected);
+    }
+
+    @Test
+    public void testNullInputSize0() {
+        assertThrows(NullPointerException.class, () -> createInputStream(null, 0));
+    }
+
+    @Test
+    public void testWholeRangeOfBytes() throws IOException {
+        final int size = Byte.MAX_VALUE - Byte.MIN_VALUE + 1;
+        final byte[] contentToCycle = new byte[size];
+        byte value = Byte.MIN_VALUE;
+        for (int i = 0; i < contentToCycle.length; i++) {
+            contentToCycle[i] = value == IOUtils.EOF ? 0 : value;
+            value++;
+        }
+
+        final byte[] expectedOutput = Arrays.copyOf(contentToCycle, size);
+
+        assertStreamOutput(contentToCycle, expectedOutput);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/ClassLoaderObjectInputStreamTest.java b/src/test/java/org/apache/commons/io/input/ClassLoaderObjectInputStreamTest.java
new file mode 100644
index 0000000..2e8d0f5
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/ClassLoaderObjectInputStreamTest.java
@@ -0,0 +1,158 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.Flushable;
+import java.io.InputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+
+import org.apache.commons.lang3.SerializationUtils;
+
+/**
+ * Tests {@link ClassLoaderObjectInputStream}.
+ */
+public class ClassLoaderObjectInputStreamTest {
+
+    /*
+     * Note: This test case tests the simplest functionality of ObjectInputStream. IF we really wanted to test
+     * ClassLoaderObjectInputStream we would probably need to create a transient Class Loader. -TO
+     */
+
+    private enum E {
+        A, B, C
+    }
+
+    private static class Test implements Serializable {
+        private static final long serialVersionUID = 1L;
+        private final int i;
+
+        private final Object o;
+
+        private final E e;
+
+        Test(final int i, final Object o) {
+            this.i = i;
+            this.e = E.A;
+            this.o = o;
+        }
+
+        private boolean equalObject(final Object other) {
+            if (this.o == null) {
+                return other == null;
+            }
+            return o.equals(other);
+        }
+
+        @Override
+        public boolean equals(final Object other) {
+            if (other instanceof Test) {
+                final Test tOther = (Test) other;
+                return this.i == tOther.i & this.e == tOther.e & equalObject(tOther.o);
+            }
+            return false;
+        }
+
+        @Override
+        public int hashCode() {
+            return super.hashCode();
+        }
+    }
+
+    @org.junit.jupiter.api.Test
+    public void testExpected() throws Exception {
+        final Boolean input = Boolean.FALSE;
+        final InputStream bais = new ByteArrayInputStream(SerializationUtils.serialize(input));
+        try (ClassLoaderObjectInputStream clois = new ClassLoaderObjectInputStream(getClass().getClassLoader(), bais)) {
+            final Object result = clois.readObject();
+            assertEquals(input, result);
+        }
+    }
+
+    @org.junit.jupiter.api.Test
+    public void testLong() throws Exception {
+        final Long input = 123L;
+        final InputStream bais = new ByteArrayInputStream(SerializationUtils.serialize(input));
+        try (ClassLoaderObjectInputStream clois = new ClassLoaderObjectInputStream(getClass().getClassLoader(), bais)) {
+            final Object result = clois.readObject();
+            assertEquals(input, result);
+        }
+    }
+
+    @org.junit.jupiter.api.Test
+    public void testObject1() throws Exception {
+        final Test input = new Test(123, null);
+        final InputStream bais = new ByteArrayInputStream(SerializationUtils.serialize(input));
+        try (ClassLoaderObjectInputStream clois = new ClassLoaderObjectInputStream(getClass().getClassLoader(), bais)) {
+            final Object result = clois.readObject();
+            assertEquals(input, result);
+        }
+    }
+
+    @org.junit.jupiter.api.Test
+    public void testObject2() throws Exception {
+        final Test input = new Test(123, 0);
+        final InputStream bais = new ByteArrayInputStream(SerializationUtils.serialize(input));
+        try (ClassLoaderObjectInputStream clois = new ClassLoaderObjectInputStream(getClass().getClassLoader(), bais)) {
+            final Object result = clois.readObject();
+            assertEquals(input, result);
+        }
+    }
+
+    @org.junit.jupiter.api.Test
+    public void testPrimitiveLong() throws Exception {
+        final long input = 12345L;
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        try (final ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+            oos.writeLong(input);
+        }
+        final InputStream bais = new ByteArrayInputStream(baos.toByteArray());
+        try (ClassLoaderObjectInputStream clois = new ClassLoaderObjectInputStream(getClass().getClassLoader(), bais)) {
+            final long result = clois.readLong();
+            assertEquals(input, result);
+        }
+    }
+
+    @org.junit.jupiter.api.Test
+    public void testResolveProxyClass() throws Exception {
+        final InputStream bais = new ByteArrayInputStream(SerializationUtils.serialize(Boolean.FALSE));
+        try (ClassLoaderObjectInputStream clois = new ClassLoaderObjectInputStream(getClass().getClassLoader(), bais)) {
+            final String[] interfaces = {Comparable.class.getName()};
+            final Class<?> result = clois.resolveProxyClass(interfaces);
+            assertTrue(Comparable.class.isAssignableFrom(result), "Assignable");
+        }
+    }
+
+    @org.junit.jupiter.api.Test
+    public void testResolveProxyClassWithMultipleInterfaces() throws Exception {
+        final InputStream bais = new ByteArrayInputStream(SerializationUtils.serialize(Boolean.FALSE));
+        try (ClassLoaderObjectInputStream clois = new ClassLoaderObjectInputStream(getClass().getClassLoader(), bais)) {
+            final String[] interfaces = {Comparable.class.getName(), Serializable.class.getName(), Runnable.class.getName()};
+            final Class<?> result = clois.resolveProxyClass(interfaces);
+            assertTrue(Comparable.class.isAssignableFrom(result), "Assignable");
+            assertTrue(Runnable.class.isAssignableFrom(result), "Assignable");
+            assertTrue(Serializable.class.isAssignableFrom(result), "Assignable");
+            assertFalse(Flushable.class.isAssignableFrom(result), "Not Assignable");
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/CloseShieldInputStreamTest.java b/src/test/java/org/apache/commons/io/input/CloseShieldInputStreamTest.java
new file mode 100644
index 0000000..4a09310
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/CloseShieldInputStreamTest.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link CloseShieldInputStream}.
+ */
+public class CloseShieldInputStreamTest {
+
+    private byte[] data;
+
+    private InputStream original;
+
+    private InputStream shielded;
+
+    private boolean closed;
+
+    @BeforeEach
+    public void setUp() {
+        data = new byte[] { 'x', 'y', 'z' };
+        original = new ByteArrayInputStream(data) {
+            @Override
+            public void close() {
+                closed = true;
+            }
+        };
+        shielded = CloseShieldInputStream.wrap(original);
+        closed = false;
+    }
+
+    @Test
+    public void testClose() throws IOException {
+        shielded.close();
+        assertFalse(closed, "closed");
+        assertEquals(-1, shielded.read(), "read()");
+        assertEquals(data[0], original.read(), "read()");
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/CloseShieldReaderTest.java b/src/test/java/org/apache/commons/io/input/CloseShieldReaderTest.java
new file mode 100644
index 0000000..59a9959
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/CloseShieldReaderTest.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+import java.io.IOException;
+import java.io.Reader;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link CloseShieldReader}.
+ */
+public class CloseShieldReaderTest {
+
+    private String data;
+
+    private Reader original;
+
+    private Reader shielded;
+
+    @BeforeEach
+    public void setUp() {
+        data = "xyz";
+        original = spy(new CharSequenceReader(data));
+        shielded = CloseShieldReader.wrap(original);
+    }
+
+    @Test
+    public void testClose() throws IOException {
+        shielded.close();
+        verify(original, never()).close();
+        final char[] cbuf = new char[10];
+        assertEquals(-1, shielded.read(cbuf, 0, 10), "read(cbuf, off, len)");
+        assertEquals(data.length(), original.read(cbuf, 0, 10), "read(cbuf, off, len)");
+        assertEquals(data, new String(cbuf, 0, data.length()));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/ClosedInputStreamTest.java b/src/test/java/org/apache/commons/io/input/ClosedInputStreamTest.java
new file mode 100644
index 0000000..6a4d685
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/ClosedInputStreamTest.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link ClosedInputStream}.
+ */
+public class ClosedInputStreamTest {
+
+    @Test
+    public void testRead() throws Exception {
+        try (ClosedInputStream cis = new ClosedInputStream()) {
+            assertEquals(-1, cis.read(), "read()");
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/ClosedReaderTest.java b/src/test/java/org/apache/commons/io/input/ClosedReaderTest.java
new file mode 100644
index 0000000..2c9debc
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/ClosedReaderTest.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link ClosedReader}.
+ */
+public class ClosedReaderTest {
+
+    @Test
+    public void testRead() throws Exception {
+        try (ClosedReader cr = new ClosedReader()) {
+            assertEquals(-1, cr.read(new char[10], 0, 10), "read(cbuf, off, len)");
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/CountingInputStreamTest.java b/src/test/java/org/apache/commons/io/input/CountingInputStreamTest.java
new file mode 100644
index 0000000..abd8bf2
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/CountingInputStreamTest.java
@@ -0,0 +1,202 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests the CountingInputStream.
+ *
+ */
+public class CountingInputStreamTest {
+
+    @Test
+    public void testCounting() throws Exception {
+        final String text = "A piece of text";
+        try (CountingInputStream cis = new CountingInputStream(new StringInputStream(text))) {
+
+            // have to declare this larger as we're going to read
+            // off the end of the stream and input stream seems
+            // to do bounds checking
+            final byte[] result = new byte[21];
+
+            final byte[] ba = new byte[5];
+            int found = cis.read(ba);
+            System.arraycopy(ba, 0, result, 0, 5);
+            assertEquals(found, cis.getCount());
+
+            final int value = cis.read();
+            found++;
+            result[5] = (byte) value;
+            assertEquals(found, cis.getCount());
+
+            found += cis.read(result, 6, 5);
+            assertEquals(found, cis.getCount());
+
+            found += cis.read(result, 11, 10); // off the end
+            assertEquals(found, cis.getCount());
+
+            // trim to get rid of the 6 empty values
+            final String textResult = new String(result).trim();
+            assertEquals(textResult, text);
+        }
+    }
+
+
+    @Test
+    public void testEOF1() throws Exception {
+        final ByteArrayInputStream bais = new ByteArrayInputStream(new byte[2]);
+        try (CountingInputStream cis = new CountingInputStream(bais)) {
+
+            int found = cis.read();
+            assertEquals(0, found);
+            assertEquals(1, cis.getCount());
+            found = cis.read();
+            assertEquals(0, found);
+            assertEquals(2, cis.getCount());
+            found = cis.read();
+            assertEquals(-1, found);
+            assertEquals(2, cis.getCount());
+        }
+    }
+
+    @Test
+    public void testEOF2() throws Exception {
+        final ByteArrayInputStream bais = new ByteArrayInputStream(new byte[2]);
+        try (CountingInputStream cis = new CountingInputStream(bais)) {
+
+            final byte[] result = new byte[10];
+
+            final int found = cis.read(result);
+            assertEquals(2, found);
+            assertEquals(2, cis.getCount());
+        }
+    }
+
+    @Test
+    public void testEOF3() throws Exception {
+        final ByteArrayInputStream bais = new ByteArrayInputStream(new byte[2]);
+        try (CountingInputStream cis = new CountingInputStream(bais)) {
+
+            final byte[] result = new byte[10];
+
+            final int found = cis.read(result, 0, 5);
+            assertEquals(2, found);
+            assertEquals(2, cis.getCount());
+        }
+    }
+
+    /*
+     * Test for files > 2GB in size - see issue IO-84
+     */
+    @Test
+    public void testLargeFiles_IO84() throws Exception {
+        final long size = (long) Integer.MAX_VALUE + (long) 1;
+        final NullInputStream mock = new NullInputStream(size);
+        final CountingInputStream cis = new CountingInputStream(mock);
+
+        // Test integer methods
+        IOUtils.consume(cis);
+        assertThrows(ArithmeticException.class, () -> cis.getCount());
+        assertThrows(ArithmeticException.class, () -> cis.resetCount());
+
+        mock.close();
+
+        // Test long methods
+        IOUtils.consume(cis);
+        assertEquals(size, cis.getByteCount(), "getByteCount()");
+        assertEquals(size, cis.resetByteCount(), "resetByteCount()");
+    }
+
+    @Test
+    public void testResetting() throws Exception {
+        final String text = "A piece of text";
+        final byte[] bytes = text.getBytes();
+        final ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
+        try (CountingInputStream cis = new CountingInputStream(bais)) {
+
+            final byte[] result = new byte[bytes.length];
+
+            int found = cis.read(result, 0, 5);
+            assertEquals(found, cis.getCount());
+
+            final int count = cis.resetCount();
+            found = cis.read(result, 6, 5);
+            assertEquals(found, count);
+        }
+    }
+
+    @Test
+    public void testSkipping() throws IOException {
+        final String text = "Hello World!";
+        try (CountingInputStream cis = new CountingInputStream(new StringInputStream(text))) {
+
+            assertEquals(6, cis.skip(6));
+            assertEquals(6, cis.getCount());
+            final byte[] result = new byte[6];
+            cis.read(result);
+
+            assertEquals("World!", new String(result));
+            assertEquals(12, cis.getCount());
+        }
+    }
+
+    @Test
+    public void testZeroLength1() throws Exception {
+        final ByteArrayInputStream bais = new ByteArrayInputStream(IOUtils.EMPTY_BYTE_ARRAY);
+        try (CountingInputStream cis = new CountingInputStream(bais)) {
+
+            final int found = cis.read();
+            assertEquals(-1, found);
+            assertEquals(0, cis.getCount());
+        }
+    }
+
+    @Test
+    public void testZeroLength2() throws Exception {
+        final ByteArrayInputStream bais = new ByteArrayInputStream(IOUtils.EMPTY_BYTE_ARRAY);
+        try (CountingInputStream cis = new CountingInputStream(bais)) {
+
+            final byte[] result = new byte[10];
+
+            final int found = cis.read(result);
+            assertEquals(-1, found);
+            assertEquals(0, cis.getCount());
+        }
+    }
+
+    @Test
+    public void testZeroLength3() throws Exception {
+        final ByteArrayInputStream bais = new ByteArrayInputStream(IOUtils.EMPTY_BYTE_ARRAY);
+        try (CountingInputStream cis = new CountingInputStream(bais)) {
+
+            final byte[] result = new byte[10];
+
+            final int found = cis.read(result, 0, 5);
+            assertEquals(-1, found);
+            assertEquals(0, cis.getCount());
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/InfiniteCircularInputStreamTest.java b/src/test/java/org/apache/commons/io/input/InfiniteCircularInputStreamTest.java
new file mode 100644
index 0000000..67baaca
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/InfiniteCircularInputStreamTest.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link InfiniteCircularInputStream}.
+ */
+public class InfiniteCircularInputStreamTest {
+
+    private void assertStreamOutput(final byte[] toCycle, final byte[] expected) throws IOException {
+        final byte[] actual = new byte[expected.length];
+
+        try (InputStream infStream = new InfiniteCircularInputStream(toCycle)) {
+            final int actualReadBytes = infStream.read(actual);
+
+            assertArrayEquals(expected, actual);
+            assertEquals(expected.length, actualReadBytes);
+        }
+    }
+
+    private InputStream createInputStream(final byte[] repeatContent) {
+        return new InfiniteCircularInputStream(repeatContent);
+    }
+
+    @Test
+    public void testContainsEofInputSize0() {
+        assertThrows(IllegalArgumentException.class, () -> createInputStream(new byte[] { -1 }));
+    }
+
+    @Test
+    public void testCount0InputSize0() {
+        assertThrows(IllegalArgumentException.class, () -> createInputStream(new byte[] {}));
+    }
+
+    @Test
+    public void testCount0InputSize1() throws IOException {
+        try (InputStream in = createInputStream(new byte[] { 1 })) {
+            // empty
+        }
+    }
+
+    @Test
+    public void testCount1InputSize1() throws IOException {
+        try (InputStream in = createInputStream(new byte[] { 1 })) {
+            assertEquals(1, in.read());
+            assertEquals(1, in.read());
+        }
+    }
+
+    @Test
+    public void testCycleBytes() throws IOException {
+        final byte[] input = { 1, 2 };
+        final byte[] expected = { 1, 2, 1, 2, 1 };
+
+        assertStreamOutput(input, expected);
+    }
+
+    @Test
+    public void testNullInputSize0() {
+        assertThrows(NullPointerException.class, () -> createInputStream(null));
+    }
+
+    @Test
+    public void testWholeRangeOfBytes() throws IOException {
+        final int size = Byte.MAX_VALUE - Byte.MIN_VALUE + 1;
+        final byte[] contentToCycle = new byte[size];
+        byte value = Byte.MIN_VALUE;
+        for (int i = 0; i < contentToCycle.length; i++) {
+            contentToCycle[i] = value == IOUtils.EOF ? 0 : value;
+            value++;
+        }
+
+        final byte[] expectedOutput = Arrays.copyOf(contentToCycle, size);
+
+        assertStreamOutput(contentToCycle, expectedOutput);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/MarkShieldInputStreamTest.java b/src/test/java/org/apache/commons/io/input/MarkShieldInputStreamTest.java
new file mode 100644
index 0000000..a72e0f3
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/MarkShieldInputStreamTest.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.junit.jupiter.api.Test;
+
+public class MarkShieldInputStreamTest {
+
+    private static class MarkTestableInputStream extends ProxyInputStream {
+        int markcount;
+        int readlimit;
+
+        public MarkTestableInputStream(final InputStream in) {
+            super(in);
+        }
+
+        @SuppressWarnings("sync-override")
+        @Override
+        public void mark(final int readlimit) {
+            // record that `mark` was called
+            this.markcount++;
+            this.readlimit = readlimit;
+
+            // invoke on super
+            super.mark(readlimit);
+        }
+    }
+
+    @Test
+    public void markIsNoOpWhenUnderlyingDoesNotSupport() throws IOException {
+        try (MarkTestableInputStream in = new MarkTestableInputStream(new NullInputStream(64, false, false));
+             final MarkShieldInputStream msis = new MarkShieldInputStream(in)) {
+
+            msis.mark(1024);
+
+            assertEquals(0, in.markcount);
+            assertEquals(0, in.readlimit);
+        }
+    }
+
+    @Test
+    public void markIsNoOpWhenUnderlyingSupports() throws IOException {
+        try (MarkTestableInputStream in = new MarkTestableInputStream(new NullInputStream(64, true, false));
+             final MarkShieldInputStream msis = new MarkShieldInputStream(in)) {
+
+            msis.mark(1024);
+
+            assertEquals(0, in.markcount);
+            assertEquals(0, in.readlimit);
+        }
+    }
+
+    @Test
+    public void markSupportedIsFalseWhenUnderlyingFalse() throws IOException {
+        // test wrapping an underlying stream which does NOT support marking
+        try (InputStream is = new NullInputStream(64, false, false)) {
+            assertFalse(is.markSupported());
+
+            try (MarkShieldInputStream msis = new MarkShieldInputStream(is)) {
+                assertFalse(msis.markSupported());
+            }
+        }
+    }
+
+    @Test
+    public void markSupportedIsFalseWhenUnderlyingTrue() throws IOException {
+        // test wrapping an underlying stream which supports marking
+        try (InputStream is = new NullInputStream(64, true, false)) {
+            assertTrue(is.markSupported());
+
+            try (MarkShieldInputStream msis = new MarkShieldInputStream(is)) {
+                assertFalse(msis.markSupported());
+            }
+        }
+    }
+
+    @Test
+    public void resetThrowsExceptionWhenUnderlyingDoesNotSupport() throws IOException {
+        // test wrapping an underlying stream which does NOT support marking
+        try (MarkShieldInputStream msis = new MarkShieldInputStream(
+                new NullInputStream(64, false, false))) {
+            assertThrows(UnsupportedOperationException.class, msis::reset);
+        }
+    }
+
+    @Test
+    public void resetThrowsExceptionWhenUnderlyingSupports() throws IOException {
+        // test wrapping an underlying stream which supports marking
+        try (MarkShieldInputStream msis = new MarkShieldInputStream(
+                new NullInputStream(64, true, false))) {
+            assertThrows(UnsupportedOperationException.class, msis::reset);
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/MemoryMappedFileInputStreamTest.java b/src/test/java/org/apache/commons/io/input/MemoryMappedFileInputStreamTest.java
new file mode 100644
index 0000000..dae929f
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/MemoryMappedFileInputStreamTest.java
@@ -0,0 +1,246 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.lang3.ArrayUtils.EMPTY_BYTE_ARRAY;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.RandomUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * Tests {@link MemoryMappedFileInputStream}.
+ */
+public class MemoryMappedFileInputStreamTest {
+
+    @TempDir
+    Path tempDir;
+
+    @AfterEach
+    void afterEach() {
+        // Ask to run the garbage collector to clean up memory mapped buffers,
+        // otherwise the temporary files won't be able to be removed when running on
+        // Windows. Calling gc() is just a hint to the VM.
+        System.gc();
+        Thread.yield();
+        System.runFinalization();
+        Thread.yield();
+        System.gc();
+        Thread.yield();
+        System.runFinalization();
+        Thread.yield();
+    }
+
+    private Path createTestFile(final int size) throws IOException {
+        return Files.write(Files.createTempFile(tempDir, null, null), RandomUtils.nextBytes(size));
+    }
+
+    @Test
+    void testAlternateBufferSize() throws IOException {
+        // setup
+        final Path file = createTestFile(1024 * 1024);
+        final byte[] expectedData = Files.readAllBytes(file);
+
+        // test
+        try (InputStream inputStream = new MemoryMappedFileInputStream(file, 1024)) {
+            // verify
+            assertArrayEquals(expectedData, IOUtils.toByteArray(inputStream));
+        }
+    }
+
+    @Test
+    void testEmptyFile() throws IOException {
+        // setup
+        final Path file = createTestFile(0);
+        // test
+        try (InputStream inputStream = new MemoryMappedFileInputStream(file)) {
+            // verify
+            assertArrayEquals(EMPTY_BYTE_ARRAY, IOUtils.toByteArray(inputStream));
+        }
+    }
+
+    @Test
+    void testLargerFile() throws IOException {
+        // setup
+        final Path file = createTestFile(1024 * 1024);
+        final byte[] expectedData = Files.readAllBytes(file);
+
+        // test
+        try (InputStream inputStream = new MemoryMappedFileInputStream(file)) {
+            // verify
+            assertArrayEquals(expectedData, IOUtils.toByteArray(inputStream));
+        }
+    }
+
+    @Test
+    void testReadAfterClose() throws IOException {
+        // setup
+        final Path file = createTestFile(1 * 1024 * 1024);
+
+        // test
+        try (InputStream inputStream = new MemoryMappedFileInputStream(file, 1024)) {
+            inputStream.close();
+            // verify
+            Assertions.assertThrows(IOException.class, () -> IOUtils.toByteArray(inputStream));
+        }
+    }
+
+    @Test
+    void testReadSingleByte() throws IOException {
+        // setup
+        final Path file = createTestFile(2);
+        final byte[] expectedData = Files.readAllBytes(file);
+        // test
+        try (InputStream inputStream = new MemoryMappedFileInputStream(file, 1024)) {
+            final int b1 = inputStream.read();
+            final int b2 = inputStream.read();
+            assertEquals(-1, inputStream.read());
+            // verify
+            assertArrayEquals(expectedData, new byte[] {(byte) b1, (byte) b2});
+        }
+    }
+
+    @Test
+    void testSkipAtStart() throws IOException {
+        // setup
+        final Path file = createTestFile(100);
+        final byte[] expectedData = Files.readAllBytes(file);
+
+        // test
+        try (InputStream inputStream = new MemoryMappedFileInputStream(file, 10)) {
+            assertEquals(1, inputStream.skip(1));
+            final byte[] data = IOUtils.toByteArray(inputStream);
+            // verify
+            assertArrayEquals(Arrays.copyOfRange(expectedData, 1, expectedData.length), data);
+        }
+    }
+
+    @Test
+    void testSkipEmpty() throws IOException {
+        // setup
+        final Path file = createTestFile(0);
+        // test
+        try (InputStream inputStream = new MemoryMappedFileInputStream(file)) {
+            assertEquals(0, inputStream.skip(5));
+            // verify
+            assertArrayEquals(EMPTY_BYTE_ARRAY, IOUtils.toByteArray(inputStream));
+        }
+    }
+
+    @Test
+    void testSkipInCurrentBuffer() throws IOException {
+        // setup
+        final Path file = createTestFile(100);
+        final byte[] expectedData = Files.readAllBytes(file);
+
+        // test
+        try (InputStream inputStream = new MemoryMappedFileInputStream(file, 10)) {
+            IOUtils.toByteArray(inputStream, 5);
+            assertEquals(3, inputStream.skip(3));
+            final byte[] data = IOUtils.toByteArray(inputStream);
+            // verify
+            assertArrayEquals(Arrays.copyOfRange(expectedData, 8, expectedData.length), data);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {-5, -1, 0})
+    void testSkipNoop(final int amountToSkip) throws IOException {
+        // setup
+        final Path file = createTestFile(10);
+        final byte[] expectedData = Files.readAllBytes(file);
+        // test
+        try (InputStream inputStream = new MemoryMappedFileInputStream(file)) {
+            assertEquals(0, inputStream.skip(amountToSkip));
+            // verify
+            assertArrayEquals(expectedData, IOUtils.toByteArray(inputStream));
+        }
+    }
+
+    @Test
+    void testSkipOutOfCurrentBuffer() throws IOException {
+        // setup
+        final Path file = createTestFile(100);
+        final byte[] expectedData = Files.readAllBytes(file);
+
+        // test
+        try (InputStream inputStream = new MemoryMappedFileInputStream(file, 10)) {
+            IOUtils.toByteArray(inputStream, 5);
+            assertEquals(6, inputStream.skip(6));
+            final byte[] data = IOUtils.toByteArray(inputStream);
+            // verify
+            assertArrayEquals(Arrays.copyOfRange(expectedData, 11, expectedData.length), data);
+        }
+    }
+
+    @Test
+    void testSkipPastEof() throws IOException {
+        // setup
+        final Path file = createTestFile(100);
+
+        // test
+        try (InputStream inputStream = new MemoryMappedFileInputStream(file, 10)) {
+            IOUtils.toByteArray(inputStream, 5);
+            assertEquals(95, inputStream.skip(96));
+            // verify
+            assertArrayEquals(EMPTY_BYTE_ARRAY, IOUtils.toByteArray(inputStream));
+        }
+    }
+
+    @Test
+    void testSkipToEndOfCurrentBuffer() throws IOException {
+        // setup
+        final Path file = createTestFile(100);
+        final byte[] expectedData = Files.readAllBytes(file);
+
+        // test
+        try (InputStream inputStream = new MemoryMappedFileInputStream(file, 10)) {
+            IOUtils.toByteArray(inputStream, 5);
+            assertEquals(5, inputStream.skip(5));
+            final byte[] data = IOUtils.toByteArray(inputStream);
+            // verify
+            assertArrayEquals(Arrays.copyOfRange(expectedData, 10, expectedData.length), data);
+        }
+    }
+
+    @Test
+    void testSmallFile() throws IOException {
+        // setup
+        final Path file = createTestFile(100);
+        final byte[] expectedData = Files.readAllBytes(file);
+
+        // test
+        try (InputStream inputStream = new MemoryMappedFileInputStream(file)) {
+            // verify
+            assertArrayEquals(expectedData, IOUtils.toByteArray(inputStream));
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/MessageDigestCalculatingInputStreamTest.java b/src/test/java/org/apache/commons/io/input/MessageDigestCalculatingInputStreamTest.java
new file mode 100644
index 0000000..e0e611f
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/MessageDigestCalculatingInputStreamTest.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+
+import java.io.ByteArrayInputStream;
+import java.security.MessageDigest;
+import java.util.Random;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link MessageDigestCalculatingInputStream}.
+ */
+public class MessageDigestCalculatingInputStreamTest {
+
+    public static byte[] generateRandomByteStream(final int pSize) {
+        final byte[] buffer = new byte[pSize];
+        final Random rnd = new Random();
+        rnd.nextBytes(buffer);
+        return buffer;
+    }
+
+    @Test
+    public void test() throws Exception {
+        for (int i = 256; i < 8192; i = i * 2) {
+            final byte[] buffer = generateRandomByteStream(i);
+            final MessageDigest messageDigest = MessageDigestCalculatingInputStream.getDefaultMessageDigest();
+            final byte[] expect = messageDigest.digest(buffer);
+            try (MessageDigestCalculatingInputStream messageDigestInputStream = new MessageDigestCalculatingInputStream(
+                new ByteArrayInputStream(buffer))) {
+                messageDigestInputStream.consume();
+                assertArrayEquals(expect, messageDigestInputStream.getMessageDigest().digest());
+            }
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/NullInputStreamTest.java b/src/test/java/org/apache/commons/io/input/NullInputStreamTest.java
new file mode 100644
index 0000000..49f31a1
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/NullInputStreamTest.java
@@ -0,0 +1,226 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link NullInputStream}.
+ *
+ */
+public class NullInputStreamTest {
+
+    private static final class TestNullInputStream extends NullInputStream {
+        public TestNullInputStream(final int size) {
+            super(size);
+        }
+        public TestNullInputStream(final int size, final boolean markSupported, final boolean throwEofException) {
+            super(size, markSupported, throwEofException);
+        }
+        @Override
+        protected int processByte() {
+            return (int)getPosition() - 1;
+        }
+        @Override
+        protected void processBytes(final byte[] bytes, final int offset, final int length) {
+            final int startPos = (int)getPosition() - length;
+            for (int i = offset; i < length; i++) {
+                bytes[i] = (byte)(startPos + i);
+            }
+        }
+
+    }
+
+    // Use the same message as in java.io.InputStream.reset() in OpenJDK 8.0.275-1.
+    private static final String MARK_RESET_NOT_SUPPORTED = "mark/reset not supported";
+
+    @Test
+    public void testEOFException() throws Exception {
+        try (InputStream input = new TestNullInputStream(2, false, true)) {
+            assertEquals(0, input.read(), "Read 1");
+            assertEquals(1, input.read(), "Read 2");
+            assertThrows(EOFException.class, () -> input.read());
+        }
+    }
+
+    @Test
+    public void testMarkAndReset() throws Exception {
+        int position = 0;
+        final int readlimit = 10;
+        try (InputStream input = new TestNullInputStream(100, true, false)) {
+
+            assertTrue(input.markSupported(), "Mark Should be Supported");
+
+            // No Mark
+            try {
+                input.reset();
+                fail("Read limit exceeded, expected IOException ");
+            } catch (final IOException e) {
+                assertEquals("No position has been marked", e.getMessage(), "No Mark IOException message");
+            }
+
+            for (; position < 3; position++) {
+                assertEquals(position, input.read(), "Read Before Mark [" + position + "]");
+            }
+
+            // Mark
+            input.mark(readlimit);
+
+            // Read further
+            for (int i = 0; i < 3; i++) {
+                assertEquals(position + i, input.read(), "Read After Mark [" + i + "]");
+            }
+
+            // Reset
+            input.reset();
+
+            // Read From marked position
+            for (int i = 0; i < readlimit + 1; i++) {
+                assertEquals(position + i, input.read(), "Read After Reset [" + i + "]");
+            }
+
+            // Reset after read limit passed
+            try {
+                input.reset();
+                fail("Read limit exceeded, expected IOException ");
+            } catch (final IOException e) {
+                assertEquals("Marked position [" + position + "] is no longer valid - passed the read limit [" + readlimit + "]", e.getMessage(),
+                    "Read limit IOException message");
+            }
+        }
+    }
+
+    @Test
+    public void testMarkNotSupported() throws Exception {
+        final InputStream input = new TestNullInputStream(100, false, true);
+        assertFalse(input.markSupported(), "Mark Should NOT be Supported");
+
+        try {
+            input.mark(5);
+            fail("mark() should throw UnsupportedOperationException");
+        } catch (final UnsupportedOperationException e) {
+            assertEquals(MARK_RESET_NOT_SUPPORTED, e.getMessage(), "mark() error message");
+        }
+
+        try {
+            input.reset();
+            fail("reset() should throw UnsupportedOperationException");
+        } catch (final UnsupportedOperationException e) {
+            assertEquals(MARK_RESET_NOT_SUPPORTED, e.getMessage(), "reset() error message");
+        }
+        input.close();
+    }
+
+    @Test
+    public void testRead() throws Exception {
+        final int size = 5;
+        final InputStream input = new TestNullInputStream(size);
+        for (int i = 0; i < size; i++) {
+            assertEquals(size - i, input.available(), "Check Size [" + i + "]");
+            assertEquals(i, input.read(), "Check Value [" + i + "]");
+        }
+        assertEquals(0, input.available(), "Available after contents all read");
+
+        // Check available is zero after End of file
+        assertEquals(-1, input.read(), "End of File");
+        assertEquals(0, input.available(), "Available after End of File");
+
+        // Test reading after the end of file
+        try {
+            final int result = input.read();
+            fail("Should have thrown an IOException, byte=[" + result + "]");
+        } catch (final IOException e) {
+            assertEquals("Read after end of file", e.getMessage());
+        }
+
+        // Close - should reset
+        input.close();
+        assertEquals(size, input.available(), "Available after close");
+    }
+
+    @Test
+    public void testReadByteArray() throws Exception {
+        final byte[] bytes = new byte[10];
+        final InputStream input = new TestNullInputStream(15);
+
+        // Read into array
+        final int count1 = input.read(bytes);
+        assertEquals(bytes.length, count1, "Read 1");
+        for (int i = 0; i < count1; i++) {
+            assertEquals(i, bytes[i], "Check Bytes 1");
+        }
+
+        // Read into array
+        final int count2 = input.read(bytes);
+        assertEquals(5, count2, "Read 2");
+        for (int i = 0; i < count2; i++) {
+            assertEquals(count1 + i, bytes[i], "Check Bytes 2");
+        }
+
+        // End of File
+        final int count3 = input.read(bytes);
+        assertEquals(-1, count3, "Read 3 (EOF)");
+
+        // Test reading after the end of file
+        try {
+            final int count4 = input.read(bytes);
+            fail("Should have thrown an IOException, byte=[" + count4 + "]");
+        } catch (final IOException e) {
+            assertEquals("Read after end of file", e.getMessage());
+        }
+
+        // reset by closing
+        input.close();
+
+        // Read into array using offset & length
+        final int offset = 2;
+        final int lth    = 4;
+        final int count5 = input.read(bytes, offset, lth);
+        assertEquals(lth, count5, "Read 5");
+        for (int i = offset; i < lth; i++) {
+            assertEquals(i, bytes[i], "Check Bytes 2");
+        }
+    }
+
+    @Test
+    public void testSkip() throws Exception {
+        final InputStream input = new TestNullInputStream(10, true, false);
+        assertEquals(0, input.read(), "Read 1");
+        assertEquals(1, input.read(), "Read 2");
+        assertEquals(5, input.skip(5), "Skip 1");
+        assertEquals(7, input.read(), "Read 3");
+        assertEquals(2, input.skip(5), "Skip 2"); // only 2 left to skip
+        assertEquals(-1, input.skip(5), "Skip 3 (EOF)"); // End of file
+        try {
+            input.skip(5); //
+            fail("Expected IOException for skipping after end of file");
+        } catch (final IOException e) {
+            assertEquals("Skip after end of file", e.getMessage(), "Skip after EOF IOException message");
+        }
+        input.close();
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/NullReaderTest.java b/src/test/java/org/apache/commons/io/input/NullReaderTest.java
new file mode 100644
index 0000000..39d8673
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/NullReaderTest.java
@@ -0,0 +1,223 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.Reader;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link NullReader}.
+ *
+ */
+public class NullReaderTest {
+
+    private static final class TestNullReader extends NullReader {
+        public TestNullReader(final int size) {
+            super(size);
+        }
+        public TestNullReader(final int size, final boolean markSupported, final boolean throwEofException) {
+            super(size, markSupported, throwEofException);
+        }
+        @Override
+        protected int processChar() {
+            return (int)getPosition() - 1;
+        }
+        @Override
+        protected void processChars(final char[] chars, final int offset, final int length) {
+            final int startPos = (int)getPosition() - length;
+            for (int i = offset; i < length; i++) {
+                chars[i] = (char)(startPos + i);
+            }
+        }
+
+    }
+
+    // Use the same message as in java.io.InputStream.reset() in OpenJDK 8.0.275-1.
+    private static final String MARK_RESET_NOT_SUPPORTED = "mark/reset not supported";
+
+    @Test
+    public void testEOFException() throws Exception {
+        try (Reader reader = new TestNullReader(2, false, true)) {
+            assertEquals(0, reader.read(), "Read 1");
+            assertEquals(1, reader.read(), "Read 2");
+            assertThrows(EOFException.class, () -> reader.read());
+        }
+    }
+
+    @Test
+    public void testMarkAndReset() throws Exception {
+        int position = 0;
+        final int readlimit = 10;
+        try (Reader reader = new TestNullReader(100, true, false)) {
+
+            assertTrue(reader.markSupported(), "Mark Should be Supported");
+
+            // No Mark
+            try {
+                reader.reset();
+                fail("Read limit exceeded, expected IOException ");
+            } catch (final IOException e) {
+                assertEquals("No position has been marked", e.getMessage(), "No Mark IOException message");
+            }
+
+            for (; position < 3; position++) {
+                assertEquals(position, reader.read(), "Read Before Mark [" + position + "]");
+            }
+
+            // Mark
+            reader.mark(readlimit);
+
+            // Read further
+            for (int i = 0; i < 3; i++) {
+                assertEquals(position + i, reader.read(), "Read After Mark [" + i + "]");
+            }
+
+            // Reset
+            reader.reset();
+
+            // Read From marked position
+            for (int i = 0; i < readlimit + 1; i++) {
+                assertEquals(position + i, reader.read(), "Read After Reset [" + i + "]");
+            }
+
+            // Reset after read limit passed
+            try {
+                reader.reset();
+                fail("Read limit exceeded, expected IOException ");
+            } catch (final IOException e) {
+                assertEquals("Marked position [" + position + "] is no longer valid - passed the read limit [" + readlimit + "]", e.getMessage(),
+                    "Read limit IOException message");
+            }
+        }
+    }
+
+    @Test
+    public void testMarkNotSupported() throws Exception {
+        final Reader reader = new TestNullReader(100, false, true);
+        assertFalse(reader.markSupported(), "Mark Should NOT be Supported");
+
+        try {
+            reader.mark(5);
+            fail("mark() should throw UnsupportedOperationException");
+        } catch (final UnsupportedOperationException e) {
+            assertEquals(MARK_RESET_NOT_SUPPORTED, e.getMessage(), "mark() error message");
+        }
+
+        try {
+            reader.reset();
+            fail("reset() should throw UnsupportedOperationException");
+        } catch (final UnsupportedOperationException e) {
+            assertEquals(MARK_RESET_NOT_SUPPORTED, e.getMessage(), "reset() error message");
+        }
+        reader.close();
+    }
+
+    @Test
+    public void testRead() throws Exception {
+        final int size = 5;
+        final TestNullReader reader = new TestNullReader(size);
+        for (int i = 0; i < size; i++) {
+            assertEquals(i, reader.read(), "Check Value [" + i + "]");
+        }
+
+        // Check End of File
+        assertEquals(-1, reader.read(), "End of File");
+
+        // Test reading after the end of file
+        try {
+            final int result = reader.read();
+            fail("Should have thrown an IOException, value=[" + result + "]");
+        } catch (final IOException e) {
+            assertEquals("Read after end of file", e.getMessage());
+        }
+
+        // Close - should reset
+        reader.close();
+        assertEquals(0, reader.getPosition(), "Available after close");
+    }
+
+    @Test
+    public void testReadCharArray() throws Exception {
+        final char[] chars = new char[10];
+        final Reader reader = new TestNullReader(15);
+
+        // Read into array
+        final int count1 = reader.read(chars);
+        assertEquals(chars.length, count1, "Read 1");
+        for (int i = 0; i < count1; i++) {
+            assertEquals(i, chars[i], "Check Chars 1");
+        }
+
+        // Read into array
+        final int count2 = reader.read(chars);
+        assertEquals(5, count2, "Read 2");
+        for (int i = 0; i < count2; i++) {
+            assertEquals(count1 + i, chars[i], "Check Chars 2");
+        }
+
+        // End of File
+        final int count3 = reader.read(chars);
+        assertEquals(-1, count3, "Read 3 (EOF)");
+
+        // Test reading after the end of file
+        try {
+            final int count4 = reader.read(chars);
+            fail("Should have thrown an IOException, value=[" + count4 + "]");
+        } catch (final IOException e) {
+            assertEquals("Read after end of file", e.getMessage());
+        }
+
+        // reset by closing
+        reader.close();
+
+        // Read into array using offset & length
+        final int offset = 2;
+        final int lth    = 4;
+        final int count5 = reader.read(chars, offset, lth);
+        assertEquals(lth, count5, "Read 5");
+        for (int i = offset; i < lth; i++) {
+            assertEquals(i, chars[i], "Check Chars 3");
+        }
+    }
+
+    @Test
+    public void testSkip() throws Exception {
+        try (Reader reader = new TestNullReader(10, true, false)) {
+            assertEquals(0, reader.read(), "Read 1");
+            assertEquals(1, reader.read(), "Read 2");
+            assertEquals(5, reader.skip(5), "Skip 1");
+            assertEquals(7, reader.read(), "Read 3");
+            assertEquals(2, reader.skip(5), "Skip 2"); // only 2 left to skip
+            assertEquals(-1, reader.skip(5), "Skip 3 (EOF)"); // End of file
+            try {
+                reader.skip(5); //
+                fail("Expected IOException for skipping after end of file");
+            } catch (final IOException e) {
+                assertEquals("Skip after end of file", e.getMessage(), "Skip after EOF IOException message");
+            }
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/ObservableInputStreamTest.java b/src/test/java/org/apache/commons/io/input/ObservableInputStreamTest.java
new file mode 100644
index 0000000..0a0e915
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/ObservableInputStreamTest.java
@@ -0,0 +1,310 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.input.ObservableInputStream.Observer;
+import org.apache.commons.io.output.NullOutputStream;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link ObservableInputStream}.
+ */
+public class ObservableInputStreamTest {
+
+    private static class DataViewObserver extends MethodCountObserver {
+        private byte[] buffer;
+        private int lastValue = -1;
+        private int length = -1;
+        private int offset = -1;
+
+        @Override
+        public void data(final byte[] buffer, final int offset, final int length) throws IOException {
+            this.buffer = buffer;
+            this.offset = offset;
+            this.length = length;
+        }
+
+        @Override
+        public void data(final int value) throws IOException {
+            super.data(value);
+            lastValue = value;
+        }
+    }
+
+    private static class LengthObserver extends Observer {
+        private long total;
+
+        @Override
+        public void data(final byte[] buffer, final int offset, final int length) throws IOException {
+            this.total += length;
+        }
+
+        @Override
+        public void data(final int value) throws IOException {
+            total++;
+        }
+
+        public long getTotal() {
+            return total;
+        }
+    }
+
+    private static class MethodCountObserver extends Observer {
+        private long closedCount;
+        private long dataBufferCount;
+        private long dataCount;
+        private long errorCount;
+        private long finishedCount;
+
+        @Override
+        public void closed() throws IOException {
+            closedCount++;
+        }
+
+        @Override
+        public void data(final byte[] buffer, final int offset, final int length) throws IOException {
+            dataBufferCount++;
+        }
+
+        @Override
+        public void data(final int value) throws IOException {
+            dataCount++;
+        }
+
+        @Override
+        public void error(final IOException exception) throws IOException {
+            errorCount++;
+        }
+
+        @Override
+        public void finished() throws IOException {
+            finishedCount++;
+        }
+
+        public long getClosedCount() {
+            return closedCount;
+        }
+
+        public long getDataBufferCount() {
+            return dataBufferCount;
+        }
+
+        public long getDataCount() {
+            return dataCount;
+        }
+
+        public long getErrorCount() {
+            return errorCount;
+        }
+
+        public long getFinishedCount() {
+            return finishedCount;
+        }
+
+    }
+
+    @Test
+    public void testBrokenInputStreamRead() throws IOException {
+        try (ObservableInputStream ois = new ObservableInputStream(BrokenInputStream.INSTANCE)) {
+            assertThrows(IOException.class, ois::read);
+        }
+    }
+
+    @Test
+    public void testBrokenInputStreamReadBuffer() throws IOException {
+        try (ObservableInputStream ois = new ObservableInputStream(BrokenInputStream.INSTANCE)) {
+            assertThrows(IOException.class, () -> ois.read(new byte[1]));
+        }
+    }
+
+    @Test
+    public void testBrokenInputStreamReadSubBuffer() throws IOException {
+        try (ObservableInputStream ois = new ObservableInputStream(BrokenInputStream.INSTANCE)) {
+            assertThrows(IOException.class, () -> ois.read(new byte[2], 0, 1));
+        }
+    }
+
+    /**
+     * Tests that {@link Observer#data(int)} is called.
+     */
+    @Test
+    public void testDataByteCalled_add() throws Exception {
+        final byte[] buffer = MessageDigestCalculatingInputStreamTest
+            .generateRandomByteStream(IOUtils.DEFAULT_BUFFER_SIZE);
+        final DataViewObserver lko = new DataViewObserver();
+        try (ObservableInputStream ois = new ObservableInputStream(new ByteArrayInputStream(buffer))) {
+            assertEquals(-1, lko.lastValue);
+            ois.read();
+            assertEquals(-1, lko.lastValue);
+            assertEquals(0, lko.getFinishedCount());
+            assertEquals(0, lko.getClosedCount());
+            ois.add(lko);
+            for (int i = 1; i < buffer.length; i++) {
+                final int result = ois.read();
+                assertEquals((byte) result, buffer[i]);
+                assertEquals(result, lko.lastValue);
+                assertEquals(0, lko.getFinishedCount());
+                assertEquals(0, lko.getClosedCount());
+            }
+            final int result = ois.read();
+            assertEquals(-1, result);
+            assertEquals(1, lko.getFinishedCount());
+            assertEquals(0, lko.getClosedCount());
+            ois.close();
+            assertEquals(1, lko.getFinishedCount());
+            assertEquals(1, lko.getClosedCount());
+        }
+    }
+
+    /**
+     * Tests that {@link Observer#data(int)} is called.
+     */
+    @Test
+    public void testDataByteCalled_ctor() throws Exception {
+        final byte[] buffer = MessageDigestCalculatingInputStreamTest
+            .generateRandomByteStream(IOUtils.DEFAULT_BUFFER_SIZE);
+        final DataViewObserver lko = new DataViewObserver();
+        try (ObservableInputStream ois = new ObservableInputStream(new ByteArrayInputStream(buffer), lko)) {
+            assertEquals(-1, lko.lastValue);
+            ois.read();
+            assertNotEquals(-1, lko.lastValue);
+            assertEquals(0, lko.getFinishedCount());
+            assertEquals(0, lko.getClosedCount());
+            for (int i = 1; i < buffer.length; i++) {
+                final int result = ois.read();
+                assertEquals((byte) result, buffer[i]);
+                assertEquals(result, lko.lastValue);
+                assertEquals(0, lko.getFinishedCount());
+                assertEquals(0, lko.getClosedCount());
+            }
+            final int result = ois.read();
+            assertEquals(-1, result);
+            assertEquals(1, lko.getFinishedCount());
+            assertEquals(0, lko.getClosedCount());
+            ois.close();
+            assertEquals(1, lko.getFinishedCount());
+            assertEquals(1, lko.getClosedCount());
+        }
+    }
+
+    /**
+     * Tests that {@link Observer#data(byte[],int,int)} is called.
+     */
+    @Test
+    public void testDataBytesCalled() throws Exception {
+        final byte[] buffer = MessageDigestCalculatingInputStreamTest
+            .generateRandomByteStream(IOUtils.DEFAULT_BUFFER_SIZE);
+        try (ByteArrayInputStream bais = new ByteArrayInputStream(buffer);
+            final ObservableInputStream ois = new ObservableInputStream(bais)) {
+            final DataViewObserver observer = new DataViewObserver();
+            final byte[] readBuffer = new byte[23];
+            assertNull(observer.buffer);
+            ois.read(readBuffer);
+            assertNull(observer.buffer);
+            ois.add(observer);
+            for (;;) {
+                if (bais.available() >= 2048) {
+                    final int result = ois.read(readBuffer);
+                    if (result == -1) {
+                        ois.close();
+                        break;
+                    }
+                    assertEquals(readBuffer, observer.buffer);
+                    assertEquals(0, observer.offset);
+                    assertEquals(readBuffer.length, observer.length);
+                } else {
+                    final int res = Math.min(11, bais.available());
+                    final int result = ois.read(readBuffer, 1, 11);
+                    if (result == -1) {
+                        ois.close();
+                        break;
+                    }
+                    assertEquals(readBuffer, observer.buffer);
+                    assertEquals(1, observer.offset);
+                    assertEquals(res, observer.length);
+                }
+            }
+        }
+    }
+
+    @Test
+    public void testGetObservers0() throws IOException {
+        try (ObservableInputStream ois = new ObservableInputStream(NullInputStream.INSTANCE)) {
+            assertTrue(ois.getObservers().isEmpty());
+        }
+    }
+
+    @Test
+    public void testGetObservers1() throws IOException {
+        final DataViewObserver observer0 = new DataViewObserver();
+        try (ObservableInputStream ois = new ObservableInputStream(NullInputStream.INSTANCE, observer0)) {
+            assertEquals(observer0, ois.getObservers().get(0));
+        }
+    }
+
+    @Test
+    public void testGetObserversOrder() throws IOException {
+        final DataViewObserver observer0 = new DataViewObserver();
+        final DataViewObserver observer1 = new DataViewObserver();
+        try (ObservableInputStream ois = new ObservableInputStream(NullInputStream.INSTANCE, observer0, observer1)) {
+            assertEquals(observer0, ois.getObservers().get(0));
+            assertEquals(observer1, ois.getObservers().get(1));
+        }
+    }
+
+    private void testNotificationCallbacks(final int bufferSize) throws IOException {
+        final byte[] buffer = IOUtils.byteArray();
+        final LengthObserver lengthObserver = new LengthObserver();
+        final MethodCountObserver methodCountObserver = new MethodCountObserver();
+        try (ObservableInputStream ois = new ObservableInputStream(new ByteArrayInputStream(buffer),
+            lengthObserver, methodCountObserver)) {
+            assertEquals(IOUtils.DEFAULT_BUFFER_SIZE,
+                IOUtils.copy(ois, NullOutputStream.INSTANCE, bufferSize));
+        }
+        assertEquals(IOUtils.DEFAULT_BUFFER_SIZE, lengthObserver.getTotal());
+        assertEquals(1, methodCountObserver.getClosedCount());
+        assertEquals(1, methodCountObserver.getFinishedCount());
+        assertEquals(0, methodCountObserver.getErrorCount());
+        assertEquals(0, methodCountObserver.getDataCount());
+        assertEquals(buffer.length / bufferSize, methodCountObserver.getDataBufferCount());
+    }
+
+    @Test
+    public void testNotificationCallbacksBufferSize1() throws Exception {
+        testNotificationCallbacks(1);
+    }
+
+    @Test
+    public void testNotificationCallbacksBufferSize2() throws Exception {
+        testNotificationCallbacks(2);
+    }
+
+    @Test
+    public void testNotificationCallbacksBufferSizeDefault() throws Exception {
+        testNotificationCallbacks(IOUtils.DEFAULT_BUFFER_SIZE);
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/ProxyReaderTest.java b/src/test/java/org/apache/commons/io/input/ProxyReaderTest.java
new file mode 100644
index 0000000..b176d48
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/ProxyReaderTest.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.CharBuffer;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link ProxyReader}.
+ */
+public class ProxyReaderTest {
+
+    /** Custom NullReader implementation. */
+    private static class CustomNullReader extends NullReader {
+        CustomNullReader(final int len) {
+            super(len);
+        }
+
+        @Override
+        public int read(final char[] chars) throws IOException {
+            return chars == null ? 0 : super.read(chars);
+        }
+
+        @Override
+        public int read(final CharBuffer target) throws IOException {
+            return target == null ? 0 : super.read(target);
+        }
+    }
+
+    /** ProxyReader implementation. */
+    private static class ProxyReaderImpl extends ProxyReader {
+        ProxyReaderImpl(final Reader proxy) {
+            super(proxy);
+        }
+    }
+
+    @Test
+    public void testNullCharArray() throws Exception {
+        try (ProxyReader proxy = new ProxyReaderImpl(new CustomNullReader(0))) {
+            proxy.read((char[]) null);
+            proxy.read(null, 0, 0);
+        }
+    }
+
+    @Test
+    public void testNullCharBuffer() throws Exception {
+        try (ProxyReader proxy = new ProxyReaderImpl(new CustomNullReader(0))) {
+            proxy.read((CharBuffer) null);
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/QueueInputStreamTest.java b/src/test/java/org/apache/commons/io/input/QueueInputStreamTest.java
new file mode 100644
index 0000000..2902bb9
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/QueueInputStreamTest.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.output.QueueOutputStream;
+import org.apache.commons.io.output.QueueOutputStreamTest;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Test {@link QueueInputStream}.
+ *
+ * @see {@link QueueOutputStreamTest}
+ */
+public class QueueInputStreamTest {
+
+    public static Stream<Arguments> inputData() {
+        return Stream.of(Arguments.of(""),
+                Arguments.of("1"),
+                Arguments.of("12"),
+                Arguments.of("1234"),
+                Arguments.of("12345678"),
+                Arguments.of(StringUtils.repeat("A", 4095)),
+                Arguments.of(StringUtils.repeat("A", 4096)),
+                Arguments.of(StringUtils.repeat("A", 4097)),
+                Arguments.of(StringUtils.repeat("A", 8191)),
+                Arguments.of(StringUtils.repeat("A", 8192)),
+                Arguments.of(StringUtils.repeat("A", 8193)),
+                Arguments.of(StringUtils.repeat("A", 8192 * 4)));
+    }
+
+    @ParameterizedTest(name = "inputData={0}")
+    @MethodSource("inputData")
+    public void bufferedReads(final String inputData) throws IOException {
+        final BlockingQueue<Integer> queue = new LinkedBlockingQueue<>();
+        try (BufferedInputStream inputStream = new BufferedInputStream(new QueueInputStream(queue));
+                final QueueOutputStream outputStream = new QueueOutputStream(queue)) {
+            outputStream.write(inputData.getBytes(UTF_8));
+            final String actualData = IOUtils.toString(inputStream, UTF_8);
+            assertEquals(inputData, actualData);
+        }
+    }
+
+    @ParameterizedTest(name = "inputData={0}")
+    @MethodSource("inputData")
+    public void bufferedReadWrite(final String inputData) throws IOException {
+        final BlockingQueue<Integer> queue = new LinkedBlockingQueue<>();
+        try (BufferedInputStream inputStream = new BufferedInputStream(new QueueInputStream(queue));
+                final BufferedOutputStream outputStream = new BufferedOutputStream(new QueueOutputStream(queue), defaultBufferSize())) {
+            outputStream.write(inputData.getBytes(UTF_8));
+            outputStream.flush();
+            final String dataCopy = IOUtils.toString(inputStream, UTF_8);
+            assertEquals(inputData, dataCopy);
+        }
+    }
+
+    @ParameterizedTest(name = "inputData={0}")
+    @MethodSource("inputData")
+    public void bufferedWrites(final String inputData) throws IOException {
+        final BlockingQueue<Integer> queue = new LinkedBlockingQueue<>();
+        try (QueueInputStream inputStream = new QueueInputStream(queue);
+                final BufferedOutputStream outputStream = new BufferedOutputStream(new QueueOutputStream(queue), defaultBufferSize())) {
+            outputStream.write(inputData.getBytes(UTF_8));
+            outputStream.flush();
+            final String actualData = readUnbuffered(inputStream);
+            assertEquals(inputData, actualData);
+        }
+    }
+
+    private int defaultBufferSize() {
+        return 8192;
+    }
+
+    private String readUnbuffered(final InputStream inputStream) throws IOException {
+        final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
+        int n = -1;
+        while ((n = inputStream.read()) != -1) {
+            byteArrayOutputStream.write(n);
+        }
+        return byteArrayOutputStream.toString(StandardCharsets.UTF_8.name());
+    }
+
+    @Test
+    public void testNullArgument() {
+        assertThrows(NullPointerException.class, () -> new QueueInputStream(null), "queue is required");
+    }
+
+    @ParameterizedTest(name = "inputData={0}")
+    @MethodSource("inputData")
+    public void unbufferedReadWrite(final String inputData) throws IOException {
+        try (QueueInputStream inputStream = new QueueInputStream();
+                final QueueOutputStream outputStream = inputStream.newQueueOutputStream()) {
+            writeUnbuffered(outputStream, inputData);
+            final String actualData = readUnbuffered(inputStream);
+            assertEquals(inputData, actualData);
+        }
+    }
+
+    private void writeUnbuffered(final QueueOutputStream outputStream, final String inputData) throws InterruptedIOException {
+        final byte[] bytes = inputData.getBytes(UTF_8);
+        for (final byte oneByte : bytes) {
+            outputStream.write(oneByte);
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/RandomAccessFileInputStreamTest.java b/src/test/java/org/apache/commons/io/input/RandomAccessFileInputStreamTest.java
new file mode 100644
index 0000000..45e1d01
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/RandomAccessFileInputStreamTest.java
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.commons.io.RandomAccessFileMode;
+import org.junit.jupiter.api.Test;
+
+public class RandomAccessFileInputStreamTest {
+
+    private static final String DATA_FILE = "src/test/resources/org/apache/commons/io/test-file-iso8859-1.bin";
+    private static final int DATA_FILE_LEN = 1430;
+
+    private RandomAccessFile createRandomAccessFile() throws FileNotFoundException {
+        return RandomAccessFileMode.READ_ONLY.create(DATA_FILE);
+    }
+
+    @Test
+    public void testAvailable() throws IOException {
+        try (RandomAccessFileInputStream inputStream = new RandomAccessFileInputStream(createRandomAccessFile(),
+            true)) {
+            assertEquals(DATA_FILE_LEN, inputStream.available());
+        }
+    }
+
+    @Test
+    public void testAvailableLong() throws IOException {
+        try (RandomAccessFileInputStream inputStream = new RandomAccessFileInputStream(createRandomAccessFile(),
+            true)) {
+            assertEquals(DATA_FILE_LEN, inputStream.availableLong());
+        }
+    }
+
+    @Test
+    public void testConstructorCloseOnCloseFalse() throws IOException {
+        try (RandomAccessFile file = createRandomAccessFile()) {
+            try (RandomAccessFileInputStream inputStream = new RandomAccessFileInputStream(file, false)) {
+                assertFalse(inputStream.isCloseOnClose());
+            }
+            file.read();
+        }
+    }
+
+    @Test
+    public void testConstructorCloseOnCloseTrue() throws IOException {
+        try (RandomAccessFile file = createRandomAccessFile()) {
+            try (RandomAccessFileInputStream inputStream = new RandomAccessFileInputStream(file, true)) {
+                assertTrue(inputStream.isCloseOnClose());
+            }
+            assertThrows(IOException.class, file::read);
+        }
+    }
+
+    @Test
+    public void testConstructorFile() throws IOException {
+        try (RandomAccessFile file = createRandomAccessFile()) {
+            try (RandomAccessFileInputStream inputStream = new RandomAccessFileInputStream(file)) {
+                assertFalse(inputStream.isCloseOnClose());
+            }
+            file.read();
+        }
+    }
+
+    @Test
+    public void testConstructorFileNull() {
+        assertThrows(NullPointerException.class, () -> new RandomAccessFileInputStream(null));
+    }
+
+    @Test
+    public void testGetters() throws IOException {
+        try (RandomAccessFile file = createRandomAccessFile()) {
+            try (RandomAccessFileInputStream inputStream = new RandomAccessFileInputStream(file, true)) {
+                assertEquals(file, inputStream.getRandomAccessFile());
+                assertTrue(inputStream.isCloseOnClose());
+            }
+        }
+    }
+
+    @Test
+    public void testRead() throws IOException {
+        try (RandomAccessFileInputStream inputStream = new RandomAccessFileInputStream(createRandomAccessFile(),
+            true)) {
+            // A Test Line.
+            assertEquals('A', inputStream.read());
+            assertEquals(' ', inputStream.read());
+            assertEquals('T', inputStream.read());
+            assertEquals('e', inputStream.read());
+            assertEquals('s', inputStream.read());
+            assertEquals('t', inputStream.read());
+            assertEquals(' ', inputStream.read());
+            assertEquals('L', inputStream.read());
+            assertEquals('i', inputStream.read());
+            assertEquals('n', inputStream.read());
+            assertEquals('e', inputStream.read());
+            assertEquals('.', inputStream.read());
+            assertEquals(DATA_FILE_LEN - 12, inputStream.available());
+            assertEquals(DATA_FILE_LEN - 12, inputStream.availableLong());
+        }
+    }
+
+    @Test
+    public void testReadByteArray() throws IOException {
+        try (RandomAccessFileInputStream inputStream = new RandomAccessFileInputStream(createRandomAccessFile(),
+            true)) {
+            // A Test Line.
+            final int dataLen = 12;
+            final byte[] buffer = new byte[dataLen];
+            assertEquals(dataLen, inputStream.read(buffer));
+            assertArrayEquals("A Test Line.".getBytes(StandardCharsets.ISO_8859_1), buffer);
+            //
+            assertEquals(DATA_FILE_LEN - dataLen, inputStream.available());
+            assertEquals(DATA_FILE_LEN - dataLen, inputStream.availableLong());
+        }
+    }
+
+    @Test
+    public void testReadByteArrayBounds() throws IOException {
+        try (RandomAccessFileInputStream inputStream = new RandomAccessFileInputStream(createRandomAccessFile(),
+            true)) {
+            // A Test Line.
+            final int dataLen = 12;
+            final byte[] buffer = new byte[dataLen];
+            assertEquals(dataLen, inputStream.read(buffer, 0, dataLen));
+            assertArrayEquals("A Test Line.".getBytes(StandardCharsets.ISO_8859_1), buffer);
+            //
+            assertEquals(DATA_FILE_LEN - dataLen, inputStream.available());
+            assertEquals(DATA_FILE_LEN - dataLen, inputStream.availableLong());
+        }
+    }
+
+    @Test
+    public void testSkip() throws IOException {
+
+        try (RandomAccessFile file = createRandomAccessFile();
+            final RandomAccessFileInputStream inputStream = new RandomAccessFileInputStream(file, false)) {
+            assertEquals(0, inputStream.skip(-1));
+            assertEquals(0, inputStream.skip(Integer.MIN_VALUE));
+            assertEquals(0, inputStream.skip(0));
+            // A Test Line.
+            assertEquals('A', inputStream.read());
+            assertEquals(1, inputStream.skip(1));
+            assertEquals('T', inputStream.read());
+            assertEquals(1, inputStream.skip(1));
+            assertEquals('s', inputStream.read());
+            assertEquals(1, inputStream.skip(1));
+            assertEquals(' ', inputStream.read());
+            assertEquals(1, inputStream.skip(1));
+            assertEquals('i', inputStream.read());
+            assertEquals(1, inputStream.skip(1));
+            assertEquals('e', inputStream.read());
+            assertEquals(1, inputStream.skip(1));
+            //
+            assertEquals(DATA_FILE_LEN - 12, inputStream.available());
+            assertEquals(DATA_FILE_LEN - 12, inputStream.availableLong());
+            assertEquals(10, inputStream.skip(10));
+            assertEquals(DATA_FILE_LEN - 22, inputStream.availableLong());
+            //
+            final long avail = inputStream.availableLong();
+            assertEquals(avail, inputStream.skip(inputStream.availableLong()));
+            // At EOF
+            assertEquals(DATA_FILE_LEN, file.length());
+            assertEquals(DATA_FILE_LEN, file.getFilePointer());
+            //
+            assertEquals(0, inputStream.skip(1));
+            assertEquals(0, inputStream.skip(1000000000000L));
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/ReadAheadInputStreamTest.java b/src/test/java/org/apache/commons/io/input/ReadAheadInputStreamTest.java
new file mode 100644
index 0000000..cf1c643
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/ReadAheadInputStreamTest.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.junit.jupiter.api.BeforeEach;
+
+/**
+ * Tests {@link ReadAheadInputStream}.
+ *
+ * This class was ported and adapted from Apache Spark commit 933dc6cb7b3de1d8ccaf73d124d6eb95b947ed19 where it was
+ * called {@code ReadAheadInputStreamSuite}.
+ */
+public class ReadAheadInputStreamTest extends AbstractInputStreamTest {
+
+    @SuppressWarnings("resource")
+    @Override
+    @BeforeEach
+    public void setUp() throws IOException {
+        super.setUp();
+        inputStreams = new InputStream[] {
+            // Tests equal and aligned buffers of wrapped an outer stream.
+            new ReadAheadInputStream(new BufferedFileChannelInputStream(inputFile, 8 * 1024), 8 * 1024),
+            // Tests aligned buffers, wrapped bigger than outer.
+            new ReadAheadInputStream(new BufferedFileChannelInputStream(inputFile, 3 * 1024), 2 * 1024),
+            // Tests aligned buffers, wrapped smaller than outer.
+            new ReadAheadInputStream(new BufferedFileChannelInputStream(inputFile, 2 * 1024), 3 * 1024),
+            // Tests unaligned buffers, wrapped bigger than outer.
+            new ReadAheadInputStream(new BufferedFileChannelInputStream(inputFile, 321), 123),
+            // Tests unaligned buffers, wrapped smaller than outer.
+            new ReadAheadInputStream(new BufferedFileChannelInputStream(inputFile, 123), 321)};
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/ReaderInputStreamTest.java b/src/test/java/org/apache/commons/io/input/ReaderInputStreamTest.java
new file mode 100644
index 0000000..b305ccf
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/ReaderInputStreamTest.java
@@ -0,0 +1,247 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.CharArrayReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringReader;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetEncoder;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Random;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+public class ReaderInputStreamTest {
+
+    private static final String UTF_16 = StandardCharsets.UTF_16.name();
+    private static final String UTF_8 = StandardCharsets.UTF_8.name();
+    private static final String TEST_STRING = "\u00e0 peine arriv\u00e9s nous entr\u00e2mes dans sa chambre";
+    private static final String LARGE_TEST_STRING;
+
+    static {
+        final StringBuilder buffer = new StringBuilder();
+        for (int i = 0; i < 100; i++) {
+            buffer.append(TEST_STRING);
+        }
+        LARGE_TEST_STRING = buffer.toString();
+    }
+
+    static Stream<Arguments> charsetData() {
+        // @formatter:off
+        return Stream.of(
+                Arguments.of("Cp930", "\u0391"),
+                Arguments.of("ISO_8859_1", "A"),
+                Arguments.of(UTF_8, "\u0391"));
+        // @formatter:on
+    }
+
+    private final Random random = new Random();
+
+    @Test
+    @Timeout(value = 500, unit = TimeUnit.MILLISECONDS)
+    public void testBufferSmallest() throws IOException {
+        final Charset charset = StandardCharsets.UTF_8;
+        try (InputStream in = new ReaderInputStream(new StringReader("\uD800"), charset, (int) ReaderInputStream.minBufferSize(charset.newEncoder()))) {
+            in.read();
+        }
+    }
+
+    @Test
+    public void testBufferTooSmall() {
+        assertThrows(IllegalArgumentException.class, () -> new ReaderInputStream(new StringReader("\uD800"), StandardCharsets.UTF_8, -1));
+        assertThrows(IllegalArgumentException.class, () -> new ReaderInputStream(new StringReader("\uD800"), StandardCharsets.UTF_8, 0));
+        assertThrows(IllegalArgumentException.class, () -> new ReaderInputStream(new StringReader("\uD800"), StandardCharsets.UTF_8, 1));
+    }
+
+    @ParameterizedTest
+    @MethodSource("charsetData")
+    public void testCharsetEncoderFlush(final String charsetName, final String data) throws IOException {
+        final Charset charset = Charset.forName(charsetName);
+        final byte[] expected = data.getBytes(charset);
+        try (InputStream in = new ReaderInputStream(new StringReader(data), charset)) {
+            final byte[] actual = IOUtils.toByteArray(in);
+            assertEquals(Arrays.toString(expected), Arrays.toString(actual));
+        }
+    }
+
+    /*
+     * Tests https://issues.apache.org/jira/browse/IO-277
+     */
+    @Test
+    public void testCharsetMismatchInfiniteLoop() throws IOException {
+        // Input is UTF-8 bytes: 0xE0 0xB2 0xA0
+        final char[] inputChars = {(char) 0xE0, (char) 0xB2, (char) 0xA0};
+        // Charset charset = Charset.forName("UTF-8"); // works
+        final Charset charset = StandardCharsets.US_ASCII; // infinite loop
+        try (ReaderInputStream stream = new ReaderInputStream(new CharArrayReader(inputChars), charset)) {
+            IOUtils.toCharArray(stream, charset);
+        }
+    }
+
+    /**
+     * Tests IO-717 to avoid infinite loops.
+     *
+     * ReaderInputStream does not throw exception with {@link CodingErrorAction#REPORT}.
+     */
+    @Test
+    @Timeout(value = 500, unit = TimeUnit.MILLISECONDS)
+    public void testCodingErrorAction() throws IOException {
+        final Charset charset = StandardCharsets.UTF_8;
+        final CharsetEncoder encoder = charset.newEncoder().onMalformedInput(CodingErrorAction.REPORT);
+        try (InputStream in = new ReaderInputStream(new StringReader("\uD800aa"), encoder, (int) ReaderInputStream.minBufferSize(charset.newEncoder()))) {
+            assertThrows(CharacterCodingException.class, in::read);
+        }
+    }
+
+    @Test
+    @Timeout(value = 500, unit = TimeUnit.MILLISECONDS)
+    public void testConstructNullCharset() throws IOException {
+        final Charset charset = Charset.defaultCharset();
+        final Charset encoder = null;
+        try (ReaderInputStream in = new ReaderInputStream(new StringReader("ABC"), encoder, (int) ReaderInputStream.minBufferSize(charset.newEncoder()))) {
+            IOUtils.toByteArray(in);
+            assertEquals(Charset.defaultCharset(), in.getCharsetEncoder().charset());
+        }
+    }
+
+    @Test
+    @Timeout(value = 500, unit = TimeUnit.MILLISECONDS)
+    public void testConstructNullCharsetEncoder() throws IOException {
+        final Charset charset = Charset.defaultCharset();
+        final CharsetEncoder encoder = null;
+        try (ReaderInputStream in = new ReaderInputStream(new StringReader("ABC"), encoder, (int) ReaderInputStream.minBufferSize(charset.newEncoder()))) {
+            IOUtils.toByteArray(in);
+            assertEquals(Charset.defaultCharset(), in.getCharsetEncoder().charset());
+        }
+    }
+
+    @Test
+    @Timeout(value = 500, unit = TimeUnit.MILLISECONDS)
+    public void testConstructNullCharsetNameEncoder() throws IOException {
+        final Charset charset = Charset.defaultCharset();
+        final String encoder = null;
+        try (ReaderInputStream in = new ReaderInputStream(new StringReader("ABC"), encoder, (int) ReaderInputStream.minBufferSize(charset.newEncoder()))) {
+            IOUtils.toByteArray(in);
+            assertEquals(Charset.defaultCharset(), in.getCharsetEncoder().charset());
+        }
+    }
+
+    @Test
+    public void testLargeUTF8WithBufferedRead() throws IOException {
+        testWithBufferedRead(LARGE_TEST_STRING, UTF_8);
+    }
+
+    @Test
+    public void testLargeUTF8WithSingleByteRead() throws IOException {
+        testWithSingleByteRead(LARGE_TEST_STRING, UTF_8);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Test
+    public void testReadZero() throws Exception {
+        final String inStr = "test";
+        try (ReaderInputStream inputStream = new ReaderInputStream(new StringReader(inStr))) {
+            final byte[] bytes = new byte[30];
+            assertEquals(0, inputStream.read(bytes, 0, 0));
+            assertEquals(inStr.length(), inputStream.read(bytes, 0, inStr.length() + 1));
+            // Should always return 0 for length == 0
+            assertEquals(0, inputStream.read(bytes, 0, 0));
+        }
+    }
+
+    @SuppressWarnings("deprecation")
+    @Test
+    public void testReadZeroEmptyString() throws Exception {
+        try (ReaderInputStream inputStream = new ReaderInputStream(new StringReader(""))) {
+            final byte[] bytes = new byte[30];
+            // Should always return 0 for length == 0
+            assertEquals(0, inputStream.read(bytes, 0, 0));
+            assertEquals(-1, inputStream.read(bytes, 0, 1));
+            assertEquals(0, inputStream.read(bytes, 0, 0));
+            assertEquals(-1, inputStream.read(bytes, 0, 1));
+        }
+    }
+
+    @Test
+    public void testUTF16WithSingleByteRead() throws IOException {
+        testWithSingleByteRead(TEST_STRING, UTF_16);
+    }
+
+    @Test
+    public void testUTF8WithBufferedRead() throws IOException {
+        testWithBufferedRead(TEST_STRING, UTF_8);
+    }
+
+    @Test
+    public void testUTF8WithSingleByteRead() throws IOException {
+        testWithSingleByteRead(TEST_STRING, UTF_8);
+    }
+
+    private void testWithBufferedRead(final String testString, final String charsetName) throws IOException {
+        final byte[] expected = testString.getBytes(charsetName);
+        try (ReaderInputStream in = new ReaderInputStream(new StringReader(testString), charsetName)) {
+            final byte[] buffer = new byte[128];
+            int offset = 0;
+            while (true) {
+                int bufferOffset = random.nextInt(64);
+                final int bufferLength = random.nextInt(64);
+                int read = in.read(buffer, bufferOffset, bufferLength);
+                if (read == -1) {
+                    assertEquals(offset, expected.length);
+                    break;
+                }
+                assertTrue(read <= bufferLength);
+                while (read > 0) {
+                    assertTrue(offset < expected.length);
+                    assertEquals(expected[offset], buffer[bufferOffset]);
+                    offset++;
+                    bufferOffset++;
+                    read--;
+                }
+            }
+        }
+    }
+
+    private void testWithSingleByteRead(final String testString, final String charsetName) throws IOException {
+        final byte[] bytes = testString.getBytes(charsetName);
+        try (ReaderInputStream in = new ReaderInputStream(new StringReader(testString), charsetName)) {
+            for (final byte b : bytes) {
+                final int read = in.read();
+                assertTrue(read >= 0);
+                assertTrue(read <= 255);
+                assertEquals(b, (byte) read);
+            }
+            assertEquals(-1, in.read());
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/ReversedLinesFileReaderTestParamBlockSize.java b/src/test/java/org/apache/commons/io/input/ReversedLinesFileReaderTestParamBlockSize.java
new file mode 100644
index 0000000..aa84f61
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/ReversedLinesFileReaderTestParamBlockSize.java
@@ -0,0 +1,255 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.StandardLineSeparator.CR;
+import static org.apache.commons.io.StandardLineSeparator.LF;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.util.stream.IntStream;
+
+import org.apache.commons.io.TestResources;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+public class ReversedLinesFileReaderTestParamBlockSize {
+
+    private static final String UTF_8 = StandardCharsets.UTF_8.name();
+    private static final String ISO_8859_1 = StandardCharsets.ISO_8859_1.name();
+
+    // "A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ¥£±²®"
+    private static final String TEST_LINE = "A Test Line. Special chars: "
+        + "\u00C4\u00E4\u00DC\u00FC\u00D6\u00F6\u00DF \u00C3\u00E1\u00E9\u00ED\u00EF\u00E7\u00F1\u00C2 \u00A9\u00B5\u00A5\u00A3\u00B1\u00B2\u00AE";
+
+    // Hiragana letters: �����
+    private static final String TEST_LINE_SHIFT_JIS1 = "Hiragana letters: \u3041\u3042\u3043\u3044\u3045";
+
+    // Strings are escaped in constants to avoid java source encoding issues (source file enc is UTF-8):
+
+    // Kanji letters: 明輸�京
+    private static final String TEST_LINE_SHIFT_JIS2 = "Kanji letters: \u660E\u8F38\u5B50\u4EAC";
+    // windows-31j characters
+    private static final String TEST_LINE_WINDOWS_31J_1 = "\u3041\u3042\u3043\u3044\u3045";
+    private static final String TEST_LINE_WINDOWS_31J_2 = "\u660E\u8F38\u5B50\u4EAC";
+    // gbk characters (Simplified Chinese)
+    private static final String TEST_LINE_GBK_1 = "\u660E\u8F38\u5B50\u4EAC";
+    private static final String TEST_LINE_GBK_2 = "\u7B80\u4F53\u4E2D\u6587";
+    // x-windows-949 characters (Korean)
+    private static final String TEST_LINE_X_WINDOWS_949_1 = "\uD55C\uAD6D\uC5B4";
+    private static final String TEST_LINE_X_WINDOWS_949_2 = "\uB300\uD55C\uBBFC\uAD6D";
+    // x-windows-950 characters (Traditional Chinese)
+    private static final String TEST_LINE_X_WINDOWS_950_1 = "\u660E\u8F38\u5B50\u4EAC";
+    private static final String TEST_LINE_X_WINDOWS_950_2 = "\u7E41\u9AD4\u4E2D\u6587";
+
+    static void assertEqualsAndNoLineBreaks(final String expected, final String actual) {
+        assertEqualsAndNoLineBreaks(null, expected, actual);
+    }
+
+    static void assertEqualsAndNoLineBreaks(final String msg, final String expected, final String actual) {
+        if (actual != null) {
+            assertFalse(actual.contains(LF.getString()), "Line contains \\n: line=" + actual);
+            assertFalse(actual.contains(CR.getString()), "Line contains \\r: line=" + actual);
+        }
+        assertEquals(expected, actual, msg);
+    }
+
+
+    // small and uneven block sizes are not used in reality but are good to show that the algorithm is solid
+    public static IntStream blockSizes() {
+        return IntStream.of(1, 3, 8, 256, 4096);
+    }
+
+    private ReversedLinesFileReader reversedLinesFileReader;
+
+    private void assertFileWithShrinkingTestLines(final ReversedLinesFileReader reversedLinesFileReader) throws IOException {
+        String line = null;
+        int lineCount = 0;
+        while ((line = reversedLinesFileReader.readLine()) != null) {
+            lineCount++;
+            assertEqualsAndNoLineBreaks("Line " + lineCount + " is not matching", TEST_LINE.substring(0, lineCount), line);
+        }
+    }
+
+    @AfterEach
+    public void closeReader() {
+        try {
+            if (reversedLinesFileReader != null) {
+                reversedLinesFileReader.close();
+            }
+        } catch (final Exception e) {
+            // ignore
+        }
+    }
+
+    @ParameterizedTest(name = "BlockSize={0}")
+    @MethodSource("blockSizes")
+    public void testEmptyFile(final int testParamBlockSize) throws URISyntaxException, IOException {
+        final File testFileEmpty = TestResources.getFile("/test-file-empty.bin");
+        reversedLinesFileReader = new ReversedLinesFileReader(testFileEmpty, testParamBlockSize, UTF_8);
+        assertNull(reversedLinesFileReader.readLine());
+    }
+
+    @Test
+    public void testFileSizeIsExactMultipleOfBlockSize() throws URISyntaxException, IOException {
+        final int blockSize = 10;
+        final File testFile20Bytes = TestResources.getFile("/test-file-20byteslength.bin");
+        reversedLinesFileReader = new ReversedLinesFileReader(testFile20Bytes, blockSize, ISO_8859_1);
+        assertEqualsAndNoLineBreaks("987654321", reversedLinesFileReader.readLine());
+        assertEqualsAndNoLineBreaks("123456789", reversedLinesFileReader.readLine());
+    }
+
+    @ParameterizedTest(name = "BlockSize={0}")
+    @MethodSource("blockSizes")
+    public void testGBK(final int testParamBlockSize) throws URISyntaxException, IOException {
+        final File testFileGBK = TestResources.getFile("/test-file-gbk.bin");
+        reversedLinesFileReader = new ReversedLinesFileReader(testFileGBK, testParamBlockSize, "GBK");
+        assertEqualsAndNoLineBreaks(TEST_LINE_GBK_2, reversedLinesFileReader.readLine());
+        assertEqualsAndNoLineBreaks(TEST_LINE_GBK_1, reversedLinesFileReader.readLine());
+    }
+
+    @ParameterizedTest(name = "BlockSize={0}")
+    @MethodSource("blockSizes")
+    public void testIsoFileDefaults(final int testParamBlockSize) throws URISyntaxException, IOException {
+        final File testFileIso = TestResources.getFile("/test-file-iso8859-1.bin");
+        reversedLinesFileReader = new ReversedLinesFileReader(testFileIso, testParamBlockSize, ISO_8859_1);
+        assertFileWithShrinkingTestLines(reversedLinesFileReader);
+    }
+
+    @ParameterizedTest(name = "BlockSize={0}")
+    @MethodSource("blockSizes")
+    public void testIsoFileManyWindowsBreaksSmallBlockSize2VerifyBlockSpanningNewLines(final int testParamBlockSize) throws URISyntaxException, IOException {
+        final File testFileIso = TestResources.getFile("/test-file-iso8859-1-shortlines-win-linebr.bin");
+        reversedLinesFileReader = new ReversedLinesFileReader(testFileIso, testParamBlockSize, ISO_8859_1);
+
+        for (int i = 3; i > 0; i--) {
+            for (int j = 1; j <= 3; j++) {
+                assertEqualsAndNoLineBreaks("", reversedLinesFileReader.readLine());
+            }
+            assertEqualsAndNoLineBreaks("" + i, reversedLinesFileReader.readLine());
+        }
+    }
+
+    @ParameterizedTest(name = "BlockSize={0}")
+    @MethodSource("blockSizes")
+    public void testShiftJISFile(final int testParamBlockSize) throws URISyntaxException, IOException {
+        final File testFileShiftJIS = TestResources.getFile("/test-file-shiftjis.bin");
+        reversedLinesFileReader = new ReversedLinesFileReader(testFileShiftJIS, testParamBlockSize, "Shift_JIS");
+        assertEqualsAndNoLineBreaks(TEST_LINE_SHIFT_JIS2, reversedLinesFileReader.readLine());
+        assertEqualsAndNoLineBreaks(TEST_LINE_SHIFT_JIS1, reversedLinesFileReader.readLine());
+    }
+
+    @ParameterizedTest(name = "BlockSize={0}")
+    @MethodSource("blockSizes")
+    public void testUnsupportedEncodingBig5(final int testParamBlockSize) throws URISyntaxException {
+        final File testFileEncodingBig5 = TestResources.getFile("/test-file-empty.bin");
+        assertThrows(UnsupportedEncodingException.class,
+                () -> new ReversedLinesFileReader(testFileEncodingBig5, testParamBlockSize, "Big5").close());
+    }
+
+    @ParameterizedTest(name = "BlockSize={0}")
+    @MethodSource("blockSizes")
+    public void testUnsupportedEncodingUTF16(final int testParamBlockSize) throws URISyntaxException {
+        final File testFileEmpty = TestResources.getFile("/test-file-empty.bin");
+        assertThrows(UnsupportedEncodingException.class,
+                () -> new ReversedLinesFileReader(testFileEmpty, testParamBlockSize, StandardCharsets.UTF_16.name()).close());
+    }
+
+    @ParameterizedTest(name = "BlockSize={0}")
+    @MethodSource("blockSizes")
+    public void testUTF16BEFile(final int testParamBlockSize) throws URISyntaxException, IOException {
+        final File testFileUTF16BE = TestResources.getFile("/test-file-utf16be.bin");
+        reversedLinesFileReader = new ReversedLinesFileReader(testFileUTF16BE, testParamBlockSize, StandardCharsets.UTF_16BE.name());
+        assertFileWithShrinkingTestLines(reversedLinesFileReader);
+    }
+
+    @ParameterizedTest(name = "BlockSize={0}")
+    @MethodSource("blockSizes")
+    public void testUTF16LEFile(final int testParamBlockSize) throws URISyntaxException, IOException {
+        final File testFileUTF16LE = TestResources.getFile("/test-file-utf16le.bin");
+        reversedLinesFileReader = new ReversedLinesFileReader(testFileUTF16LE, testParamBlockSize, StandardCharsets.UTF_16LE.name());
+        assertFileWithShrinkingTestLines(reversedLinesFileReader);
+    }
+
+    @ParameterizedTest(name = "BlockSize={0}")
+    @MethodSource("blockSizes")
+    public void testUTF8File(final int testParamBlockSize) throws URISyntaxException, IOException {
+        final File testFileIso = TestResources.getFile("/test-file-utf8.bin");
+        reversedLinesFileReader = new ReversedLinesFileReader(testFileIso, testParamBlockSize, UTF_8);
+        assertFileWithShrinkingTestLines(reversedLinesFileReader);
+    }
+
+    @ParameterizedTest(name = "BlockSize={0}")
+    @MethodSource("blockSizes")
+    public void testUTF8FileCRBreaks(final int testParamBlockSize) throws URISyntaxException, IOException {
+        final File testFileIso = TestResources.getFile("/test-file-utf8-cr-only.bin");
+        reversedLinesFileReader = new ReversedLinesFileReader(testFileIso, testParamBlockSize, UTF_8);
+        assertFileWithShrinkingTestLines(reversedLinesFileReader);
+    }
+
+    @ParameterizedTest(name = "BlockSize={0}")
+    @MethodSource("blockSizes")
+    public void testUTF8FileWindowsBreaks(final int testParamBlockSize) throws URISyntaxException, IOException {
+        final File testFileIso = TestResources.getFile("/test-file-utf8-win-linebr.bin");
+        reversedLinesFileReader = new ReversedLinesFileReader(testFileIso, testParamBlockSize, UTF_8);
+        assertFileWithShrinkingTestLines(reversedLinesFileReader);
+    }
+
+    @ParameterizedTest(name = "BlockSize={0}")
+    @MethodSource("blockSizes")
+    public void testUTF8FileWindowsBreaksSmallBlockSize2VerifyBlockSpanningNewLines(final int testParamBlockSize) throws URISyntaxException, IOException {
+        final File testFileUtf8 = TestResources.getFile("/test-file-utf8-win-linebr.bin");
+        reversedLinesFileReader = new ReversedLinesFileReader(testFileUtf8, testParamBlockSize, UTF_8);
+        assertFileWithShrinkingTestLines(reversedLinesFileReader);
+    }
+
+    @ParameterizedTest(name = "BlockSize={0}")
+    @MethodSource("blockSizes")
+    public void testWindows31jFile(final int testParamBlockSize) throws URISyntaxException, IOException {
+        final File testFileWindows31J = TestResources.getFile("/test-file-windows-31j.bin");
+        reversedLinesFileReader = new ReversedLinesFileReader(testFileWindows31J, testParamBlockSize, "windows-31j");
+        assertEqualsAndNoLineBreaks(TEST_LINE_WINDOWS_31J_2, reversedLinesFileReader.readLine());
+        assertEqualsAndNoLineBreaks(TEST_LINE_WINDOWS_31J_1, reversedLinesFileReader.readLine());
+    }
+
+    @ParameterizedTest(name = "BlockSize={0}")
+    @MethodSource("blockSizes")
+    public void testxWindows949File(final int testParamBlockSize) throws URISyntaxException, IOException {
+        final File testFilexWindows949 = TestResources.getFile("/test-file-x-windows-949.bin");
+        reversedLinesFileReader = new ReversedLinesFileReader(testFilexWindows949, testParamBlockSize, "x-windows-949");
+        assertEqualsAndNoLineBreaks(TEST_LINE_X_WINDOWS_949_2, reversedLinesFileReader.readLine());
+        assertEqualsAndNoLineBreaks(TEST_LINE_X_WINDOWS_949_1, reversedLinesFileReader.readLine());
+    }
+
+    @ParameterizedTest(name = "BlockSize={0}")
+    @MethodSource("blockSizes")
+    public void testxWindows950File(final int testParamBlockSize) throws URISyntaxException, IOException {
+        final File testFilexWindows950 = TestResources.getFile("/test-file-x-windows-950.bin");
+        reversedLinesFileReader = new ReversedLinesFileReader(testFilexWindows950, testParamBlockSize, "x-windows-950");
+        assertEqualsAndNoLineBreaks(TEST_LINE_X_WINDOWS_950_2, reversedLinesFileReader.readLine());
+        assertEqualsAndNoLineBreaks(TEST_LINE_X_WINDOWS_950_1, reversedLinesFileReader.readLine());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/ReversedLinesFileReaderTestParamFile.java b/src/test/java/org/apache/commons/io/input/ReversedLinesFileReaderTestParamFile.java
new file mode 100644
index 0000000..7009fe4
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/ReversedLinesFileReaderTestParamFile.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.Reader;
+import java.io.Writer;
+import java.net.URISyntaxException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystem;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Stack;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.Charsets;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.TestResources;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import com.google.common.jimfs.Configuration;
+import com.google.common.jimfs.Jimfs;
+
+/**
+ * Test checks symmetric behavior with BufferedReader.
+ */
+public class ReversedLinesFileReaderTestParamFile {
+
+    private static final String UTF_16BE = StandardCharsets.ISO_8859_1.name();
+    private static final String UTF_16LE = StandardCharsets.UTF_16LE.name();
+    private static final String UTF_8 = StandardCharsets.UTF_8.name();
+    private static final String ISO_8859_1 = StandardCharsets.ISO_8859_1.name();
+
+    public static Stream<Arguments> testDataIntegrityWithBufferedReader() throws IOException, URISyntaxException {
+        // Make a file using the default encoding.
+        final Path sourcePath = TestResources.getPath("test-file-utf8-win-linebr.bin");
+        final Path targetPath = Files.createTempFile("ReversedLinesFileReaderTestParamFile", ".bin");
+        try (Reader input = Files.newBufferedReader(sourcePath, StandardCharsets.UTF_8);
+            Writer output = Files.newBufferedWriter(targetPath, Charset.defaultCharset())) {
+            IOUtils.copyLarge(input, output);
+        }
+        // All tests
+        // @formatter:off
+        return Stream.of(
+                Arguments.of(targetPath.toAbsolutePath().toString(), null, null, false, false),
+                Arguments.of("test-file-20byteslength.bin", ISO_8859_1, null, false, true),
+                Arguments.of("test-file-iso8859-1-shortlines-win-linebr.bin", ISO_8859_1, null, false, true),
+                Arguments.of("test-file-iso8859-1.bin", ISO_8859_1, null, false, true),
+                Arguments.of("test-file-shiftjis.bin", "Shift_JIS", null, false, true),
+                Arguments.of("test-file-utf16be.bin", UTF_16BE, null, false, true),
+                Arguments.of("test-file-utf16le.bin", UTF_16LE, null, false, true),
+                Arguments.of("test-file-utf8-cr-only.bin", UTF_8, null, false, true),
+                Arguments.of("test-file-utf8-win-linebr.bin", UTF_8, null, false, true,
+                Arguments.of("test-file-utf8-win-linebr.bin", UTF_8, 1, false, true),
+                Arguments.of("test-file-utf8-win-linebr.bin", UTF_8, 2, false, true),
+                Arguments.of("test-file-utf8-win-linebr.bin", UTF_8, 3, false, true),
+                Arguments.of("test-file-utf8-win-linebr.bin", UTF_8, 4, false, true),
+                Arguments.of("test-file-utf8.bin", UTF_8, null, false, true),
+                Arguments.of("test-file-utf8.bin", UTF_8, null, true, true),
+                Arguments.of("test-file-windows-31j.bin", "windows-31j", null, false, true),
+                Arguments.of("test-file-gbk.bin", "gbk", null, false, true),
+                Arguments.of("test-file-x-windows-949.bin", "x-windows-949", null, false, true),
+                Arguments.of("test-file-x-windows-950.bin", "x-windows-950", null, false, true)));
+        // @formatter:on
+    }
+
+    @ParameterizedTest(name = "{0}, encoding={1}, blockSize={2}, useNonDefaultFileSystem={3}, isResource={4}")
+    @MethodSource
+    public void testDataIntegrityWithBufferedReader(final String fileName, final String charsetName, final Integer blockSize,
+        final boolean useNonDefaultFileSystem, final boolean isResource) throws IOException, URISyntaxException {
+
+        Path filePath = isResource ? TestResources.getPath(fileName) : Paths.get(fileName);
+        FileSystem fileSystem = null;
+        if (useNonDefaultFileSystem) {
+            fileSystem = Jimfs.newFileSystem(Configuration.unix());
+            filePath = Files.copy(filePath, fileSystem.getPath("/" + fileName));
+        }
+
+        // We want to test null Charset in the ReversedLinesFileReaderconstructor.
+        final Charset charset = charsetName != null ? Charset.forName(charsetName) : null;
+        try (ReversedLinesFileReader reversedLinesFileReader = blockSize == null ? new ReversedLinesFileReader(filePath, charset)
+            : new ReversedLinesFileReader(filePath, blockSize, charset)) {
+
+            final Stack<String> lineStack = new Stack<>();
+            String line;
+
+            try (BufferedReader bufferedReader = Files.newBufferedReader(filePath, Charsets.toCharset(charset))) {
+                // read all lines in normal order
+                while ((line = bufferedReader.readLine()) != null) {
+                    lineStack.push(line);
+                }
+            }
+
+            // read in reverse order and compare with lines from stack
+            while ((line = reversedLinesFileReader.readLine()) != null) {
+                final String lineFromBufferedReader = lineStack.pop();
+                assertEquals(lineFromBufferedReader, line);
+            }
+            assertEquals(0, lineStack.size(), "Stack should be empty");
+
+            if (fileSystem != null) {
+                fileSystem.close();
+            }
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/ReversedLinesFileReaderTestSimple.java b/src/test/java/org/apache/commons/io/input/ReversedLinesFileReaderTestSimple.java
new file mode 100644
index 0000000..00e2100
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/ReversedLinesFileReaderTestSimple.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.input.ReversedLinesFileReaderTestParamBlockSize.assertEqualsAndNoLineBreaks;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.TestResources;
+import org.junit.jupiter.api.Test;
+
+public class ReversedLinesFileReaderTestSimple {
+
+    @Test
+    public void testFileSizeIsExactMultipleOfBlockSize() throws URISyntaxException, IOException {
+        final int blockSize = 10;
+        final File testFile20Bytes = TestResources.getFile("/test-file-20byteslength.bin");
+        try (ReversedLinesFileReader reversedLinesFileReader = new ReversedLinesFileReader(testFile20Bytes, blockSize,
+            "ISO-8859-1")) {
+            assertEqualsAndNoLineBreaks("987654321", reversedLinesFileReader.readLine());
+            assertEqualsAndNoLineBreaks("123456789", reversedLinesFileReader.readLine());
+        }
+    }
+
+    @Test
+    public void testLineCount() throws URISyntaxException, IOException {
+        final int blockSize = 10;
+        final File testFile20Bytes = TestResources.getFile("/test-file-20byteslength.bin");
+        try (ReversedLinesFileReader reversedLinesFileReader = new ReversedLinesFileReader(testFile20Bytes, blockSize,
+            "ISO-8859-1")) {
+            assertThrows(IllegalArgumentException.class, () -> reversedLinesFileReader.readLines(-1));
+            assertTrue(reversedLinesFileReader.readLines(0).isEmpty());
+            final List<String> lines = reversedLinesFileReader.readLines(2);
+            assertEqualsAndNoLineBreaks("987654321", lines.get(0));
+            assertEqualsAndNoLineBreaks("123456789", lines.get(1));
+            assertTrue(reversedLinesFileReader.readLines(0).isEmpty());
+            assertTrue(reversedLinesFileReader.readLines(10000).isEmpty());
+        }
+    }
+
+    @Test
+    public void testToString() throws URISyntaxException, IOException {
+        final int blockSize = 10;
+        final File testFile20Bytes = TestResources.getFile("/test-file-20byteslength.bin");
+        try (ReversedLinesFileReader reversedLinesFileReader = new ReversedLinesFileReader(testFile20Bytes, blockSize,
+            "ISO-8859-1")) {
+            assertThrows(IllegalArgumentException.class, () -> reversedLinesFileReader.toString(-1));
+            assertTrue(reversedLinesFileReader.readLines(0).isEmpty());
+            final String lines = reversedLinesFileReader.toString(2);
+            assertEquals("123456789" + System.lineSeparator() + "987654321" + System.lineSeparator(), lines);
+            assertTrue(reversedLinesFileReader.toString(0).isEmpty());
+            assertTrue(reversedLinesFileReader.toString(10000).isEmpty());
+        }
+    }
+
+    @Test
+    public void testUnsupportedEncodingBig5() throws URISyntaxException {
+        final File testFileEncodingBig5 = TestResources.getFile("/test-file-empty.bin");
+        assertThrows(UnsupportedEncodingException.class,
+            () -> new ReversedLinesFileReader(testFileEncodingBig5, IOUtils.DEFAULT_BUFFER_SIZE, "Big5").close());
+    }
+
+    @Test
+    public void testUnsupportedEncodingUTF16() throws URISyntaxException {
+        final File testFileEmpty = TestResources.getFile("/test-file-empty.bin");
+        assertThrows(UnsupportedEncodingException.class,
+            () -> new ReversedLinesFileReader(testFileEmpty, IOUtils.DEFAULT_BUFFER_SIZE, StandardCharsets.UTF_16.name()).close());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/SequenceReaderTest.java b/src/test/java/org/apache/commons/io/input/SequenceReaderTest.java
new file mode 100644
index 0000000..4ce2014
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/SequenceReaderTest.java
@@ -0,0 +1,273 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.IOUtils.EOF;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test case for {@link SequenceReader}.
+ */
+public class SequenceReaderTest {
+
+    private static class CustomReader extends Reader {
+
+        boolean closed;
+
+        protected void checkOpen() throws IOException {
+            if (closed) {
+                throw new IOException("emptyReader already closed");
+            }
+        }
+
+        @Override
+        public int read(final char[] cbuf, final int off, final int len) throws IOException {
+            checkOpen();
+            close();
+            return EOF;
+        }
+
+        @Override
+        public void close() throws IOException {
+            closed = true;
+        }
+
+        public boolean isClosed() {
+            return closed;
+        }
+    };
+
+    private static final char NUL = 0;
+
+    private void checkArray(final char[] expected, final char[] actual) {
+        for (int i = 0; i < expected.length; i++) {
+            assertEquals(expected[i], actual[i], "Compare[" + i + "]");
+        }
+    }
+
+    private void checkRead(final Reader reader, final String expected) throws IOException {
+        for (int i = 0; i < expected.length(); i++) {
+            assertEquals(expected.charAt(i), (char) reader.read(), "Read[" + i + "] of '" + expected + "'");
+        }
+    }
+
+    private void checkReadEof(final Reader reader) throws IOException {
+        for (int i = 0; i < 10; i++) {
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testAutoClose() throws IOException {
+        try (Reader reader = new SequenceReader(new CharSequenceReader("FooBar"))) {
+            checkRead(reader, "Foo");
+            reader.close();
+            checkReadEof(reader);
+        }
+    }
+
+    @Test
+    public void testClose() throws IOException {
+        final Reader reader = new SequenceReader(new CharSequenceReader("FooBar"));
+        checkRead(reader, "Foo");
+        reader.close();
+        checkReadEof(reader);
+    }
+
+    @Test
+    public void testCloseReaders() throws IOException {
+        final CustomReader reader0 = new CustomReader();
+        final CustomReader reader1 = new CustomReader() {
+
+            private final char[] content = {'A'};
+            private int position;
+
+            @Override
+            public int read(final char[] cbuf, final int off, final int len) throws IOException {
+                checkOpen();
+
+                if (off < 0) {
+                    throw new IndexOutOfBoundsException("off is negative");
+                } else if (len < 0) {
+                    throw new IndexOutOfBoundsException("len is negative");
+                } else if (len > cbuf.length - off) {
+                    throw new IndexOutOfBoundsException("len is greater than cbuf.length - off");
+                }
+
+                if (position > 0) {
+                    return EOF;
+                }
+
+                cbuf[off] = content[0];
+                position++;
+                return 1;
+            }
+
+        };
+
+        try (SequenceReader sequenceReader = new SequenceReader(reader1, reader0)) {
+            assertEquals('A', sequenceReader.read());
+            assertEquals(EOF, sequenceReader.read());
+        } finally {
+            assertTrue(reader1.isClosed());
+            assertTrue(reader0.isClosed());
+        }
+        assertTrue(reader1.isClosed());
+        assertTrue(reader0.isClosed());
+
+    }
+
+    @Test
+    public void testMarkSupported() throws Exception {
+        try (Reader reader = new SequenceReader()) {
+            assertFalse(reader.markSupported());
+        }
+    }
+
+    @Test
+    public void testRead() throws IOException {
+        try (Reader reader = new SequenceReader(new StringReader("Foo"), new StringReader("Bar"))) {
+            assertEquals('F', reader.read());
+            assertEquals('o', reader.read());
+            assertEquals('o', reader.read());
+            assertEquals('B', reader.read());
+            assertEquals('a', reader.read());
+            assertEquals('r', reader.read());
+            checkReadEof(reader);
+        }
+    }
+
+    @Test
+    public void testReadCharArray() throws IOException {
+        try (Reader reader = new SequenceReader(new StringReader("Foo"), new StringReader("Bar"))) {
+            char[] chars = new char[2];
+            assertEquals(2, reader.read(chars));
+            checkArray(new char[] { 'F', 'o' }, chars);
+            chars = new char[3];
+            assertEquals(3, reader.read(chars));
+            checkArray(new char[] { 'o', 'B', 'a' }, chars);
+            chars = new char[3];
+            assertEquals(1, reader.read(chars));
+            checkArray(new char[] { 'r', NUL, NUL }, chars);
+            assertEquals(-1, reader.read(chars));
+        }
+    }
+
+    @Test
+    public void testReadCharArrayPortion() throws IOException {
+        final char[] chars = new char[10];
+        try (Reader reader = new SequenceReader(new StringReader("Foo"), new StringReader("Bar"))) {
+            assertEquals(3, reader.read(chars, 3, 3));
+            checkArray(new char[] { NUL, NUL, NUL, 'F', 'o', 'o' }, chars);
+            assertEquals(3, reader.read(chars, 0, 3));
+            checkArray(new char[] { 'B', 'a', 'r', 'F', 'o', 'o', NUL }, chars);
+            assertEquals(-1, reader.read(chars));
+            assertThrows(IndexOutOfBoundsException.class, () -> reader.read(chars, 10, 10));
+            assertThrows(NullPointerException.class, () -> reader.read(null, 0, 10));
+        }
+    }
+
+    @Test
+    public void testReadClosedReader() throws IOException {
+        @SuppressWarnings("resource")
+        final Reader reader = new SequenceReader(new CharSequenceReader("FooBar"));
+        reader.close();
+        checkReadEof(reader);
+    }
+
+    @Test
+    public void testReadCollection() throws IOException {
+        final Collection<Reader> readers = new ArrayList<>();
+        readers.add(new StringReader("F"));
+        readers.add(new StringReader("B"));
+        try (Reader reader = new SequenceReader(readers)) {
+            assertEquals('F', reader.read());
+            assertEquals('B', reader.read());
+            checkReadEof(reader);
+        }
+    }
+
+    @Test
+    public void testReadIterable() throws IOException {
+        final Collection<Reader> readers = new ArrayList<>();
+        readers.add(new StringReader("F"));
+        readers.add(new StringReader("B"));
+        final Iterable<Reader> iterable = readers;
+        try (Reader reader = new SequenceReader(iterable)) {
+            assertEquals('F', reader.read());
+            assertEquals('B', reader.read());
+            checkReadEof(reader);
+        }
+    }
+
+    @Test
+    public void testReadLength0Readers() throws IOException {
+        try (Reader reader = new SequenceReader(new StringReader(StringUtils.EMPTY),
+            new StringReader(StringUtils.EMPTY), new StringReader(StringUtils.EMPTY))) {
+            checkReadEof(reader);
+        }
+    }
+
+    @Test
+    public void testReadLength1Readers() throws IOException {
+        try (Reader reader = new SequenceReader(
+        // @formatter:off
+            new StringReader("0"),
+            new StringReader("1"),
+            new StringReader("2"),
+            new StringReader("3"))) {
+            // @formatter:on
+            assertEquals('0', reader.read());
+            assertEquals('1', reader.read());
+            assertEquals('2', reader.read());
+            assertEquals('3', reader.read());
+        }
+    }
+
+    @Test
+    public void testReadList() throws IOException {
+        final List<Reader> readers = new ArrayList<>();
+        readers.add(new StringReader("F"));
+        readers.add(new StringReader("B"));
+        try (Reader reader = new SequenceReader(readers)) {
+            assertEquals('F', reader.read());
+            assertEquals('B', reader.read());
+            checkReadEof(reader);
+        }
+    }
+
+    @Test
+    public void testSkip() throws IOException {
+        try (Reader reader = new SequenceReader(new StringReader("Foo"), new StringReader("Bar"))) {
+            assertEquals(3, reader.skip(3));
+            checkRead(reader, "Bar");
+            assertEquals(0, reader.skip(3));
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/commons/io/input/StringInputStream.java b/src/test/java/org/apache/commons/io/input/StringInputStream.java
new file mode 100644
index 0000000..3600b6c
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/StringInputStream.java
@@ -0,0 +1,78 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ */
+
+package org.apache.commons.io.input;
+
+import java.io.InputStream;
+import java.io.StringReader;
+import java.nio.charset.Charset;
+
+/**
+ * An {@link InputStream} on a String.
+ *
+ * @since 2.12.0
+ */
+public class StringInputStream extends ReaderInputStream {
+
+    /**
+     * Creates a new instance on a String.
+     *
+     * @param source The source string, MUST not be null.
+     * @return A new instance.
+     */
+    public static StringInputStream on(final String source) {
+        return new StringInputStream(source);
+    }
+
+    /**
+     * Creates a new instance on the empty String.
+     */
+    public StringInputStream() {
+        this("", Charset.defaultCharset());
+    }
+
+    /**
+     * Creates a new instance on a String.
+     *
+     * @param source The source string, MUST not be null.
+     */
+    public StringInputStream(final String source) {
+        this(source, Charset.defaultCharset());
+    }
+
+    /**
+     * Creates a new instance on a String for a Charset.
+     *
+     * @param source The source string, MUST not be null.
+     * @param charset The source charset, MUST not be null.
+     */
+    public StringInputStream(final String source, final Charset charset) {
+        super(new StringReader(source), charset);
+    }
+
+    /**
+     * Creates a new instance on a String and for a Charset.
+     *
+     * @param source The source string, MUST not be null.
+     * @param charset The source charset, MUST not be null.
+     */
+    public StringInputStream(final String source, final String charset) {
+        super(new StringReader(source), charset);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/StringInputStreamTest.java b/src/test/java/org/apache/commons/io/input/StringInputStreamTest.java
new file mode 100644
index 0000000..66a0cb6
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/StringInputStreamTest.java
@@ -0,0 +1,47 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ */
+
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.nio.charset.Charset;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link StringInputStream}.
+ */
+public class StringInputStreamTest {
+
+    @Test
+    public void testStringConstructorString() throws IOException {
+        try (StringInputStream input = StringInputStream.on("01")) {
+            assertEquals("01", IOUtils.toString(input, Charset.defaultCharset()));
+        }
+    }
+
+    @Test
+    public void testStringConstructorStringCharset() throws IOException {
+        try (StringInputStream input = new StringInputStream("01", Charset.defaultCharset())) {
+            assertEquals("01", IOUtils.toString(input, Charset.defaultCharset()));
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/SwappedDataInputStreamTest.java b/src/test/java/org/apache/commons/io/input/SwappedDataInputStreamTest.java
new file mode 100644
index 0000000..67f73ed
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/SwappedDataInputStreamTest.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Test for the SwappedDataInputStream. This also
+ * effectively tests the underlying EndianUtils Stream methods.
+ *
+ */
+
+public class SwappedDataInputStreamTest {
+
+    private SwappedDataInputStream sdis;
+    private byte[] bytes;
+
+    @BeforeEach
+    public void setUp() {
+        bytes = new byte[] {
+            0x01,
+            0x02,
+            0x03,
+            0x04,
+            0x05,
+            0x06,
+            0x07,
+            0x08
+        };
+        final ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
+        this.sdis = new SwappedDataInputStream( bais );
+    }
+
+    @AfterEach
+    public void tearDown() {
+        this.sdis = null;
+    }
+
+    @Test
+    public void testReadBoolean() throws IOException {
+        bytes = new byte[] {0x00, 0x01, 0x02,};
+        try (
+            final ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
+            final SwappedDataInputStream sdis = new SwappedDataInputStream(bais)
+        ) {
+            assertFalse(sdis.readBoolean());
+            assertTrue(sdis.readBoolean());
+            assertTrue(sdis.readBoolean());
+        }
+    }
+
+    @Test
+    public void testReadByte() throws IOException {
+        assertEquals( 0x01, this.sdis.readByte() );
+    }
+
+    @Test
+    public void testReadChar() throws IOException {
+        assertEquals( (char) 0x0201, this.sdis.readChar() );
+    }
+
+    @Test
+    public void testReadDouble() throws IOException {
+        assertEquals( Double.longBitsToDouble(0x0807060504030201L), this.sdis.readDouble(), 0 );
+    }
+
+    @Test
+    public void testReadFloat() throws IOException {
+        assertEquals( Float.intBitsToFloat(0x04030201), this.sdis.readFloat(), 0 );
+    }
+
+    @Test
+    public void testReadFully() throws IOException {
+        final byte[] bytesIn = new byte[8];
+        this.sdis.readFully(bytesIn);
+        for( int i=0; i<8; i++) {
+            assertEquals( bytes[i], bytesIn[i] );
+        }
+    }
+
+    @Test
+    public void testReadInt() throws IOException {
+        assertEquals( 0x04030201, this.sdis.readInt() );
+    }
+
+    @Test
+    public void testReadLine() {
+        assertThrows(UnsupportedOperationException.class, () ->  this.sdis.readLine(),
+                "readLine should be unsupported. ");
+    }
+
+    @Test
+    public void testReadLong() throws IOException {
+        assertEquals( 0x0807060504030201L, this.sdis.readLong() );
+    }
+
+    @Test
+    public void testReadShort() throws IOException {
+        assertEquals( (short) 0x0201, this.sdis.readShort() );
+    }
+
+    @Test
+    public void testReadUnsignedByte() throws IOException {
+        assertEquals( 0x01, this.sdis.readUnsignedByte() );
+    }
+
+    @Test
+    public void testReadUnsignedShort() throws IOException {
+        assertEquals( (short) 0x0201, this.sdis.readUnsignedShort() );
+    }
+
+    @Test
+    public void testReadUTF() {
+        assertThrows(UnsupportedOperationException.class, () ->  this.sdis.readUTF(),
+                "readUTF should be unsupported. ");
+    }
+
+    @Test
+    public void testSkipBytes() throws IOException {
+        this.sdis.skipBytes(4);
+        assertEquals( 0x08070605, this.sdis.readInt() );
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/TaggedInputStreamTest.java b/src/test/java/org/apache/commons/io/input/TaggedInputStreamTest.java
new file mode 100644
index 0000000..ffa2ff4
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/TaggedInputStreamTest.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.UUID;
+
+import org.apache.commons.io.TaggedIOException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link TaggedInputStream}.
+ */
+public class TaggedInputStreamTest  {
+
+    @Test
+    public void testBrokenStream() {
+        final IOException exception = new IOException("test exception");
+        final TaggedInputStream stream =
+            new TaggedInputStream(new BrokenInputStream(exception));
+
+        // Test the available() method
+        try {
+            stream.available();
+            fail("Expected exception not thrown.");
+        } catch (final IOException e) {
+            assertTrue(stream.isCauseOf(e));
+            try {
+                stream.throwIfCauseOf(e);
+                fail("Expected exception not thrown.");
+            } catch (final IOException e2) {
+                assertEquals(exception, e2);
+            }
+        }
+
+        // Test the read() method
+        try {
+            stream.read();
+            fail("Expected exception not thrown.");
+        } catch (final IOException e) {
+            assertTrue(stream.isCauseOf(e));
+            try {
+                stream.throwIfCauseOf(e);
+                fail("Expected exception not thrown.");
+            } catch (final IOException e2) {
+                assertEquals(exception, e2);
+            }
+        }
+
+        // Test the close() method
+        try {
+            stream.close();
+            fail("Expected exception not thrown.");
+        } catch (final IOException e) {
+            assertTrue(stream.isCauseOf(e));
+            try {
+                stream.throwIfCauseOf(e);
+                fail("Expected exception not thrown.");
+            } catch (final IOException e2) {
+                assertEquals(exception, e2);
+            }
+        }
+    }
+
+    @Test
+    public void testEmptyStream() throws IOException {
+        try (InputStream stream = new TaggedInputStream(ClosedInputStream.INSTANCE)) {
+            assertEquals(0, stream.available());
+            assertEquals(-1, stream.read());
+            assertEquals(-1, stream.read(new byte[1]));
+            assertEquals(-1, stream.read(new byte[1], 0, 1));
+        }
+    }
+
+    @Test
+    public void testNormalStream() throws IOException {
+        try (InputStream stream = new TaggedInputStream(new ByteArrayInputStream(new byte[] {'a', 'b', 'c'}))) {
+            assertEquals(3, stream.available());
+            assertEquals('a', stream.read());
+            final byte[] buffer = new byte[1];
+            assertEquals(1, stream.read(buffer));
+            assertEquals('b', buffer[0]);
+            assertEquals(1, stream.read(buffer, 0, 1));
+            assertEquals('c', buffer[0]);
+            assertEquals(-1, stream.read());
+        }
+    }
+
+    @Test
+    public void testOtherException() throws Exception {
+        final IOException exception = new IOException("test exception");
+        try (TaggedInputStream stream = new TaggedInputStream(ClosedInputStream.INSTANCE)) {
+
+            assertFalse(stream.isCauseOf(exception));
+            assertFalse(stream.isCauseOf(new TaggedIOException(exception, UUID.randomUUID())));
+
+            stream.throwIfCauseOf(exception);
+
+            stream.throwIfCauseOf(new TaggedIOException(exception, UUID.randomUUID()));
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/TaggedReaderTest.java b/src/test/java/org/apache/commons/io/input/TaggedReaderTest.java
new file mode 100644
index 0000000..9360cf2
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/TaggedReaderTest.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringReader;
+import java.util.UUID;
+
+import org.apache.commons.io.TaggedIOException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link TaggedReader}.
+ */
+public class TaggedReaderTest {
+
+    @Test
+    public void testBrokenReader() {
+        final IOException exception = new IOException("test exception");
+        final TaggedReader reader = new TaggedReader(new BrokenReader(exception));
+
+        // Test the ready() method
+        try {
+            reader.ready();
+            fail("Expected exception not thrown.");
+        } catch (final IOException e) {
+            assertTrue(reader.isCauseOf(e));
+            try {
+                reader.throwIfCauseOf(e);
+                fail("Expected exception not thrown.");
+            } catch (final IOException e2) {
+                assertEquals(exception, e2);
+            }
+        }
+
+        // Test the read() method
+        try {
+            reader.read();
+            fail("Expected exception not thrown.");
+        } catch (final IOException e) {
+            assertTrue(reader.isCauseOf(e));
+            try {
+                reader.throwIfCauseOf(e);
+                fail("Expected exception not thrown.");
+            } catch (final IOException e2) {
+                assertEquals(exception, e2);
+            }
+        }
+
+        // Test the close() method
+        try {
+            reader.close();
+            fail("Expected exception not thrown.");
+        } catch (final IOException e) {
+            assertTrue(reader.isCauseOf(e));
+            try {
+                reader.throwIfCauseOf(e);
+                fail("Expected exception not thrown.");
+            } catch (final IOException e2) {
+                assertEquals(exception, e2);
+            }
+        }
+    }
+
+    @Test
+    public void testEmptyReader() throws IOException {
+        try (Reader reader = new TaggedReader(ClosedReader.INSTANCE)) {
+            assertFalse(reader.ready());
+            assertEquals(-1, reader.read());
+            assertEquals(-1, reader.read(new char[1]));
+            assertEquals(-1, reader.read(new char[1], 0, 1));
+        }
+    }
+
+    @Test
+    public void testNormalReader() throws IOException {
+        try (Reader reader = new TaggedReader(new StringReader("abc"))) {
+            assertTrue(reader.ready());
+            assertEquals('a', reader.read());
+            final char[] buffer = new char[1];
+            assertEquals(1, reader.read(buffer));
+            assertEquals('b', buffer[0]);
+            assertEquals(1, reader.read(buffer, 0, 1));
+            assertEquals('c', buffer[0]);
+            assertEquals(-1, reader.read());
+        }
+    }
+
+    @Test
+    public void testOtherException() throws Exception {
+        final IOException exception = new IOException("test exception");
+        try (TaggedReader reader = new TaggedReader(ClosedReader.INSTANCE)) {
+
+            assertFalse(reader.isCauseOf(exception));
+            assertFalse(reader.isCauseOf(new TaggedIOException(exception, UUID.randomUUID())));
+
+            reader.throwIfCauseOf(exception);
+
+            reader.throwIfCauseOf(new TaggedIOException(exception, UUID.randomUUID()));
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/TailerTest.java b/src/test/java/org/apache/commons/io/input/TailerTest.java
new file mode 100644
index 0000000..c7d34fa
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/TailerTest.java
@@ -0,0 +1,724 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.BufferedOutputStream;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.io.RandomAccessFile;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.FileTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.RandomAccessFileMode;
+import org.apache.commons.io.TestResources;
+import org.apache.commons.io.test.TestUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import com.google.common.collect.Lists;
+
+/**
+ * Test for {@link Tailer}.
+ */
+public class TailerTest {
+
+    private static class NonStandardTailable implements Tailer.Tailable {
+
+        private final File file;
+
+        public NonStandardTailable(final File file) {
+            this.file = file;
+        }
+
+        @Override
+        public Tailer.RandomAccessResourceBridge getRandomAccess(final String mode) throws FileNotFoundException {
+            return new Tailer.RandomAccessResourceBridge() {
+
+                private final RandomAccessFile randomAccessFile = new RandomAccessFile(file, mode);
+
+                @Override
+                public void close() throws IOException {
+                    randomAccessFile.close();
+                }
+
+                @Override
+                public long getPointer() throws IOException {
+                    return randomAccessFile.getFilePointer();
+                }
+
+                @Override
+                public int read(final byte[] b) throws IOException {
+                    return randomAccessFile.read(b);
+                }
+
+                @Override
+                public void seek(final long position) throws IOException {
+                    randomAccessFile.seek(position);
+                }
+            };
+        }
+
+        @Override
+        public boolean isNewer(final FileTime fileTime) throws IOException {
+            return FileUtils.isFileNewer(file, fileTime);
+        }
+
+        @Override
+        public FileTime lastModifiedFileTime() throws IOException {
+            return FileUtils.lastModifiedFileTime(file);
+        }
+
+        @Override
+        public long size() {
+            return file.length();
+        }
+    }
+
+    /**
+     * Test {@link TailerListener} implementation.
+     */
+    private static class TestTailerListener extends TailerListenerAdapter {
+
+        // Must be synchronized because it is written by one thread and read by another
+        private final List<String> lines = Collections.synchronizedList(new ArrayList<>());
+
+        private final CountDownLatch latch;
+
+        volatile Exception exception;
+
+        volatile int notFound;
+
+        volatile int rotated;
+
+        volatile int initialized;
+
+        volatile int reachedEndOfFile;
+
+        public TestTailerListener() {
+            latch = new CountDownLatch(1);
+        }
+
+        public TestTailerListener(final int expectedLines) {
+            latch = new CountDownLatch(expectedLines);
+        }
+
+        public boolean awaitExpectedLines(final long timeout, final TimeUnit timeUnit) throws InterruptedException {
+            return latch.await(timeout, timeUnit);
+        }
+
+        public void clear() {
+            lines.clear();
+        }
+
+        @Override
+        public void endOfFileReached() {
+            reachedEndOfFile++; // not atomic, but OK because only updated here.
+        }
+
+        @Override
+        public void fileNotFound() {
+            notFound++; // not atomic, but OK because only updated here.
+        }
+
+        @Override
+        public void fileRotated() {
+            rotated++; // not atomic, but OK because only updated here.
+        }
+
+        public List<String> getLines() {
+            return lines;
+        }
+
+        @Override
+        public void handle(final Exception e) {
+            exception = e;
+        }
+
+        @Override
+        public void handle(final String line) {
+            lines.add(line);
+            latch.countDown();
+        }
+
+        @Override
+        public void init(final Tailer tailer) {
+            initialized++; // not atomic, but OK because only updated here.
+        }
+    }
+
+    private static final int TEST_BUFFER_SIZE = 1024;
+
+    private static final int TEST_DELAY_MILLIS = 1500;
+
+    @TempDir
+    public static File temporaryFolder;
+
+    protected void createFile(final File file, final long size) throws IOException {
+        assertTrue(file.getParentFile().exists(), () -> "Cannot create file " + file + " as the parent directory does not exist");
+        try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(file.toPath()))) {
+            TestUtils.generateTestData(output, size);
+        }
+
+        // try to make sure file is found
+        // (to stop continuum occasionally failing)
+        RandomAccessFile reader = null;
+        try {
+            while (reader == null) {
+                try {
+                    reader = RandomAccessFileMode.READ_ONLY.create(file);
+                } catch (final FileNotFoundException ignore) {
+                    // ignore
+                }
+                TestUtils.sleepQuietly(200L);
+            }
+        } finally {
+            IOUtils.closeQuietly(reader);
+        }
+        // sanity checks
+        assertTrue(file.exists());
+        assertEquals(size, file.length());
+    }
+
+    @Test
+    @SuppressWarnings("squid:S2699") // Suppress "Add at least one assertion to this test case"
+    public void testBufferBreak() throws Exception {
+        final long delay = 50;
+
+        final File file = new File(temporaryFolder, "testBufferBreak.txt");
+        createFile(file, 0);
+        writeString(file, "SBTOURIST\n");
+
+        final TestTailerListener listener = new TestTailerListener();
+        try (Tailer tailer = new Tailer(file, listener, delay, false, 1)) {
+            final Thread thread = new Thread(tailer);
+            thread.start();
+
+            List<String> lines = listener.getLines();
+            while (lines.isEmpty() || !lines.get(lines.size() - 1).equals("SBTOURIST")) {
+                lines = listener.getLines();
+            }
+
+            listener.clear();
+        }
+    }
+
+    @Test
+    public void testBuilderWithNonStandardTailable() throws Exception {
+        final File file = new File(temporaryFolder, "tailer-create-with-delay-and-from-start-with-reopen-and-buffersize-and-charset.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener(1);
+        try (Tailer tailer = new Tailer.Builder(new NonStandardTailable(file), listener).build()) {
+            assertTrue(tailer.getTailable() instanceof NonStandardTailable);
+            validateTailer(listener, file);
+        }
+    }
+
+    @Test
+    public void testCreate() throws Exception {
+        final File file = new File(temporaryFolder, "tailer-create.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener(1);
+        try (Tailer tailer = Tailer.create(file, listener)) {
+            validateTailer(listener, file);
+        }
+    }
+
+    @Test
+    public void testCreaterWithDelayAndFromStartWithReopen() throws Exception {
+        final File file = new File(temporaryFolder, "tailer-create-with-delay-and-from-start-with-reopen.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener(1);
+        try (Tailer tailer = Tailer.create(file, listener, TEST_DELAY_MILLIS, false, false)) {
+            validateTailer(listener, file);
+        }
+    }
+
+    @Test
+    public void testCreateWithDelay() throws Exception {
+        final File file = new File(temporaryFolder, "tailer-create-with-delay.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener(1);
+        try (Tailer tailer = Tailer.create(file, listener, TEST_DELAY_MILLIS)) {
+            validateTailer(listener, file);
+        }
+    }
+
+    @Test
+    public void testCreateWithDelayAndFromStart() throws Exception {
+        final File file = new File(temporaryFolder, "tailer-create-with-delay-and-from-start.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener(1);
+        try (Tailer tailer = Tailer.create(file, listener, TEST_DELAY_MILLIS, false)) {
+            validateTailer(listener, file);
+        }
+    }
+
+    @Test
+    public void testCreateWithDelayAndFromStartWithBufferSize() throws Exception {
+        final File file = new File(temporaryFolder, "tailer-create-with-delay-and-from-start-with-buffersize.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener(1);
+        try (Tailer tailer = Tailer.create(file, listener, TEST_DELAY_MILLIS, false, TEST_BUFFER_SIZE)) {
+            validateTailer(listener, file);
+        }
+    }
+
+    @Test
+    public void testCreateWithDelayAndFromStartWithReopenAndBufferSize() throws Exception {
+        final File file = new File(temporaryFolder, "tailer-create-with-delay-and-from-start-with-reopen-and-buffersize.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener(1);
+        try (Tailer tailer = Tailer.create(file, listener, TEST_DELAY_MILLIS, false, true, TEST_BUFFER_SIZE)) {
+            validateTailer(listener, file);
+        }
+    }
+
+    @Test
+    public void testCreateWithDelayAndFromStartWithReopenAndBufferSizeAndCharset() throws Exception {
+        final File file = new File(temporaryFolder, "tailer-create-with-delay-and-from-start-with-reopen-and-buffersize-and-charset.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener(1);
+        try (Tailer tailer = Tailer.create(file, StandardCharsets.UTF_8, listener, TEST_DELAY_MILLIS, false, true, TEST_BUFFER_SIZE)) {
+            validateTailer(listener, file);
+        }
+    }
+
+    /*
+     * Tests [IO-357][Tailer] InterruptedException while the thead is sleeping is silently ignored.
+     */
+    @Test
+    public void testInterrupt() throws Exception {
+        final File file = new File(temporaryFolder, "nosuchfile");
+        assertFalse(file.exists(), "nosuchfile should not exist");
+        final TestTailerListener listener = new TestTailerListener();
+        // Use a long delay to try to make sure the test thread calls interrupt() while the tailer thread is sleeping.
+        final int delay = 1000;
+        final int idle = 50; // allow time for thread to work
+        try (Tailer tailer = new Tailer(file, listener, delay, false, IOUtils.DEFAULT_BUFFER_SIZE)) {
+            final Thread thread = new Thread(tailer);
+            thread.setDaemon(true);
+            thread.start();
+            TestUtils.sleep(idle);
+            thread.interrupt();
+            TestUtils.sleep(delay + idle);
+            assertNotNull(listener.exception, "Missing InterruptedException");
+            assertTrue(listener.exception instanceof InterruptedException, "Unexpected Exception: " + listener.exception);
+            assertEquals(1, listener.initialized, "Expected init to be called");
+            assertTrue(listener.notFound > 0, "fileNotFound should be called");
+            assertEquals(0, listener.rotated, "fileRotated should be not be called");
+            assertEquals(0, listener.reachedEndOfFile, "end of file never reached");
+        }
+    }
+
+    @Test
+    public void testIO335() throws Exception { // test CR behavior
+        // Create & start the Tailer
+        final long delayMillis = 50;
+        final File file = new File(temporaryFolder, "tailer-testio334.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener();
+        try (Tailer tailer = new Tailer(file, listener, delayMillis, false)) {
+            final Thread thread = new Thread(tailer);
+            thread.start();
+
+            // Write some lines to the file
+            writeString(file, "CRLF\r\n", "LF\n", "CR\r", "CRCR\r\r", "trail");
+            final long testDelayMillis = delayMillis * 10;
+            TestUtils.sleep(testDelayMillis);
+            final List<String> lines = listener.getLines();
+            assertEquals(4, lines.size(), "line count");
+            assertEquals("CRLF", lines.get(0), "line 1");
+            assertEquals("LF", lines.get(1), "line 2");
+            assertEquals("CR", lines.get(2), "line 3");
+            assertEquals("CRCR\r", lines.get(3), "line 4");
+        }
+    }
+
+    @Test
+    @SuppressWarnings("squid:S2699") // Suppress "Add at least one assertion to this test case"
+    public void testLongFile() throws Exception {
+        final long delay = 50;
+
+        final File file = new File(temporaryFolder, "testLongFile.txt");
+        createFile(file, 0);
+        try (Writer writer = Files.newBufferedWriter(file.toPath(), StandardOpenOption.APPEND)) {
+            for (int i = 0; i < 100000; i++) {
+                writer.write("LineLineLineLineLineLineLineLineLineLine\n");
+            }
+            writer.write("SBTOURIST\n");
+        }
+
+        final TestTailerListener listener = new TestTailerListener();
+        try (Tailer tailer = new Tailer(file, listener, delay, false)) {
+
+            // final long start = System.currentTimeMillis();
+
+            final Thread thread = new Thread(tailer);
+            thread.start();
+
+            List<String> lines = listener.getLines();
+            while (lines.isEmpty() || !lines.get(lines.size() - 1).equals("SBTOURIST")) {
+                lines = listener.getLines();
+            }
+            // System.out.println("Elapsed: " + (System.currentTimeMillis() - start));
+
+            listener.clear();
+        }
+    }
+
+    @Test
+    public void testMultiByteBreak() throws Exception {
+        // System.out.println("testMultiByteBreak() Default charset: " + Charset.defaultCharset().displayName());
+        final long delay = 50;
+        final File origin = TestResources.getFile("test-file-utf8.bin");
+        final File file = new File(temporaryFolder, "testMultiByteBreak.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener();
+        final String osname = System.getProperty("os.name");
+        final boolean isWindows = osname.startsWith("Windows");
+        // Need to use UTF-8 to read & write the file otherwise it can be corrupted (depending on the default charset)
+        final Charset charsetUTF8 = StandardCharsets.UTF_8;
+        try (Tailer tailer = new Tailer(file, charsetUTF8, listener, delay, false, isWindows, IOUtils.DEFAULT_BUFFER_SIZE)) {
+            final Thread thread = new Thread(tailer);
+            thread.start();
+
+            try (Writer out = new OutputStreamWriter(Files.newOutputStream(file.toPath()), charsetUTF8);
+                BufferedReader reader = new BufferedReader(new InputStreamReader(Files.newInputStream(origin.toPath()), charsetUTF8))) {
+                final List<String> lines = new ArrayList<>();
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    out.write(line);
+                    out.write("\n");
+                    lines.add(line);
+                }
+                out.close(); // ensure data is written
+
+                final long testDelayMillis = delay * 10;
+                TestUtils.sleep(testDelayMillis);
+                final List<String> tailerlines = listener.getLines();
+                assertEquals(lines.size(), tailerlines.size(), "line count");
+                for (int i = 0, len = lines.size(); i < len; i++) {
+                    final String expected = lines.get(i);
+                    final String actual = tailerlines.get(i);
+                    if (!expected.equals(actual)) {
+                        fail("Line: " + i + "\nExp: (" + expected.length() + ") " + expected + "\nAct: (" + actual.length() + ") " + actual);
+                    }
+                }
+            }
+        }
+    }
+
+    @Test
+    public void testSimpleConstructor() throws Exception {
+        final File file = new File(temporaryFolder, "tailer-simple-constructor.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener(1);
+        try (Tailer tailer = new Tailer(file, listener)) {
+            final Thread thread = new Thread(tailer);
+            thread.start();
+            validateTailer(listener, file);
+        }
+    }
+
+    @Test
+    public void testSimpleConstructorWithDelay() throws Exception {
+        final File file = new File(temporaryFolder, "tailer-simple-constructor-with-delay.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener(1);
+        try (Tailer tailer = new Tailer(file, listener, TEST_DELAY_MILLIS)) {
+            final Thread thread = new Thread(tailer);
+            thread.start();
+            validateTailer(listener, file);
+        }
+    }
+
+    @Test
+    public void testSimpleConstructorWithDelayAndFromStart() throws Exception {
+        final File file = new File(temporaryFolder, "tailer-simple-constructor-with-delay-and-from-start.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener(1);
+        try (Tailer tailer = new Tailer(file, listener, TEST_DELAY_MILLIS, false)) {
+            final Thread thread = new Thread(tailer);
+            thread.start();
+            validateTailer(listener, file);
+        }
+    }
+
+    @Test
+    public void testSimpleConstructorWithDelayAndFromStartWithBufferSize() throws Exception {
+        final File file = new File(temporaryFolder, "tailer-simple-constructor-with-delay-and-from-start-with-buffersize.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener(1);
+        try (Tailer tailer = new Tailer(file, listener, TEST_DELAY_MILLIS, false, TEST_BUFFER_SIZE)) {
+            final Thread thread = new Thread(tailer);
+            thread.start();
+            validateTailer(listener, file);
+        }
+    }
+
+    @Test
+    public void testSimpleConstructorWithDelayAndFromStartWithReopen() throws Exception {
+        final File file = new File(temporaryFolder, "tailer-simple-constructor-with-delay-and-from-start-with-reopen.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener(1);
+        try (Tailer tailer = new Tailer(file, listener, TEST_DELAY_MILLIS, false, false)) {
+            final Thread thread = new Thread(tailer);
+            thread.start();
+            validateTailer(listener, file);
+        }
+    }
+
+    @Test
+    public void testSimpleConstructorWithDelayAndFromStartWithReopenAndBufferSize() throws Exception {
+        final File file = new File(temporaryFolder, "tailer-simple-constructor-with-delay-and-from-start-with-reopen-and-buffersize.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener(1);
+        try (Tailer tailer = new Tailer(file, listener, TEST_DELAY_MILLIS, false, true, TEST_BUFFER_SIZE)) {
+            final Thread thread = new Thread(tailer);
+            thread.start();
+            validateTailer(listener, file);
+        }
+    }
+
+    @Test
+    public void testSimpleConstructorWithDelayAndFromStartWithReopenAndBufferSizeAndCharset() throws Exception {
+        final File file = new File(temporaryFolder, "tailer-simple-constructor-with-delay-and-from-start-with-reopen-and-buffersize-and-charset.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener(1);
+        try (Tailer tailer = new Tailer(file, StandardCharsets.UTF_8, listener, TEST_DELAY_MILLIS, false, true, TEST_BUFFER_SIZE)) {
+            final Thread thread = new Thread(tailer);
+            thread.start();
+            validateTailer(listener, file);
+        }
+    }
+
+    @Test
+    public void testStopWithNoFile() throws Exception {
+        final File file = new File(temporaryFolder, "nosuchfile");
+        assertFalse(file.exists(), "nosuchfile should not exist");
+        final TestTailerListener listener = new TestTailerListener();
+        final int delay = 100;
+        final int idle = 50; // allow time for thread to work
+        try (Tailer tailer = Tailer.create(file, listener, delay, false)) {
+            TestUtils.sleep(idle);
+        }
+        TestUtils.sleep(delay + idle);
+        assertNull(listener.exception, "Should not generate Exception");
+        assertEquals(1, listener.initialized, "Expected init to be called");
+        assertTrue(listener.notFound > 0, "fileNotFound should be called");
+        assertEquals(0, listener.rotated, "fileRotated should be not be called");
+        assertEquals(0, listener.reachedEndOfFile, "end of file never reached");
+    }
+
+    @Test
+    public void testStopWithNoFileUsingExecutor() throws Exception {
+        final File file = new File(temporaryFolder, "nosuchfile");
+        assertFalse(file.exists(), "nosuchfile should not exist");
+        final TestTailerListener listener = new TestTailerListener();
+        final int delay = 100;
+        final int idle = 50; // allow time for thread to work
+        try (Tailer tailer = new Tailer(file, listener, delay, false)) {
+            final Executor exec = new ScheduledThreadPoolExecutor(1);
+            exec.execute(tailer);
+            TestUtils.sleep(idle);
+        }
+        TestUtils.sleep(delay + idle);
+        assertNull(listener.exception, "Should not generate Exception");
+        assertEquals(1, listener.initialized, "Expected init to be called");
+        assertTrue(listener.notFound > 0, "fileNotFound should be called");
+        assertEquals(0, listener.rotated, "fileRotated should be not be called");
+        assertEquals(0, listener.reachedEndOfFile, "end of file never reached");
+    }
+
+    @Test
+    public void testTailer() throws Exception {
+
+        // Create & start the Tailer
+        final long delayMillis = 50;
+        final File file = new File(temporaryFolder, "tailer1-test.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener();
+        final String osname = System.getProperty("os.name");
+        final boolean isWindows = osname.startsWith("Windows");
+        try (Tailer tailer = new Tailer(file, listener, delayMillis, false, isWindows)) {
+            final Thread thread = new Thread(tailer);
+            thread.start();
+
+            // Write some lines to the file
+            write(file, "Line one", "Line two");
+            final long testDelayMillis = delayMillis * 10;
+            TestUtils.sleep(testDelayMillis);
+            List<String> lines = listener.getLines();
+            assertEquals(2, lines.size(), "1 line count");
+            assertEquals("Line one", lines.get(0), "1 line 1");
+            assertEquals("Line two", lines.get(1), "1 line 2");
+            listener.clear();
+
+            // Write another line to the file
+            write(file, "Line three");
+            TestUtils.sleep(testDelayMillis);
+            lines = listener.getLines();
+            assertEquals(1, lines.size(), "2 line count");
+            assertEquals("Line three", lines.get(0), "2 line 3");
+            listener.clear();
+
+            // Check file does actually have all the lines
+            lines = FileUtils.readLines(file, StandardCharsets.UTF_8);
+            assertEquals(3, lines.size(), "3 line count");
+            assertEquals("Line one", lines.get(0), "3 line 1");
+            assertEquals("Line two", lines.get(1), "3 line 2");
+            assertEquals("Line three", lines.get(2), "3 line 3");
+
+            // Delete & re-create
+            file.delete();
+            assertFalse(file.exists(), "File should not exist");
+            createFile(file, 0);
+            assertTrue(file.exists(), "File should now exist");
+            TestUtils.sleep(testDelayMillis);
+
+            // Write another line
+            write(file, "Line four");
+            TestUtils.sleep(testDelayMillis);
+            lines = listener.getLines();
+            assertEquals(1, lines.size(), "4 line count");
+            assertEquals("Line four", lines.get(0), "4 line 3");
+            listener.clear();
+
+            // Stop
+            thread.interrupt();
+            TestUtils.sleep(testDelayMillis * 4);
+            write(file, "Line five");
+            assertEquals(0, listener.getLines().size(), "4 line count");
+            assertNotNull(listener.exception, "Missing InterruptedException");
+            assertTrue(listener.exception instanceof InterruptedException, "Unexpected Exception: " + listener.exception);
+            assertEquals(1, listener.initialized, "Expected init to be called");
+            // assertEquals(0 , listener.notFound, "fileNotFound should not be called"); // there is a window when it might be
+            // called
+            assertEquals(1, listener.rotated, "fileRotated should be called");
+        }
+    }
+
+    @Test
+    public void testTailerEndOfFileReached() throws Exception {
+        // Create & start the Tailer
+        final long delayMillis = 50;
+        final long testDelayMillis = delayMillis * 10;
+        final File file = new File(temporaryFolder, "tailer-eof-test.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener();
+        final String osname = System.getProperty("os.name");
+        final boolean isWindows = osname.startsWith("Windows");
+        try (Tailer tailer = new Tailer(file, listener, delayMillis, false, isWindows)) {
+            final Thread thread = new Thread(tailer);
+            thread.start();
+
+            // write a few lines
+            write(file, "line1", "line2", "line3");
+            TestUtils.sleep(testDelayMillis);
+
+            // write a few lines
+            write(file, "line4", "line5", "line6");
+            TestUtils.sleep(testDelayMillis);
+
+            // write a few lines
+            write(file, "line7", "line8", "line9");
+            TestUtils.sleep(testDelayMillis);
+
+            // May be > 3 times due to underlying OS behavior wrt streams
+            assertTrue(listener.reachedEndOfFile >= 3, "end of file reached at least 3 times");
+        }
+    }
+
+    @Test
+    public void testTailerEof() throws Exception {
+        // Create & start the Tailer
+        final long delayMillis = 100;
+        final File file = new File(temporaryFolder, "tailer2-test.txt");
+        createFile(file, 0);
+        final TestTailerListener listener = new TestTailerListener();
+        try (Tailer tailer = new Tailer(file, listener, delayMillis, false)) {
+            final Thread thread = new Thread(tailer);
+            thread.start();
+
+            // Write some lines to the file
+            writeString(file, "Line");
+
+            TestUtils.sleep(delayMillis * 2);
+            List<String> lines = listener.getLines();
+            assertEquals(0, lines.size(), "1 line count");
+
+            writeString(file, " one\n");
+            TestUtils.sleep(delayMillis * 4);
+            lines = listener.getLines();
+
+            assertEquals(1, lines.size(), "1 line count");
+            assertEquals("Line one", lines.get(0), "1 line 1");
+
+            listener.clear();
+        }
+    }
+
+    private void validateTailer(final TestTailerListener listener, final File file) throws IOException, InterruptedException {
+        write(file, "foo");
+        final int timeout = 30;
+        final TimeUnit timeoutUnit = TimeUnit.SECONDS;
+        assertTrue(listener.awaitExpectedLines(timeout, timeoutUnit), () -> String.format("await timed out after %s %s", timeout, timeoutUnit));
+        assertEquals(listener.getLines(), Lists.newArrayList("foo"), "lines");
+    }
+
+    /** Appends lines to a file */
+    private void write(final File file, final String... lines) throws IOException {
+        try (Writer writer = Files.newBufferedWriter(file.toPath(), StandardOpenOption.APPEND)) {
+            for (final String line : lines) {
+                writer.write(line + "\n");
+            }
+        }
+    }
+
+    /** Appends strings to a file */
+    private void writeString(final File file, final String... strings) throws IOException {
+        try (Writer writer = Files.newBufferedWriter(file.toPath(), StandardOpenOption.APPEND)) {
+            for (final String string : strings) {
+                writer.write(string);
+            }
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/TeeInputStreamTest.java b/src/test/java/org/apache/commons/io/input/TeeInputStreamTest.java
new file mode 100644
index 0000000..7c095a3
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/TeeInputStreamTest.java
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import org.apache.commons.io.test.ThrowOnCloseInputStream;
+import org.apache.commons.io.test.ThrowOnCloseOutputStream;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link TeeInputStream}.
+ */
+public class TeeInputStreamTest  {
+
+    private final String ASCII = "US-ASCII";
+
+    private InputStream tee;
+
+    private ByteArrayOutputStream output;
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        final InputStream input = new ByteArrayInputStream("abc".getBytes(ASCII));
+        output = new ByteArrayOutputStream();
+        tee = new TeeInputStream(input, output);
+    }
+
+    /**
+     * Tests that the main {@code InputStream} is closed when closing the branch {@code OutputStream} throws an
+     * exception on {@link TeeInputStream#close()}, if specified to do so.
+     */
+    @Test
+    public void testCloseBranchIOException() throws Exception {
+        final ByteArrayInputStream goodIs = mock(ByteArrayInputStream.class);
+        final OutputStream badOs = new ThrowOnCloseOutputStream();
+
+        final TeeInputStream nonClosingTis = new TeeInputStream(goodIs, badOs, false);
+        nonClosingTis.close();
+        verify(goodIs).close();
+
+        final TeeInputStream closingTis = new TeeInputStream(goodIs, badOs, true);
+        try {
+            closingTis.close();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOException e) {
+            verify(goodIs, times(2)).close();
+        }
+    }
+
+    /**
+     * Tests that the branch {@code OutputStream} is closed when closing the main {@code InputStream} throws an
+     * exception on {@link TeeInputStream#close()}, if specified to do so.
+     */
+    @Test
+    public void testCloseMainIOException() throws IOException {
+        final InputStream badIs = new ThrowOnCloseInputStream();
+        final ByteArrayOutputStream goodOs = mock(ByteArrayOutputStream.class);
+
+        final TeeInputStream nonClosingTis = new TeeInputStream(badIs, goodOs, false);
+        try {
+            nonClosingTis.close();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOException e) {
+            verify(goodOs, never()).close();
+        }
+
+        final TeeInputStream closingTis = new TeeInputStream(badIs, goodOs, true);
+        try {
+            closingTis.close();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOException e) {
+            verify(goodOs).close();
+        }
+    }
+
+    @Test
+    public void testMarkReset() throws Exception {
+        assertEquals('a', tee.read());
+        tee.mark(1);
+        assertEquals('b', tee.read());
+        tee.reset();
+        assertEquals('b', tee.read());
+        assertEquals('c', tee.read());
+        assertEquals(-1, tee.read());
+        assertEquals("abbc", output.toString(ASCII));
+    }
+
+    @Test
+    public void testReadEverything() throws Exception {
+        assertEquals('a', tee.read());
+        assertEquals('b', tee.read());
+        assertEquals('c', tee.read());
+        assertEquals(-1, tee.read());
+        assertEquals("abc", output.toString(ASCII));
+    }
+
+    @Test
+    public void testReadNothing() throws Exception {
+        assertEquals("", output.toString(ASCII));
+    }
+
+    @Test
+    public void testReadOneByte() throws Exception {
+        assertEquals('a', tee.read());
+        assertEquals("a", output.toString(ASCII));
+    }
+
+    @Test
+    public void testReadToArray() throws Exception {
+        final byte[] buffer = new byte[8];
+        assertEquals(3, tee.read(buffer));
+        assertEquals('a', buffer[0]);
+        assertEquals('b', buffer[1]);
+        assertEquals('c', buffer[2]);
+        assertEquals(-1, tee.read(buffer));
+        assertEquals("abc", output.toString(ASCII));
+    }
+
+    @Test
+    public void testReadToArrayWithOffset() throws Exception {
+        final byte[] buffer = new byte[8];
+        assertEquals(3, tee.read(buffer, 4, 4));
+        assertEquals('a', buffer[4]);
+        assertEquals('b', buffer[5]);
+        assertEquals('c', buffer[6]);
+        assertEquals(-1, tee.read(buffer, 4, 4));
+        assertEquals("abc", output.toString(ASCII));
+    }
+
+    @Test
+    public void testSkip() throws Exception {
+        assertEquals('a', tee.read());
+        assertEquals(1, tee.skip(1));
+        assertEquals('c', tee.read());
+        assertEquals(-1, tee.read());
+        assertEquals("ac", output.toString(ASCII));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/TeeReaderTest.java b/src/test/java/org/apache/commons/io/input/TeeReaderTest.java
new file mode 100644
index 0000000..b697ca2
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/TeeReaderTest.java
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.nio.CharBuffer;
+
+import org.apache.commons.io.output.StringBuilderWriter;
+import org.apache.commons.io.test.ThrowOnCloseReader;
+import org.apache.commons.io.test.ThrowOnCloseWriter;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link TeeReader}.
+ */
+public class TeeReaderTest  {
+
+    private StringBuilderWriter output;
+
+    private Reader tee;
+
+    @BeforeEach
+    public void setUp() {
+        final Reader input = new CharSequenceReader("abc");
+        output = new StringBuilderWriter();
+        tee = new TeeReader(input, output);
+    }
+
+    /**
+     * Tests that the main {@code Reader} is closed when closing the branch {@code Writer} throws an
+     * exception on {@link TeeReader#close()}, if specified to do so.
+     */
+    @Test
+    public void testCloseBranchIOException() throws Exception {
+        final StringReader goodR = mock(StringReader.class);
+        final Writer badW = new ThrowOnCloseWriter();
+
+        final TeeReader nonClosingTr = new TeeReader(goodR, badW, false);
+        nonClosingTr.close();
+        verify(goodR).close();
+
+        final TeeReader closingTr = new TeeReader(goodR, badW, true);
+        try {
+            closingTr.close();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOException e) {
+            verify(goodR, times(2)).close();
+        }
+    }
+
+    /**
+     * Tests that the branch {@code Writer} is closed when closing the main {@code Reader} throws an
+     * exception on {@link TeeReader#close()}, if specified to do so.
+     */
+    @Test
+    public void testCloseMainIOException() throws IOException {
+        final Reader badR = new ThrowOnCloseReader();
+        final StringWriter goodW = mock(StringWriter.class);
+
+        final TeeReader nonClosingTr = new TeeReader(badR, goodW, false);
+        try {
+            nonClosingTr.close();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOException e) {
+            verify(goodW, never()).close();
+        }
+
+        final TeeReader closingTr = new TeeReader(badR, goodW, true);
+        try {
+            closingTr.close();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOException e) {
+            //Assert.assertTrue(goodW.closed);
+            verify(goodW).close();
+        }
+    }
+
+    @Test
+    public void testMarkReset() throws Exception {
+        assertEquals('a', tee.read());
+        tee.mark(1);
+        assertEquals('b', tee.read());
+        tee.reset();
+        assertEquals('b', tee.read());
+        assertEquals('c', tee.read());
+        assertEquals(-1, tee.read());
+        assertEquals("abbc", output.toString());
+    }
+
+    @Test
+    public void testReadEverything() throws Exception {
+        assertEquals('a', tee.read());
+        assertEquals('b', tee.read());
+        assertEquals('c', tee.read());
+        assertEquals(-1, tee.read());
+        assertEquals("abc", output.toString());
+    }
+
+    @Test
+    public void testReadNothing() {
+        assertEquals("", output.toString());
+    }
+
+    @Test
+    public void testReadOneChar() throws Exception {
+        assertEquals('a', tee.read());
+        assertEquals("a", output.toString());
+    }
+
+    @Test
+    public void testReadToArray() throws Exception {
+        final char[] buffer = new char[8];
+        assertEquals(3, tee.read(buffer));
+        assertEquals('a', buffer[0]);
+        assertEquals('b', buffer[1]);
+        assertEquals('c', buffer[2]);
+        assertEquals(-1, tee.read(buffer));
+        assertEquals("abc", output.toString());
+    }
+
+    @Test
+    public void testReadToArrayWithOffset() throws Exception {
+        final char[] buffer = new char[8];
+        assertEquals(3, tee.read(buffer, 4, 4));
+        assertEquals('a', buffer[4]);
+        assertEquals('b', buffer[5]);
+        assertEquals('c', buffer[6]);
+        assertEquals(-1, tee.read(buffer, 4, 4));
+        assertEquals("abc", output.toString());
+    }
+
+    @Test
+    public void testReadToCharBuffer() throws Exception {
+        final CharBuffer buffer = CharBuffer.allocate(8);
+        buffer.position(1);
+        assertEquals(3, tee.read(buffer));
+        assertEquals(4, buffer.position());
+        buffer.flip();
+        buffer.position(1);
+        assertEquals('a', buffer.charAt(0));
+        assertEquals('b', buffer.charAt(1));
+        assertEquals('c', buffer.charAt(2));
+        assertEquals(-1, tee.read(buffer));
+        assertEquals("abc", output.toString());
+    }
+
+    @Test
+    public void testSkip() throws Exception {
+        assertEquals('a', tee.read());
+        assertEquals(1, tee.skip(1));
+        assertEquals('c', tee.read());
+        assertEquals(-1, tee.read());
+        assertEquals("ac", output.toString());
+    }
+
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/commons/io/input/TimestampedObserverTest.java b/src/test/java/org/apache/commons/io/input/TimestampedObserverTest.java
new file mode 100644
index 0000000..5492200
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/TimestampedObserverTest.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.ThreadUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link TimestampedObserver}.
+ */
+public class TimestampedObserverTest {
+
+    @Test
+    public void test() throws IOException, InterruptedException {
+        final Instant before = Instant.now();
+        // Some OS' clock granularity may be high.
+        ThreadUtils.sleep(Duration.ofMillis(20));
+        final TimestampedObserver timestampedObserver = new TimestampedObserver();
+        assertFalse(timestampedObserver.isClosed());
+        // Java 8 instant resolution is not great.
+        ThreadUtils.sleep(Duration.ofMillis(20));
+        // toString() should not blow up before close().
+        assertNotNull(timestampedObserver.toString());
+        assertTrue(timestampedObserver.getOpenInstant().isAfter(before));
+        assertTrue(timestampedObserver.getOpenToNowDuration().toNanos() > 0);
+        assertNull(timestampedObserver.getCloseInstant());
+        assertFalse(timestampedObserver.isClosed());
+        final byte[] buffer = MessageDigestCalculatingInputStreamTest.generateRandomByteStream(IOUtils.DEFAULT_BUFFER_SIZE);
+        try (ObservableInputStream ois = new ObservableInputStream(new ByteArrayInputStream(buffer), timestampedObserver)) {
+            assertTrue(timestampedObserver.getOpenInstant().isAfter(before));
+            assertTrue(timestampedObserver.getOpenToNowDuration().toNanos() > 0);
+            assertFalse(timestampedObserver.isClosed());
+        }
+        assertTrue(timestampedObserver.isClosed());
+        assertTrue(timestampedObserver.getOpenInstant().isAfter(before));
+        assertTrue(timestampedObserver.getOpenToNowDuration().toNanos() > 0);
+        assertTrue(timestampedObserver.getCloseInstant().isAfter(timestampedObserver.getOpenInstant()));
+        assertTrue(timestampedObserver.getOpenToCloseDuration().toNanos() > 0);
+        assertNotNull(timestampedObserver.toString());
+    }
+
+    @Test
+    public void testExample() throws IOException {
+        final TimestampedObserver timestampedObserver = new TimestampedObserver();
+        final byte[] buffer = MessageDigestCalculatingInputStreamTest
+            .generateRandomByteStream(IOUtils.DEFAULT_BUFFER_SIZE);
+        try (ObservableInputStream ois = new ObservableInputStream(new ByteArrayInputStream(buffer),
+            timestampedObserver)) {
+            //
+        }
+        // System.out.printf("IO duration: %s%n", timestampedObserver);
+        // System.out.printf("IO duration: %s%n", timestampedObserver.getOpenToCloseDuration());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/UncheckedBufferedReaderTest.java b/src/test/java/org/apache/commons/io/input/UncheckedBufferedReaderTest.java
new file mode 100644
index 0000000..fb0f18b
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/UncheckedBufferedReaderTest.java
@@ -0,0 +1,210 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.io.UncheckedIOException;
+import java.nio.CharBuffer;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link UncheckedFilterReader}.
+ */
+public class UncheckedBufferedReaderTest {
+
+    private UncheckedBufferedReader ucStringReader;
+    private UncheckedBufferedReader ucBrokenReader;
+    private IOException exception = new IOException("test exception");
+
+    @SuppressWarnings("resource")
+    @BeforeEach
+    public void beforeEach() {
+        ucStringReader = UncheckedBufferedReader.on(new StringReader("01"));
+        exception = new IOException("test exception");
+        ucBrokenReader = UncheckedBufferedReader.on(new BrokenReader(exception));
+    }
+
+    @Test
+    public void testBufferSize() {
+        try (UncheckedBufferedReader uncheckedReader = new UncheckedBufferedReader(new StringReader("0123456789"), 2)) {
+            assertEquals('0', uncheckedReader.read());
+        }
+    }
+
+    @Test
+    public void testClose() {
+        ucStringReader.close();
+        assertThrows(UncheckedIOException.class, () -> ucBrokenReader.read());
+    }
+
+    @Test
+    public void testCloseThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> ucBrokenReader.close()).getCause());
+    }
+
+    @Test
+    public void testMarkReset() {
+        ucStringReader.mark(10);
+        final int c = ucStringReader.read();
+        ucStringReader.reset();
+        assertEquals(c, ucStringReader.read());
+    }
+
+    @Test
+    public void testMarkThrows() {
+        try (UncheckedBufferedReader closedReader = UncheckedBufferedReader.on(ClosedReader.INSTANCE)) {
+            closedReader.close();
+            assertThrows(UncheckedIOException.class, () -> closedReader.mark(1));
+        }
+    }
+
+    @Test
+    public void testRead() {
+        try (UncheckedBufferedReader uncheckedReader = UncheckedBufferedReader.on(ucStringReader)) {
+            assertEquals('0', uncheckedReader.read());
+            assertEquals('1', uncheckedReader.read());
+            assertEquals(IOUtils.EOF, uncheckedReader.read());
+            assertEquals(IOUtils.EOF, uncheckedReader.read());
+        }
+    }
+
+    @Test
+    public void testReadCharArray() {
+        try (UncheckedBufferedReader uncheckedReader = UncheckedBufferedReader.on(ucStringReader)) {
+            final char[] array = new char[1];
+            assertEquals(1, uncheckedReader.read(array));
+            assertEquals('0', array[0]);
+            array[0] = 0;
+            assertEquals(1, uncheckedReader.read(array));
+            assertEquals('1', array[0]);
+            array[0] = 0;
+            assertEquals(IOUtils.EOF, uncheckedReader.read(array));
+            assertEquals(0, array[0]);
+            assertEquals(IOUtils.EOF, uncheckedReader.read(array));
+            assertEquals(0, array[0]);
+        }
+    }
+
+    @Test
+    public void testReadCharArrayIndexed() {
+        try (UncheckedBufferedReader uncheckedReader = UncheckedBufferedReader.on(ucStringReader)) {
+            final char[] array = new char[1];
+            assertEquals(1, uncheckedReader.read(array, 0, 1));
+            assertEquals('0', array[0]);
+            array[0] = 0;
+            assertEquals(1, uncheckedReader.read(array, 0, 1));
+            assertEquals('1', array[0]);
+            array[0] = 0;
+            assertEquals(IOUtils.EOF, uncheckedReader.read(array, 0, 1));
+            assertEquals(0, array[0]);
+            assertEquals(IOUtils.EOF, uncheckedReader.read(array, 0, 1));
+            assertEquals(0, array[0]);
+        }
+    }
+
+    @Test
+    public void testReadCharArrayIndexedThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> ucBrokenReader.read(new char[1], 0, 1)).getCause());
+    }
+
+    @Test
+    public void testReadCharArrayThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> ucBrokenReader.read(new char[1])).getCause());
+    }
+
+    @Test
+    public void testReadCharBuffer() {
+        try (UncheckedBufferedReader uncheckedReader = UncheckedBufferedReader.on(ucStringReader)) {
+            final CharBuffer buffer = CharBuffer.wrap(new char[1]);
+            assertEquals(1, uncheckedReader.read(buffer));
+            buffer.flip();
+            assertEquals('0', buffer.charAt(0));
+            buffer.put(0, (char) 0);
+            assertEquals(1, uncheckedReader.read(buffer));
+            buffer.flip();
+            assertEquals('1', buffer.charAt(0));
+            buffer.put(0, (char) 0);
+            assertEquals(IOUtils.EOF, uncheckedReader.read(buffer));
+            buffer.flip();
+            assertEquals(0, buffer.length());
+            assertEquals(0, uncheckedReader.read(buffer));
+            buffer.flip();
+            assertEquals(0, buffer.length());
+        }
+    }
+
+    @Test
+    public void testReadCharBufferThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> ucBrokenReader.read(CharBuffer.wrap(new char[1]))).getCause());
+    }
+
+    @Test
+    public void testReadLine() {
+        try (UncheckedBufferedReader uncheckedReader = UncheckedBufferedReader.on(ucStringReader)) {
+            assertEquals("01", uncheckedReader.readLine());
+            assertEquals(IOUtils.EOF, uncheckedReader.read());
+            assertEquals(IOUtils.EOF, uncheckedReader.read());
+        }
+    }
+
+    @Test
+    public void testReadLineThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> ucBrokenReader.readLine()).getCause());
+    }
+
+    @Test
+    public void testReadThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> ucBrokenReader.read()).getCause());
+    }
+
+    @Test
+    public void testReady() {
+        assertTrue(ucStringReader.ready());
+    }
+
+    @Test
+    public void testReadyThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> ucBrokenReader.ready()).getCause());
+    }
+
+    @Test
+    public void testResetThrows() {
+        try (UncheckedBufferedReader closedReader = UncheckedBufferedReader.on(ClosedReader.INSTANCE)) {
+            closedReader.close();
+            assertThrows(UncheckedIOException.class, () -> ucBrokenReader.reset());
+        }
+    }
+
+    @Test
+    public void testSkip() {
+        assertEquals(1, ucStringReader.skip(1));
+    }
+
+    @Test
+    public void testSkipThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> ucBrokenReader.skip(1)).getCause());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/UncheckedFilterInputStreamTest.java b/src/test/java/org/apache/commons/io/input/UncheckedFilterInputStreamTest.java
new file mode 100644
index 0000000..2da41b5
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/UncheckedFilterInputStreamTest.java
@@ -0,0 +1,144 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link UncheckedFilterInputStream}.
+ */
+public class UncheckedFilterInputStreamTest {
+
+    private UncheckedFilterInputStream stringInputStream;
+    private UncheckedFilterInputStream brokenInputStream;
+    private IOException exception = new IOException("test exception");
+
+    @SuppressWarnings("resource")
+    @BeforeEach
+    public void beforeEach() {
+        stringInputStream = UncheckedFilterInputStream.on(new BufferedInputStream(new StringInputStream("01")));
+        exception = new IOException("test exception");
+        brokenInputStream = UncheckedFilterInputStream.on(new BrokenInputStream(exception));
+    }
+
+    @Test
+    public void testClose() {
+        stringInputStream.close();
+        assertThrows(UncheckedIOException.class, () -> brokenInputStream.read());
+    }
+
+    @Test
+    public void testCloseThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenInputStream.close()).getCause());
+    }
+
+    @Test
+    public void testMarkReset() {
+        stringInputStream.mark(10);
+        final int c = stringInputStream.read();
+        stringInputStream.reset();
+        assertEquals(c, stringInputStream.read());
+    }
+
+    @Test
+    public void testRead() {
+        try (UncheckedFilterInputStream uncheckedReader = UncheckedFilterInputStream.on(stringInputStream)) {
+            assertEquals('0', uncheckedReader.read());
+            assertEquals('1', uncheckedReader.read());
+            assertEquals(IOUtils.EOF, uncheckedReader.read());
+            assertEquals(IOUtils.EOF, uncheckedReader.read());
+        }
+    }
+
+    @Test
+    public void testReadByteArray() {
+        try (UncheckedFilterInputStream uncheckedReader = UncheckedFilterInputStream.on(stringInputStream)) {
+            final byte[] array = new byte[1];
+            assertEquals(1, uncheckedReader.read(array));
+            assertEquals('0', array[0]);
+            array[0] = 0;
+            assertEquals(1, uncheckedReader.read(array));
+            assertEquals('1', array[0]);
+            array[0] = 0;
+            assertEquals(IOUtils.EOF, uncheckedReader.read(array));
+            assertEquals(0, array[0]);
+            assertEquals(IOUtils.EOF, uncheckedReader.read(array));
+            assertEquals(0, array[0]);
+        }
+    }
+
+    @Test
+    public void testReadByteArrayIndexed() {
+        try (UncheckedFilterInputStream uncheckedReader = UncheckedFilterInputStream.on(stringInputStream)) {
+            final byte[] array = new byte[1];
+            assertEquals(1, uncheckedReader.read(array, 0, 1));
+            assertEquals('0', array[0]);
+            array[0] = 0;
+            assertEquals(1, uncheckedReader.read(array, 0, 1));
+            assertEquals('1', array[0]);
+            array[0] = 0;
+            assertEquals(IOUtils.EOF, uncheckedReader.read(array, 0, 1));
+            assertEquals(0, array[0]);
+            assertEquals(IOUtils.EOF, uncheckedReader.read(array, 0, 1));
+            assertEquals(0, array[0]);
+        }
+    }
+
+    @Test
+    public void testReadByteArrayIndexedThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenInputStream.read(new byte[1], 0, 1)).getCause());
+    }
+
+    @Test
+    public void testReadByteArrayThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenInputStream.read(new byte[1])).getCause());
+    }
+
+    @Test
+    public void testReadThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenInputStream.read()).getCause());
+    }
+
+    @Test
+    public void testResetThrows() {
+        try (UncheckedFilterInputStream closedReader = UncheckedFilterInputStream.on(ClosedInputStream.INSTANCE)) {
+            closedReader.close();
+            assertThrows(UncheckedIOException.class, () -> brokenInputStream.reset());
+        }
+    }
+
+    @Test
+    public void testSkip() {
+        assertEquals(1, stringInputStream.skip(1));
+    }
+
+    @Test
+    public void testSkipThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenInputStream.skip(1)).getCause());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/UncheckedFilterReaderTest.java b/src/test/java/org/apache/commons/io/input/UncheckedFilterReaderTest.java
new file mode 100644
index 0000000..1c75795
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/UncheckedFilterReaderTest.java
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.io.UncheckedIOException;
+import java.nio.CharBuffer;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link UncheckedFilterReader}.
+ */
+public class UncheckedFilterReaderTest {
+
+    private UncheckedFilterReader ucStringReader;
+    private UncheckedFilterReader ucBrokenReader;
+    private IOException exception = new IOException("test exception");
+
+    @SuppressWarnings("resource")
+    @BeforeEach
+    public void beforeEach() {
+        ucStringReader = UncheckedFilterReader.on(new StringReader("01"));
+        exception = new IOException("test exception");
+        ucBrokenReader = UncheckedFilterReader.on(new BrokenReader(exception));
+    }
+
+    @Test
+    public void testClose() {
+        ucStringReader.close();
+        assertThrows(UncheckedIOException.class, () -> ucBrokenReader.read());
+    }
+
+    @Test
+    public void testCloseThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> ucBrokenReader.close()).getCause());
+    }
+
+    @Test
+    public void testMarkReset() {
+        ucStringReader.mark(10);
+        final int c = ucStringReader.read();
+        ucStringReader.reset();
+        assertEquals(c, ucStringReader.read());
+    }
+
+    @Test
+    public void testMarkThrows() {
+        try (UncheckedFilterReader closedReader = UncheckedFilterReader.on(ClosedReader.INSTANCE)) {
+            closedReader.close();
+            assertThrows(UncheckedIOException.class, () -> closedReader.mark(1));
+        }
+    }
+
+    @Test
+    public void testRead() {
+        try (UncheckedFilterReader uncheckedReader = UncheckedFilterReader.on(ucStringReader)) {
+            assertEquals('0', uncheckedReader.read());
+            assertEquals('1', uncheckedReader.read());
+            assertEquals(IOUtils.EOF, uncheckedReader.read());
+            assertEquals(IOUtils.EOF, uncheckedReader.read());
+        }
+    }
+
+    @Test
+    public void testReadCharArray() {
+        try (UncheckedFilterReader uncheckedReader = UncheckedFilterReader.on(ucStringReader)) {
+            final char[] array = new char[1];
+            assertEquals(1, uncheckedReader.read(array));
+            assertEquals('0', array[0]);
+            array[0] = 0;
+            assertEquals(1, uncheckedReader.read(array));
+            assertEquals('1', array[0]);
+            array[0] = 0;
+            assertEquals(IOUtils.EOF, uncheckedReader.read(array));
+            assertEquals(0, array[0]);
+            assertEquals(IOUtils.EOF, uncheckedReader.read(array));
+            assertEquals(0, array[0]);
+        }
+    }
+
+    @Test
+    public void testReadCharArrayIndexed() {
+        try (UncheckedFilterReader uncheckedReader = UncheckedFilterReader.on(ucStringReader)) {
+            final char[] array = new char[1];
+            assertEquals(1, uncheckedReader.read(array, 0, 1));
+            assertEquals('0', array[0]);
+            array[0] = 0;
+            assertEquals(1, uncheckedReader.read(array, 0, 1));
+            assertEquals('1', array[0]);
+            array[0] = 0;
+            assertEquals(IOUtils.EOF, uncheckedReader.read(array, 0, 1));
+            assertEquals(0, array[0]);
+            assertEquals(IOUtils.EOF, uncheckedReader.read(array, 0, 1));
+            assertEquals(0, array[0]);
+        }
+    }
+
+    @Test
+    public void testReadCharArrayIndexedThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> ucBrokenReader.read(new char[1], 0, 1)).getCause());
+    }
+
+    @Test
+    public void testReadCharArrayThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> ucBrokenReader.read(new char[1])).getCause());
+    }
+
+    @Test
+    public void testReadCharBuffer() {
+        try (UncheckedFilterReader uncheckedReader = UncheckedFilterReader.on(ucStringReader)) {
+            final CharBuffer buffer = CharBuffer.wrap(new char[1]);
+            assertEquals(1, uncheckedReader.read(buffer));
+            buffer.flip();
+            assertEquals('0', buffer.charAt(0));
+            buffer.put(0, (char) 0);
+            assertEquals(1, uncheckedReader.read(buffer));
+            buffer.flip();
+            assertEquals('1', buffer.charAt(0));
+            buffer.put(0, (char) 0);
+            assertEquals(IOUtils.EOF, uncheckedReader.read(buffer));
+            buffer.flip();
+            assertEquals(0, buffer.length());
+            assertEquals(0, uncheckedReader.read(buffer));
+            buffer.flip();
+            assertEquals(0, buffer.length());
+        }
+    }
+
+    @Test
+    public void testReadCharBufferThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> ucBrokenReader.read(CharBuffer.wrap(new char[1]))).getCause());
+    }
+
+    @Test
+    public void testReadThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> ucBrokenReader.read()).getCause());
+    }
+
+    @Test
+    public void testReady() {
+        assertTrue(ucStringReader.ready());
+    }
+
+    @Test
+    public void testReadyThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> ucBrokenReader.ready()).getCause());
+    }
+
+    @Test
+    public void testResetThrows() {
+        try (UncheckedFilterReader closedReader = UncheckedFilterReader.on(ClosedReader.INSTANCE)) {
+            closedReader.close();
+            assertThrows(UncheckedIOException.class, () -> ucBrokenReader.reset());
+        }
+    }
+
+    @Test
+    public void testSkip() {
+        assertEquals(1, ucStringReader.skip(1));
+    }
+
+    @Test
+    public void testSkipThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> ucBrokenReader.skip(1)).getCause());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/UnixLineEndingInputStreamTest.java b/src/test/java/org/apache/commons/io/input/UnixLineEndingInputStreamTest.java
new file mode 100644
index 0000000..66f0696
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/UnixLineEndingInputStreamTest.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+public class UnixLineEndingInputStreamTest {
+
+    @Test
+    public void crAtEnd() throws Exception {
+        assertEquals("a\n", roundtrip("a\r"));
+    }
+
+    @Test
+    public void crOnlyEnsureAtEof() throws Exception {
+        assertEquals("a\nb\n", roundtrip("a\rb"));
+    }
+
+    @Test
+    public void crOnlyNotAtEof() throws Exception {
+        assertEquals("a\nb", roundtrip("a\rb", false));
+    }
+
+    @Test
+    public void inTheMiddleOfTheLine() throws Exception {
+        assertEquals("a\nbc\n", roundtrip("a\r\nbc"));
+    }
+
+    @Test
+    public void multipleBlankLines() throws Exception {
+        assertEquals("a\n\nbc\n", roundtrip("a\r\n\r\nbc"));
+    }
+
+    @Test
+    public void retainLineFeed() throws Exception {
+        assertEquals("a\n\n", roundtrip("a\r\n\r\n", false));
+        assertEquals("a", roundtrip("a", false));
+    }
+
+    private String roundtrip(final String msg) throws IOException {
+        return roundtrip(msg, true);
+    }
+
+    private String roundtrip(final String msg, final boolean ensure) throws IOException {
+        try (final ByteArrayInputStream baos = new ByteArrayInputStream(msg.getBytes(StandardCharsets.UTF_8));
+            final UnixLineEndingInputStream lf = new UnixLineEndingInputStream(baos, ensure)) {
+            final byte[] buf = new byte[100];
+            return new String(buf, 0, lf.read(buf), StandardCharsets.UTF_8);
+        }
+    }
+
+    @Test
+    public void simpleString() throws Exception {
+        assertEquals("abc\n", roundtrip("abc"));
+    }
+
+    @Test
+    public void twoLinesAtEnd() throws Exception {
+        assertEquals("a\n\n", roundtrip("a\r\n\r\n"));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/UnsynchronizedBufferedInputStreamTest.java b/src/test/java/org/apache/commons/io/input/UnsynchronizedBufferedInputStreamTest.java
new file mode 100644
index 0000000..547f73c
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/UnsynchronizedBufferedInputStreamTest.java
@@ -0,0 +1,484 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link UnsynchronizedBufferedInputStream}.
+ * <p>
+ * Provenance: Apache Harmony and modified.
+ * </p>
+ */
+public class UnsynchronizedBufferedInputStreamTest {
+
+    private static final int BUFFER_SIZE = 4096;
+
+    static class MyUnsynchronizedBufferedInputStream extends UnsynchronizedBufferedInputStream {
+        static byte[] buf;
+
+        MyUnsynchronizedBufferedInputStream(final InputStream is) {
+            super(is);
+            buf = super.buf;
+        }
+
+        MyUnsynchronizedBufferedInputStream(final InputStream is, final int size) {
+            super(is, size);
+            buf = super.buf;
+        }
+    }
+
+    Path fileName;
+
+    private BufferedInputStream is;
+
+    private InputStream isFile;
+
+    byte[] ibuf = new byte[BUFFER_SIZE];
+
+    public static final String DATA = StringUtils.repeat("This is a test.", 500);
+
+    /**
+     * Sets up the fixture, for example, open a network connection. This method is called before a test is executed.
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @BeforeEach
+    protected void setUp() throws IOException {
+        fileName = Files.createTempFile(getClass().getSimpleName(), ".tst");
+        Files.write(fileName, DATA.getBytes("UTF-8"));
+
+        isFile = Files.newInputStream(fileName);
+        is = new BufferedInputStream(isFile);
+    }
+
+    /**
+     * Tears down the fixture, for example, close a network connection. This method is called after a test is executed.
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @AfterEach
+    protected void tearDown() throws IOException {
+        IOUtils.closeQuietly(is);
+        Files.deleteIfExists(fileName);
+    }
+
+    /**
+     * Tests java.io.BufferedInputStream#available()
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_available() throws IOException {
+        assertTrue(is.available() == DATA.length(), "Returned incorrect number of available bytes");
+
+        // Test that a closed stream throws an IOE for available()
+        final BufferedInputStream bis = new BufferedInputStream(new ByteArrayInputStream(new byte[] { 'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd' }));
+        final int available = bis.available();
+        bis.close();
+        assertTrue(available != 0);
+
+        assertThrows(IOException.class, () -> bis.available(), "Expected test to throw IOE.");
+    }
+
+    /**
+     * Tests java.io.BufferedInputStream#close()
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_close() throws IOException {
+        new BufferedInputStream(isFile).close();
+
+        // regression for HARMONY-667
+        try (BufferedInputStream buf = new BufferedInputStream(null, 5)) {
+            // closes
+        }
+
+        try (InputStream in = new InputStream() {
+            Object lock = new Object();
+
+            @Override
+            public void close() {
+                synchronized (lock) {
+                    lock.notifyAll();
+                }
+            }
+
+            @Override
+            public int read() {
+                return 1;
+            }
+
+            @Override
+            public int read(final byte[] buf, final int offset, final int length) {
+                synchronized (lock) {
+                    try {
+                        lock.wait(3000);
+                    } catch (final InterruptedException e) {
+                        // Ignore
+                    }
+                }
+                return 1;
+            }
+        }) {
+            final BufferedInputStream bufin = new BufferedInputStream(in);
+            final Thread thread = new Thread(() -> {
+                try {
+                    Thread.sleep(1000);
+                    bufin.close();
+                } catch (final Exception e) {
+                    // Ignored
+                }
+            });
+            thread.start();
+            assertThrows(IOException.class, () -> bufin.read(new byte[100], 0, 99), "Should throw IOException");
+        }
+    }
+
+    /*
+     * Tests java.io.BufferedInputStream(InputStream)
+     */
+    @Test
+    public void test_ConstructorLjava_io_InputStream() throws IOException {
+        try (BufferedInputStream str = new BufferedInputStream(null)) {
+            assertThrows(IOException.class, () -> str.read(), "Expected an IOException");
+        }
+    }
+
+    /*
+     * Tests java.io.BufferedInputStream(InputStream)
+     */
+    @Test
+    public void test_ConstructorLjava_io_InputStreamI() throws IOException {
+        try (BufferedInputStream str = new BufferedInputStream(null, 1)) {
+            assertThrows(IOException.class, () -> str.read(), "Expected an IOException");
+        }
+
+        // Test for method java.io.BufferedInputStream(java.io.InputStream, int)
+
+        // Create buffer with exact size of file
+        is = new BufferedInputStream(isFile, this.DATA.length());
+        // Ensure buffer gets filled by evaluating one read
+        is.read();
+        // Close underlying FileInputStream, all but 1 buffered bytes should
+        // still be available.
+        isFile.close();
+        // Read the remaining buffered characters, no IOException should
+        // occur.
+        is.skip(this.DATA.length() - 2);
+        is.read();
+        // is.read should now throw an exception because it will have to be filled.
+        assertThrows(IOException.class, () -> is.read());
+
+        // regression test for harmony-2407
+        new MyUnsynchronizedBufferedInputStream(null);
+        assertNotNull(MyUnsynchronizedBufferedInputStream.buf);
+        MyUnsynchronizedBufferedInputStream.buf = null;
+        new MyUnsynchronizedBufferedInputStream(null, 100);
+        assertNotNull(MyUnsynchronizedBufferedInputStream.buf);
+    }
+
+    /**
+     * Tests java.io.BufferedInputStream#mark(int)
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_markI() throws IOException {
+        final byte[] buf1 = new byte[100];
+        final byte[] buf2 = new byte[100];
+        is.skip(3000);
+        is.mark(1000);
+        is.read(buf1, 0, buf1.length);
+        is.reset();
+        is.read(buf2, 0, buf2.length);
+        is.reset();
+        assertTrue(new String(buf1, 0, buf1.length).equals(new String(buf2, 0, buf2.length)), "Failed to mark correct position");
+
+        byte[] bytes = new byte[256];
+        for (int i = 0; i < 256; i++) {
+            bytes[i] = (byte) i;
+        }
+        InputStream in = new BufferedInputStream(new ByteArrayInputStream(bytes), 12);
+        in.skip(6);
+        in.mark(14);
+        in.read(new byte[14], 0, 14);
+        in.reset();
+        assertTrue(in.read() == 6 && in.read() == 7, "Wrong bytes");
+
+        in = new BufferedInputStream(new ByteArrayInputStream(bytes), 12);
+        in.skip(6);
+        in.mark(8);
+        in.skip(7);
+        in.reset();
+        assertTrue(in.read() == 6 && in.read() == 7, "Wrong bytes 2");
+
+        BufferedInputStream buf = new BufferedInputStream(new ByteArrayInputStream(new byte[] { 0, 1, 2, 3, 4 }), 2);
+        buf.mark(3);
+        bytes = new byte[3];
+        int result = buf.read(bytes);
+        assertEquals(3, result);
+        assertEquals(0, bytes[0], "Assert 0:");
+        assertEquals(1, bytes[1], "Assert 1:");
+        assertEquals(2, bytes[2], "Assert 2:");
+        assertEquals(3, buf.read(), "Assert 3:");
+
+        buf = new BufferedInputStream(new ByteArrayInputStream(new byte[] { 0, 1, 2, 3, 4 }), 2);
+        buf.mark(3);
+        bytes = new byte[4];
+        result = buf.read(bytes);
+        assertEquals(4, result);
+        assertEquals(0, bytes[0], "Assert 4:");
+        assertEquals(1, bytes[1], "Assert 5:");
+        assertEquals(2, bytes[2], "Assert 6:");
+        assertEquals(3, bytes[3], "Assert 7:");
+        assertEquals(4, buf.read(), "Assert 8:");
+        assertEquals(-1, buf.read(), "Assert 9:");
+
+        buf = new BufferedInputStream(new ByteArrayInputStream(new byte[] { 0, 1, 2, 3, 4 }), 2);
+        buf.mark(Integer.MAX_VALUE);
+        buf.read();
+        buf.close();
+    }
+
+    /**
+     * Tests java.io.BufferedInputStream#markSupported()
+     */
+    @Test
+    public void test_markSupported() {
+        assertTrue(is.markSupported(), "markSupported returned incorrect value");
+    }
+
+    /**
+     * Tests java.io.BufferedInputStream#read()
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_read() throws IOException {
+        final InputStreamReader isr = new InputStreamReader(is);
+        final int c = isr.read();
+        assertTrue(c == DATA.charAt(0), "read returned incorrect char");
+
+        final byte[] bytes = new byte[256];
+        for (int i = 0; i < 256; i++) {
+            bytes[i] = (byte) i;
+        }
+        final InputStream in = new BufferedInputStream(new ByteArrayInputStream(bytes), 12);
+        assertEquals(0, in.read(), "Wrong initial byte"); // Fill the buffer
+        final byte[] buf = new byte[14];
+        in.read(buf, 0, 14); // Read greater than the buffer
+        assertTrue(new String(buf, 0, 14).equals(new String(bytes, 1, 14)), "Wrong block read data");
+        assertEquals(15, in.read(), "Wrong bytes"); // Check next byte
+    }
+
+    /**
+     * Tests java.io.BufferedInputStream#read(byte[], int, int)
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_read$BII() throws IOException {
+        final byte[] buf1 = new byte[100];
+        is.skip(3000);
+        is.mark(1000);
+        is.read(buf1, 0, buf1.length);
+        assertTrue(new String(buf1, 0, buf1.length).equals(DATA.substring(3000, 3100)), "Failed to read correct data");
+
+        try (BufferedInputStream bufin = new BufferedInputStream(new InputStream() {
+            int size = 2, pos = 0;
+
+            byte[] contents = new byte[size];
+
+            @Override
+            public int available() {
+                return size - pos;
+            }
+
+            @Override
+            public int read() throws IOException {
+                if (pos >= size) {
+                    throw new IOException("Read past end of data");
+                }
+                return contents[pos++];
+            }
+
+            @Override
+            public int read(final byte[] buf, final int off, final int len) throws IOException {
+                if (pos >= size) {
+                    throw new IOException("Read past end of data");
+                }
+                int toRead = len;
+                if (toRead > available()) {
+                    toRead = available();
+                }
+                System.arraycopy(contents, pos, buf, off, toRead);
+                pos += toRead;
+                return toRead;
+            }
+        })) {
+            bufin.read();
+            final int result = bufin.read(new byte[2], 0, 2);
+            assertTrue(result == 1, () -> "Incorrect result: " + result);
+        }
+    }
+
+    /**
+     * Tests java.io.BufferedInputStream#read(byte[], int, int)
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_read$BII_Exception() throws IOException {
+        final BufferedInputStream bis = new BufferedInputStream(null);
+        assertThrows(NullPointerException.class, () -> bis.read(null, -1, -1));
+
+        assertThrows(IndexOutOfBoundsException.class, () -> bis.read(new byte[0], -1, -1));
+        assertThrows(IndexOutOfBoundsException.class, () -> bis.read(new byte[0], 1, -1));
+        assertThrows(IndexOutOfBoundsException.class, () -> bis.read(new byte[0], 1, 1));
+
+        bis.close();
+
+        assertThrows(IOException.class, () -> bis.read(null, -1, -1));
+    }
+
+    /**
+     * Tests java.io.BufferedInputStream#reset()
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_reset() throws IOException {
+        final byte[] buf1 = new byte[10];
+        final byte[] buf2 = new byte[10];
+        is.mark(2000);
+        is.read(buf1, 0, 10);
+        is.reset();
+        is.read(buf2, 0, 10);
+        is.reset();
+        assertTrue(new String(buf1, 0, buf1.length).equals(new String(buf2, 0, buf2.length)), "Reset failed");
+
+        final BufferedInputStream bIn = new BufferedInputStream(new ByteArrayInputStream("1234567890".getBytes()));
+        bIn.mark(10);
+        for (int i = 0; i < 11; i++) {
+            bIn.read();
+        }
+        bIn.reset();
+    }
+
+    /**
+     * Tests java.io.BufferedInputStream#reset()
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_reset_Exception() throws IOException {
+        final BufferedInputStream bis = new BufferedInputStream(null);
+
+        // throws IOException with message "Mark has been invalidated"
+        assertThrows(IOException.class, () -> bis.reset());
+
+        // does not throw IOException
+        bis.mark(1);
+        bis.reset();
+
+        bis.close();
+
+        // throws IOException with message "stream is closed"
+        assertThrows(IOException.class, () -> bis.reset());
+    }
+
+    /**
+     * Tests java.io.BufferedInputStream#reset()
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_reset_scenario1() throws IOException {
+        final byte[] input = "12345678900".getBytes();
+        final BufferedInputStream buffis = new BufferedInputStream(new ByteArrayInputStream(input));
+        buffis.read();
+        buffis.mark(5);
+        buffis.skip(5);
+        buffis.reset();
+    }
+
+    /**
+     * Tests java.io.BufferedInputStream#reset()
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_reset_scenario2() throws IOException {
+        final byte[] input = "12345678900".getBytes();
+        final BufferedInputStream buffis = new BufferedInputStream(new ByteArrayInputStream(input));
+        buffis.mark(5);
+        buffis.skip(6);
+        buffis.reset();
+    }
+
+    /**
+     * Tests java.io.BufferedInputStream#skip(long)
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_skip_NullInputStream() throws IOException {
+        try (BufferedInputStream buf = new BufferedInputStream(null, 5)) {
+            assertEquals(0, buf.skip(0));
+        }
+    }
+
+    /**
+     * Tests java.io.BufferedInputStream#skip(long)
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_skipJ() throws IOException {
+        final byte[] buf1 = new byte[10];
+        is.mark(2000);
+        is.skip(1000);
+        is.read(buf1, 0, buf1.length);
+        is.reset();
+        assertTrue(new String(buf1, 0, buf1.length).equals(DATA.substring(1000, 1010)), "Failed to skip to correct position");
+
+        // regression for HARMONY-667
+        try (BufferedInputStream buf = new BufferedInputStream(null, 5)) {
+            assertThrows(IOException.class, () -> buf.skip(10));
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/UnsynchronizedByteArrayInputStreamTest.java b/src/test/java/org/apache/commons/io/input/UnsynchronizedByteArrayInputStreamTest.java
new file mode 100644
index 0000000..6f13b4e
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/UnsynchronizedByteArrayInputStreamTest.java
@@ -0,0 +1,360 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.apache.commons.io.input.UnsynchronizedByteArrayInputStream.END_OF_STREAM;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Basic unit tests for the alternative ByteArrayInputStream implementation.
+ */
+public class UnsynchronizedByteArrayInputStreamTest {
+
+    @Test
+    public void testConstructor1() throws IOException {
+        final byte[] empty = IOUtils.EMPTY_BYTE_ARRAY;
+        final byte[] one = new byte[1];
+        final byte[] some = new byte[25];
+
+        try (UnsynchronizedByteArrayInputStream is = new UnsynchronizedByteArrayInputStream(empty)) {
+            assertEquals(empty.length, is.available());
+        }
+        try (UnsynchronizedByteArrayInputStream is = new UnsynchronizedByteArrayInputStream(one)) {
+            assertEquals(one.length, is.available());
+        }
+        try (UnsynchronizedByteArrayInputStream is = new UnsynchronizedByteArrayInputStream(some)) {
+            assertEquals(some.length, is.available());
+        }
+    }
+
+    @Test
+    @SuppressWarnings("resource") // not necessary to close these resources
+    public void testConstructor2() {
+        final byte[] empty = IOUtils.EMPTY_BYTE_ARRAY;
+        final byte[] one = new byte[1];
+        final byte[] some = new byte[25];
+
+        UnsynchronizedByteArrayInputStream is = new UnsynchronizedByteArrayInputStream(empty, 0);
+        assertEquals(empty.length, is.available());
+        is = new UnsynchronizedByteArrayInputStream(empty, 1);
+        assertEquals(0, is.available());
+
+        is = new UnsynchronizedByteArrayInputStream(one, 0);
+        assertEquals(one.length, is.available());
+        is = new UnsynchronizedByteArrayInputStream(one, 1);
+        assertEquals(0, is.available());
+        is = new UnsynchronizedByteArrayInputStream(one, 2);
+        assertEquals(0, is.available());
+
+        is = new UnsynchronizedByteArrayInputStream(some, 0);
+        assertEquals(some.length, is.available());
+        is = new UnsynchronizedByteArrayInputStream(some, 1);
+        assertEquals(some.length - 1, is.available());
+        is = new UnsynchronizedByteArrayInputStream(some, 10);
+        assertEquals(some.length - 10, is.available());
+        is = new UnsynchronizedByteArrayInputStream(some, some.length);
+        assertEquals(0, is.available());
+    }
+
+    @Test
+    @SuppressWarnings("resource") // not necessary to close these resources
+    public void testConstructor3() {
+        final byte[] empty = IOUtils.EMPTY_BYTE_ARRAY;
+        final byte[] one = new byte[1];
+        final byte[] some = new byte[25];
+
+        UnsynchronizedByteArrayInputStream is = new UnsynchronizedByteArrayInputStream(empty, 0);
+        assertEquals(empty.length, is.available());
+        is = new UnsynchronizedByteArrayInputStream(empty, 1);
+        assertEquals(0, is.available());
+        is = new UnsynchronizedByteArrayInputStream(empty, 0,1);
+        assertEquals(0, is.available());
+        is = new UnsynchronizedByteArrayInputStream(empty, 1,1);
+        assertEquals(0, is.available());
+
+        is = new UnsynchronizedByteArrayInputStream(one, 0);
+        assertEquals(one.length, is.available());
+        is = new UnsynchronizedByteArrayInputStream(one, 1);
+        assertEquals(one.length - 1, is.available());
+        is = new UnsynchronizedByteArrayInputStream(one, 2);
+        assertEquals(0, is.available());
+        is = new UnsynchronizedByteArrayInputStream(one, 0, 1);
+        assertEquals(1, is.available());
+        is = new UnsynchronizedByteArrayInputStream(one, 1, 1);
+        assertEquals(0, is.available());
+        is = new UnsynchronizedByteArrayInputStream(one, 0, 2);
+        assertEquals(1, is.available());
+        is = new UnsynchronizedByteArrayInputStream(one, 2, 1);
+        assertEquals(0, is.available());
+        is = new UnsynchronizedByteArrayInputStream(one, 2, 2);
+        assertEquals(0, is.available());
+
+        is = new UnsynchronizedByteArrayInputStream(some, 0);
+        assertEquals(some.length, is.available());
+        is = new UnsynchronizedByteArrayInputStream(some, 1);
+        assertEquals(some.length - 1, is.available());
+        is = new UnsynchronizedByteArrayInputStream(some, 10);
+        assertEquals(some.length - 10, is.available());
+        is = new UnsynchronizedByteArrayInputStream(some, some.length);
+        assertEquals(0, is.available());
+        is = new UnsynchronizedByteArrayInputStream(some, some.length, some.length);
+        assertEquals(0, is.available());
+        is = new UnsynchronizedByteArrayInputStream(some, some.length - 1, some.length);
+        assertEquals(1, is.available());
+        is = new UnsynchronizedByteArrayInputStream(some, 0, 7);
+        assertEquals(7, is.available());
+        is = new UnsynchronizedByteArrayInputStream(some, 7, 7);
+        assertEquals(7, is.available());
+        is = new UnsynchronizedByteArrayInputStream(some, 0, some.length * 2);
+        assertEquals(some.length, is.available());
+        is = new UnsynchronizedByteArrayInputStream(some, some.length - 1, 7);
+        assertEquals(1, is.available());
+    }
+
+    @Test
+    public void testInvalidConstructor2OffsetUnder() {
+        assertThrows(IllegalArgumentException.class, () -> {
+            new UnsynchronizedByteArrayInputStream(IOUtils.EMPTY_BYTE_ARRAY, -1);
+        });
+    }
+
+    @Test
+    public void testInvalidConstructor3LengthUnder() {
+        assertThrows(IllegalArgumentException.class, () -> {
+            new UnsynchronizedByteArrayInputStream(IOUtils.EMPTY_BYTE_ARRAY, 0, -1);
+        });
+    }
+
+    @Test
+    public void testInvalidConstructor3OffsetUnder() {
+        assertThrows(IllegalArgumentException.class, () -> {
+            new UnsynchronizedByteArrayInputStream(IOUtils.EMPTY_BYTE_ARRAY, -1, 1);
+        });
+    }
+
+    @Test
+    @SuppressWarnings("resource") // not necessary to close these resources
+    public void testInvalidReadArrayExplicitLenUnder() {
+        final byte[] buf = IOUtils.EMPTY_BYTE_ARRAY;
+        final UnsynchronizedByteArrayInputStream is = new UnsynchronizedByteArrayInputStream(new byte[] {(byte)0xa, (byte)0xb, (byte)0xc});
+        assertThrows(IndexOutOfBoundsException.class, () -> {
+            is.read(buf, 0, -1);
+        });
+    }
+
+    @Test
+    public void testInvalidReadArrayExplicitOffsetUnder() {
+        final byte[] buf = IOUtils.EMPTY_BYTE_ARRAY;
+        @SuppressWarnings("resource") // not necessary to close these resources
+        final UnsynchronizedByteArrayInputStream is = new UnsynchronizedByteArrayInputStream(new byte[] {(byte)0xa, (byte)0xb, (byte)0xc});
+        assertThrows(IndexOutOfBoundsException.class, () -> {
+            is.read(buf, -1, 1);
+        });
+    }
+
+    @Test
+    public void testInvalidReadArrayExplicitRangeOver() {
+        final byte[] buf = IOUtils.EMPTY_BYTE_ARRAY;
+        @SuppressWarnings("resource") // not necessary to close these resources
+        final UnsynchronizedByteArrayInputStream is = new UnsynchronizedByteArrayInputStream(new byte[] {(byte)0xa, (byte)0xb, (byte)0xc});
+        assertThrows(IndexOutOfBoundsException.class, () -> {
+            is.read(buf, 0, 1);
+        });
+    }
+
+    @Test
+    public void testInvalidReadArrayNull() {
+        final byte[] buf = null;
+        @SuppressWarnings("resource") // not necessary to close these resources
+        final UnsynchronizedByteArrayInputStream is = new UnsynchronizedByteArrayInputStream(new byte[] {(byte)0xa, (byte)0xb, (byte)0xc});
+        assertThrows(NullPointerException.class, () -> {
+            is.read(buf);
+        });
+    }
+
+    @Test
+    public void testInvalidSkipNUnder() {
+        @SuppressWarnings("resource") // not necessary to close these resources
+        final UnsynchronizedByteArrayInputStream is = new UnsynchronizedByteArrayInputStream(new byte[] {(byte)0xa, (byte)0xb, (byte)0xc});
+        assertThrows(IllegalArgumentException.class, () -> {
+            is.skip(-1);
+        });
+    }
+
+    @Test
+    public void testMarkReset() {
+        @SuppressWarnings("resource") // not necessary to close these resources
+        final UnsynchronizedByteArrayInputStream is = new UnsynchronizedByteArrayInputStream(new byte[] {(byte)0xa, (byte)0xb, (byte)0xc});
+        assertTrue(is.markSupported());
+        assertEquals(0xa, is.read());
+        assertTrue(is.markSupported());
+
+        is.mark(10);
+
+        assertEquals(0xb, is.read());
+        assertEquals(0xc, is.read());
+
+        is.reset();
+
+        assertEquals(0xb, is.read());
+        assertEquals(0xc, is.read());
+        assertEquals(END_OF_STREAM, is.read());
+
+        is.reset();
+
+        assertEquals(0xb, is.read());
+        assertEquals(0xc, is.read());
+        assertEquals(END_OF_STREAM, is.read());
+    }
+
+    @Test
+    public void testReadArray() {
+        byte[] buf = new byte[10];
+        @SuppressWarnings("resource") // not necessary to close these resources
+        UnsynchronizedByteArrayInputStream is = new UnsynchronizedByteArrayInputStream(IOUtils.EMPTY_BYTE_ARRAY);
+        int read = is.read(buf);
+        assertEquals(END_OF_STREAM, read);
+        assertArrayEquals(new byte[10], buf);
+
+        buf = IOUtils.EMPTY_BYTE_ARRAY;
+        is = new UnsynchronizedByteArrayInputStream(new byte[]{(byte) 0xa, (byte) 0xb, (byte) 0xc});
+        read = is.read(buf);
+        assertEquals(0, read);
+
+        buf = new byte[10];
+        is = new UnsynchronizedByteArrayInputStream(new byte[]{(byte) 0xa, (byte) 0xb, (byte) 0xc});
+        read = is.read(buf);
+        assertEquals(3, read);
+        assertEquals(0xa, buf[0]);
+        assertEquals(0xb, buf[1]);
+        assertEquals(0xc, buf[2]);
+        assertEquals(0, buf[3]);
+
+        buf = new byte[2];
+        is = new UnsynchronizedByteArrayInputStream(new byte[]{(byte) 0xa, (byte) 0xb, (byte) 0xc});
+        read = is.read(buf);
+        assertEquals(2, read);
+        assertEquals(0xa, buf[0]);
+        assertEquals(0xb, buf[1]);
+        read = is.read(buf);
+        assertEquals(1, read);
+        assertEquals(0xc, buf[0]);
+    }
+
+    @Test
+    public void testReadArrayExplicit() {
+        byte[] buf = new byte[10];
+        @SuppressWarnings("resource") // not necessary to close these resources
+        UnsynchronizedByteArrayInputStream is = new UnsynchronizedByteArrayInputStream(IOUtils.EMPTY_BYTE_ARRAY);
+        int read = is.read(buf, 0, 10);
+        assertEquals(END_OF_STREAM, read);
+        assertArrayEquals(new byte[10], buf);
+
+        buf = new byte[10];
+        is = new UnsynchronizedByteArrayInputStream(IOUtils.EMPTY_BYTE_ARRAY);
+        read = is.read(buf, 4, 2);
+        assertEquals(END_OF_STREAM, read);
+        assertArrayEquals(new byte[10], buf);
+
+        buf = new byte[10];
+        is = new UnsynchronizedByteArrayInputStream(IOUtils.EMPTY_BYTE_ARRAY);
+        read = is.read(buf, 4, 6);
+        assertEquals(END_OF_STREAM, read);
+        assertArrayEquals(new byte[10], buf);
+
+        buf = IOUtils.EMPTY_BYTE_ARRAY;
+        is = new UnsynchronizedByteArrayInputStream(new byte[]{(byte) 0xa, (byte) 0xb, (byte) 0xc});
+        read = is.read(buf, 0,0);
+        assertEquals(0, read);
+
+        buf = new byte[10];
+        is = new UnsynchronizedByteArrayInputStream(new byte[]{(byte) 0xa, (byte) 0xb, (byte) 0xc});
+        read = is.read(buf, 0, 2);
+        assertEquals(2, read);
+        assertEquals(0xa, buf[0]);
+        assertEquals(0xb, buf[1]);
+        assertEquals(0, buf[2]);
+        read = is.read(buf, 0, 10);
+        assertEquals(1, read);
+        assertEquals(0xc, buf[0]);
+    }
+
+    @Test
+    public void testReadSingle() {
+        @SuppressWarnings("resource") // not necessary to close these resources
+        UnsynchronizedByteArrayInputStream is = new UnsynchronizedByteArrayInputStream(IOUtils.EMPTY_BYTE_ARRAY);
+        assertEquals(END_OF_STREAM, is.read());
+
+        is = new UnsynchronizedByteArrayInputStream(new byte[] {(byte)0xa, (byte)0xb, (byte)0xc});
+        assertEquals(0xa, is.read());
+        assertEquals(0xb, is.read());
+        assertEquals(0xc, is.read());
+        assertEquals(END_OF_STREAM, is.read());
+    }
+
+    @Test
+    public void testSkip() {
+        @SuppressWarnings("resource") // not necessary to close these resources
+        UnsynchronizedByteArrayInputStream is = new UnsynchronizedByteArrayInputStream(new byte[] {(byte)0xa, (byte)0xb, (byte)0xc});
+        assertEquals(3, is.available());
+
+        is.skip(1);
+        assertEquals(2, is.available());
+        assertEquals(0xb, is.read());
+
+        is.skip(1);
+        assertEquals(0, is.available());
+        assertEquals(END_OF_STREAM, is.read());
+
+
+        is = new UnsynchronizedByteArrayInputStream(new byte[] {(byte)0xa, (byte)0xb, (byte)0xc});
+        assertEquals(3, is.available());
+        is.skip(0);
+        assertEquals(3, is.available());
+        assertEquals(0xa, is.read());
+
+
+        is = new UnsynchronizedByteArrayInputStream(new byte[] {(byte)0xa, (byte)0xb, (byte)0xc});
+        assertEquals(3, is.available());
+        is.skip(2);
+        assertEquals(1, is.available());
+        assertEquals(0xc, is.read());
+        assertEquals(END_OF_STREAM, is.read());
+
+
+        is = new UnsynchronizedByteArrayInputStream(new byte[] {(byte)0xa, (byte)0xb, (byte)0xc});
+        assertEquals(3, is.available());
+        is.skip(3);
+        assertEquals(0, is.available());
+        assertEquals(END_OF_STREAM, is.read());
+
+
+        is = new UnsynchronizedByteArrayInputStream(new byte[] {(byte)0xa, (byte)0xb, (byte)0xc});
+        assertEquals(3, is.available());
+        is.skip(999);
+        assertEquals(0, is.available());
+        assertEquals(END_OF_STREAM, is.read());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/UnsynchronizedFilterInputStreamTest.java b/src/test/java/org/apache/commons/io/input/UnsynchronizedFilterInputStreamTest.java
new file mode 100644
index 0000000..005507d
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/UnsynchronizedFilterInputStreamTest.java
@@ -0,0 +1,175 @@
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link UnsynchronizedFilterInputStream}.
+ * <p>
+ * Provenance: Apache Harmony and modified.
+ * </p>
+ */
+public class UnsynchronizedFilterInputStreamTest {
+
+    static class MyUnsynchronizedFilterInputStream extends UnsynchronizedFilterInputStream {
+        public MyUnsynchronizedFilterInputStream(final InputStream is) {
+            super(is);
+        }
+    }
+
+    private Path fileName;
+
+    private InputStream is;
+
+    byte[] ibuf = new byte[4096];
+
+    public static final String DATA = StringUtils.repeat("This is a test.", 500);
+
+    /**
+     * Sets up the fixture, for example, open a network connection. This method is called before a test is executed.
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @SuppressWarnings("resource") // See @AfterEach tearDown() method
+    @BeforeEach
+    protected void setUp() throws IOException {
+        fileName = Files.createTempFile(getClass().getSimpleName(), ".tst");
+        Files.write(fileName, DATA.getBytes("UTF-8"));
+        is = new MyUnsynchronizedFilterInputStream(Files.newInputStream(fileName));
+    }
+
+    /**
+     * Tears down the fixture, for example, close a network connection. This method is called after a test is executed.
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @AfterEach
+    protected void tearDown() throws IOException {
+        IOUtils.closeQuietly(is);
+        Files.deleteIfExists(fileName);
+    }
+
+    /**
+     * Tests java.io.FilterInputStream#available()
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_available() throws IOException {
+        assertTrue(is.available() == DATA.length(), "Returned incorrect number of available bytes");
+    }
+
+    /**
+     * Tests java.io.FilterInputStream#close()
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_close() throws IOException {
+        is.close();
+        assertThrows(IOException.class, () -> is.read(), "Able to read from closed stream");
+    }
+
+    /**
+     * Tests java.io.FilterInputStream#mark(int)
+     */
+    @Test
+    public void test_markI() {
+        assertTrue(true, "Mark not supported by parent InputStream");
+    }
+
+    /**
+     * Tests java.io.FilterInputStream#markSupported()
+     */
+    @Test
+    public void test_markSupported() {
+        assertTrue(!is.markSupported(), "markSupported returned true");
+    }
+
+    /**
+     * Tests java.io.FilterInputStream#read()
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_read() throws IOException {
+        final int c = is.read();
+        assertTrue(c == DATA.charAt(0), "read returned incorrect char");
+    }
+
+    /**
+     * Tests java.io.FilterInputStream#read(byte[])
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_read$B() throws IOException {
+        final byte[] buf1 = new byte[100];
+        is.read(buf1);
+        assertTrue(new String(buf1, 0, buf1.length, "UTF-8").equals(DATA.substring(0, 100)), "Failed to read correct data");
+    }
+
+    /**
+     * Tests java.io.FilterInputStream#read(byte[], int, int)
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_read$BII() throws IOException {
+        final byte[] buf1 = new byte[100];
+        is.skip(3000);
+        is.mark(1000);
+        is.read(buf1, 0, buf1.length);
+        assertTrue(new String(buf1, 0, buf1.length, "UTF-8").equals(DATA.substring(3000, 3100)), "Failed to read correct data");
+    }
+
+    /**
+     * Tests java.io.FilterInputStream#reset()
+     */
+    @Test
+    public void test_reset() {
+        assertThrows(IOException.class, () -> is.reset(), "should throw IOException");
+
+    }
+
+    /**
+     * Tests java.io.FilterInputStream#skip(long)
+     *
+     * @throws IOException Thrown on test failure.
+     */
+    @Test
+    public void test_skipJ() throws IOException {
+        final byte[] buf1 = new byte[10];
+        is.skip(1000);
+        is.read(buf1, 0, buf1.length);
+        assertTrue(new String(buf1, 0, buf1.length, "UTF-8").equals(DATA.substring(1000, 1010)), "Failed to skip to correct position");
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/WindowsLineEndingInputStreamTest.java b/src/test/java/org/apache/commons/io/input/WindowsLineEndingInputStreamTest.java
new file mode 100644
index 0000000..f643e6e
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/WindowsLineEndingInputStreamTest.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+public class WindowsLineEndingInputStreamTest {
+    @Test
+    public void inTheMiddleOfTheLine() throws Exception {
+        assertEquals("a\r\nbc\r\n", roundtrip("a\r\nbc"));
+    }
+
+    @Test
+    public void linuxLineFeeds() throws Exception {
+        final String roundtrip = roundtrip("ab\nc", false);
+        assertEquals("ab\r\nc", roundtrip);
+    }
+
+    @Test
+    public void malformed() throws Exception {
+        assertEquals("a\rbc", roundtrip("a\rbc", false));
+    }
+
+    @Test
+    public void multipleBlankLines() throws Exception {
+        assertEquals("a\r\n\r\nbc\r\n", roundtrip("a\r\n\r\nbc"));
+    }
+
+    @Test
+    public void retainLineFeed() throws Exception {
+        assertEquals("a\r\n\r\n", roundtrip("a\r\n\r\n", false));
+        assertEquals("a", roundtrip("a", false));
+    }
+
+    private String roundtrip(final String msg) throws IOException {
+        return roundtrip(msg, true);
+    }
+
+    private String roundtrip(final String msg, final boolean ensure) throws IOException {
+        try (WindowsLineEndingInputStream lf = new WindowsLineEndingInputStream(new StringInputStream(msg, StandardCharsets.UTF_8), ensure)) {
+            final byte[] buf = new byte[100];
+            final int read = lf.read(buf);
+            return new String(buf, 0, read, StandardCharsets.UTF_8);
+        }
+    }
+
+    @Test
+    public void simpleString() throws Exception {
+        assertEquals("abc\r\n", roundtrip("abc"));
+    }
+
+    @Test
+    public void twoLinesAtEnd() throws Exception {
+        assertEquals("a\r\n\r\n", roundtrip("a\r\n\r\n"));
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/XmlStreamReaderTest.java b/src/test/java/org/apache/commons/io/input/XmlStreamReaderTest.java
new file mode 100644
index 0000000..312577c
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/XmlStreamReaderTest.java
@@ -0,0 +1,553 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.net.URL;
+import java.net.URLConnection;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.text.MessageFormat;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Test;
+import org.junitpioneer.jupiter.DefaultLocale;
+
+public class XmlStreamReaderTest {
+
+    private static final String ISO_8859_1 = StandardCharsets.ISO_8859_1.name();
+    private static final String US_ASCII = StandardCharsets.US_ASCII.name();
+    private static final String UTF_16 = StandardCharsets.UTF_16.name();
+    private static final String UTF_16LE = StandardCharsets.UTF_16LE.name();
+    private static final String UTF_16BE = StandardCharsets.UTF_16BE.name();
+    private static final String UTF_32 = "UTF-32";
+    private static final String UTF_32LE = "UTF-32LE";
+    private static final String UTF_32BE = "UTF-32BE";
+    private static final String UTF_8 = StandardCharsets.UTF_8.name();
+    private static final String XML5 = "xml-prolog-encoding-spaced-single-quotes";
+    private static final String XML4 = "xml-prolog-encoding-single-quotes";
+    private static final String XML3 = "xml-prolog-encoding-double-quotes";
+    private static final String XML2 = "xml-prolog";
+    private static final String XML1 = "xml";
+
+    private static final String ENCODING_ATTRIBUTE_XML = "<?xml version=\"1.0\" ?> \n"
+            + "<atom:feed xmlns:atom=\"http://www.w3.org/2005/Atom\">\n"
+            + "\n"
+            + "  <atom:entry>\n"
+            + "    <atom:title encoding='base64'><![CDATA\n"
+            + "aW5nTGluZSIgLz4";
+
+    private static final int[] NO_BOM_BYTES = {};
+
+    private static final int[] UTF_16BE_BOM_BYTES = {0xFE, 0xFF};
+
+    private static final int[] UTF_16LE_BOM_BYTES = {0xFF, 0XFE};
+
+    private static final int[] UTF_32BE_BOM_BYTES = {0x00, 0x00, 0xFE, 0xFF};
+
+    private static final int[] UTF_32LE_BOM_BYTES = {0xFF, 0XFE, 0x00, 0x00};
+
+    private static final int[] UTF_8_BOM_BYTES = {0xEF, 0xBB, 0xBF};
+
+    private static final Map<String, int[]> BOMs = new HashMap<>();
+
+    static {
+        BOMs.put("no-bom", NO_BOM_BYTES);
+        BOMs.put("UTF-16BE-bom", UTF_16BE_BOM_BYTES);
+        BOMs.put("UTF-16LE-bom", UTF_16LE_BOM_BYTES);
+        BOMs.put("UTF-32BE-bom", UTF_32BE_BOM_BYTES);
+        BOMs.put("UTF-32LE-bom", UTF_32LE_BOM_BYTES);
+        BOMs.put("UTF-16-bom", NO_BOM_BYTES); // it's added by the writer
+        BOMs.put("UTF-8-bom", UTF_8_BOM_BYTES);
+    }
+
+    private static final MessageFormat XML = new MessageFormat(
+            "<root>{2}</root>");
+
+    private static final MessageFormat XML_WITH_PROLOG = new MessageFormat(
+            "<?xml version=\"1.0\"?>\n<root>{2}</root>");
+
+    private static final MessageFormat XML_WITH_PROLOG_AND_ENCODING_DOUBLE_QUOTES = new MessageFormat(
+            "<?xml version=\"1.0\" encoding=\"{1}\"?>\n<root>{2}</root>");
+
+    private static final MessageFormat XML_WITH_PROLOG_AND_ENCODING_SINGLE_QUOTES = new MessageFormat(
+            "<?xml version=\"1.0\" encoding=''{1}''?>\n<root>{2}</root>");
+
+    private static final MessageFormat XML_WITH_PROLOG_AND_ENCODING_SPACED_SINGLE_QUOTES = new MessageFormat(
+            "<?xml version=\"1.0\" encoding =  \t \n \r''{1}''?>\n<root>{2}</root>");
+
+    private static final MessageFormat INFO = new MessageFormat(
+            "\nBOM : {0}\nDoc : {1}\nStream Enc : {2}\nProlog Enc : {3}\n");
+
+    private static final Map<String, MessageFormat> XMLs = new HashMap<>();
+
+    static {
+        XMLs.put(XML1, XML);
+        XMLs.put(XML2, XML_WITH_PROLOG);
+        XMLs.put(XML3, XML_WITH_PROLOG_AND_ENCODING_DOUBLE_QUOTES);
+        XMLs.put(XML4, XML_WITH_PROLOG_AND_ENCODING_SINGLE_QUOTES);
+        XMLs.put(XML5, XML_WITH_PROLOG_AND_ENCODING_SPACED_SINGLE_QUOTES);
+    }
+
+    /**
+     * Create the XML.
+     */
+    private String getXML(final String bomType, final String xmlType,
+                          final String streamEnc, final String prologEnc) {
+        final MessageFormat xml = XMLs.get(xmlType);
+        final String info = INFO.format(new Object[]{bomType, xmlType, prologEnc});
+        return xml.format(new Object[]{streamEnc, prologEnc, info});
+    }
+
+    /**
+     * @param bomType   no-bom, UTF-16BE-bom, UTF-16LE-bom, UTF-8-bom
+     * @param xmlType   xml, xml-prolog, xml-prolog-charset
+     * @param streamEnc encoding of the stream
+     * @param prologEnc encoding of the prolog
+     * @return XML stream
+     * @throws IOException If an I/O error occurs
+     */
+    protected InputStream getXmlInputStream(final String bomType, final String xmlType,
+        final String streamEnc, final String prologEnc) throws IOException {
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream(1024);
+        int[] bom = BOMs.get(bomType);
+        if (bom == null) {
+            bom = new int[0];
+        }
+        for (final int element : bom) {
+            baos.write(element);
+        }
+        try (Writer writer = new OutputStreamWriter(baos, streamEnc)) {
+            final String xmlDoc = getXML(bomType, xmlType, streamEnc, prologEnc);
+            writer.write(xmlDoc);
+
+            // PADDING TO TEST THINGS WORK BEYOND PUSHBACK_SIZE
+            writer.write("<da>\n");
+            for (int i = 0; i < 10000; i++) {
+                writer.write("<do/>\n");
+            }
+            writer.write("</da>\n");
+
+        }
+        return new ByteArrayInputStream(baos.toByteArray());
+    }
+
+    public void testAlternateDefaultEncoding(final String cT, final String bomEnc, final String streamEnc, final String prologEnc, final String alternateEnc)
+        throws Exception {
+        try (InputStream is = getXmlInputStream(bomEnc, prologEnc == null ? XML1 : XML3, streamEnc, prologEnc);
+            XmlStreamReader xmlReader = new XmlStreamReader(is, cT, false, alternateEnc)) {
+            assertEquals(xmlReader.getDefaultEncoding(), alternateEnc);
+            if (!streamEnc.equals(UTF_16)) {
+                // we can not assert things here because UTF-8, US-ASCII and
+                // ISO-8859-1 look alike for the chars used for detection
+                // (niallp 2010-10-06 - I re-instated the check below - the tests(6) passed)
+                final String enc = alternateEnc != null ? alternateEnc : streamEnc;
+                assertEquals(xmlReader.getEncoding(), enc);
+            } else {
+                // String enc = (alternateEnc != null) ? alternateEnc : streamEnc;
+                assertEquals(xmlReader.getEncoding().substring(0, streamEnc.length()), streamEnc);
+            }
+        }
+    }
+
+    @Test
+    protected void testConstructorFileInput() throws IOException {
+        try (XmlStreamReader reader = new XmlStreamReader(new File("pom.xml"))) {
+            // do nothing
+        }
+    }
+
+    @Test
+    protected void testConstructorFileInputNull() {
+        assertThrows(NullPointerException.class, () -> new XmlStreamReader((File) null));
+    }
+
+    @Test
+    protected void testConstructorInputStreamInput() throws IOException {
+        try (XmlStreamReader reader = new XmlStreamReader(Files.newInputStream(Paths.get("pom.xml")))) {
+            // do nothing
+        }
+    }
+
+    @Test
+    protected void testConstructorInputStreamInputNull() {
+        assertThrows(NullPointerException.class, () -> new XmlStreamReader((InputStream) null));
+    }
+
+    protected void testConstructorPathInput() throws IOException {
+        try (XmlStreamReader reader = new XmlStreamReader(Paths.get("pom.xml"))) {
+            // do nothing
+        }
+    }
+
+    @Test
+    protected void testConstructorPathInputNull() {
+        assertThrows(NullPointerException.class, () -> new XmlStreamReader((Path) null));
+    }
+
+    @Test
+    protected void testConstructorURLConnectionInput() throws IOException {
+        try (XmlStreamReader reader = new XmlStreamReader(new URL("https://www.apache.org/").openConnection(), UTF_8)) {
+            // do nothing
+        }
+    }
+
+    @Test
+    protected void testConstructorURLConnectionInputNull() {
+        assertThrows(NullPointerException.class, () -> new XmlStreamReader((URLConnection) null, US_ASCII));
+    }
+
+    @Test
+    protected void testConstructorURLInput() throws IOException {
+        try (XmlStreamReader reader = new XmlStreamReader(new URL("https://www.apache.org/"))) {
+            // do nothing
+        }
+    }
+
+    @Test
+    protected void testConstructorURLInputNull() throws IOException {
+        assertThrows(NullPointerException.class, () -> new XmlStreamReader((URL) null));
+    }
+
+    @Test
+    public void testEncodingAttributeXML() throws Exception {
+        try (InputStream is = new ByteArrayInputStream(ENCODING_ATTRIBUTE_XML.getBytes(StandardCharsets.UTF_8));
+            XmlStreamReader xmlReader = new XmlStreamReader(is, "", true)) {
+            assertEquals(xmlReader.getEncoding(), UTF_8);
+        }
+    }
+
+    // XML Stream generator
+
+    @Test
+    public void testHttp() throws Exception {
+        // niallp 2010-10-06 - remove following 2 tests - I reinstated
+        // checks for non-UTF-16 encodings (18 tests) and these failed
+        // _testHttpValid("application/xml", "no-bom", "US-ASCII", null);
+        // _testHttpValid("application/xml", "UTF-8-bom", "US-ASCII", null);
+        testHttpValid("application/xml", "UTF-8-bom", UTF_8, null);
+        testHttpValid("application/xml", "UTF-8-bom", UTF_8, UTF_8);
+        testHttpValid("application/xml;charset=UTF-8", "UTF-8-bom", UTF_8, null);
+        testHttpValid("application/xml;charset=\"UTF-8\"", "UTF-8-bom", UTF_8, null);
+        testHttpValid("application/xml;charset='UTF-8'", "UTF-8-bom", UTF_8, null);
+        testHttpValid("application/xml;charset=UTF-8", "UTF-8-bom", UTF_8, UTF_8);
+        testHttpValid("application/xml;charset=UTF-16", "UTF-16BE-bom", UTF_16BE, null);
+        testHttpValid("application/xml;charset=UTF-16", "UTF-16BE-bom", UTF_16BE, UTF_16);
+        testHttpValid("application/xml;charset=UTF-16", "UTF-16BE-bom", UTF_16BE, UTF_16BE);
+
+        testHttpInvalid("application/xml;charset=UTF-16BE", "UTF-16BE-bom", UTF_16BE, null);
+        testHttpInvalid("application/xml;charset=UTF-16BE", "UTF-16BE-bom", UTF_16BE, UTF_16);
+        testHttpInvalid("application/xml;charset=UTF-16BE", "UTF-16BE-bom", UTF_16BE, UTF_16BE);
+
+        testHttpInvalid("application/xml;charset=UTF-32BE", "UTF-32BE-bom", UTF_32BE, null);
+        testHttpInvalid("application/xml;charset=UTF-32BE", "UTF-32BE-bom", UTF_32BE, UTF_32);
+        testHttpInvalid("application/xml;charset=UTF-32BE", "UTF-32BE-bom", UTF_32BE, UTF_32BE);
+
+        testHttpInvalid("application/xml", "UTF-8-bom", US_ASCII, US_ASCII);
+        testHttpInvalid("application/xml;charset=UTF-16", UTF_16LE, UTF_8, UTF_8);
+        testHttpInvalid("application/xml;charset=UTF-16", "no-bom", UTF_16BE, UTF_16BE);
+        testHttpInvalid("application/xml;charset=UTF-32", UTF_32LE, UTF_8, UTF_8);
+        testHttpInvalid("application/xml;charset=UTF-32", "no-bom", UTF_32BE, UTF_32BE);
+
+        testHttpValid("text/xml", "no-bom", US_ASCII, null);
+        testHttpValid("text/xml;charset=UTF-8", "UTF-8-bom", UTF_8, UTF_8);
+        testHttpValid("text/xml;charset=UTF-8", "UTF-8-bom", UTF_8, null);
+        testHttpValid("text/xml;charset=UTF-16", "UTF-16BE-bom", UTF_16BE, null);
+        testHttpValid("text/xml;charset=UTF-16", "UTF-16BE-bom", UTF_16BE, UTF_16);
+        testHttpValid("text/xml;charset=UTF-16", "UTF-16BE-bom", UTF_16BE, UTF_16BE);
+        testHttpValid("text/xml;charset=UTF-32", "UTF-32BE-bom", UTF_32BE, null);
+        testHttpValid("text/xml;charset=UTF-32", "UTF-32BE-bom", UTF_32BE, UTF_32);
+        testHttpValid("text/xml;charset=UTF-32", "UTF-32BE-bom", UTF_32BE, UTF_32BE);
+        testHttpValid("text/xml", "UTF-8-bom", US_ASCII, null);
+
+        testAlternateDefaultEncoding("application/xml", "UTF-8-bom", UTF_8, null, null);
+        testAlternateDefaultEncoding("application/xml", "no-bom", US_ASCII, null, US_ASCII);
+        testAlternateDefaultEncoding("application/xml", "UTF-8-bom", UTF_8, null, UTF_8);
+        testAlternateDefaultEncoding("text/xml", "no-bom", US_ASCII, null, null);
+        testAlternateDefaultEncoding("text/xml", "no-bom", US_ASCII, null, US_ASCII);
+        testAlternateDefaultEncoding("text/xml", "no-bom", US_ASCII, null, UTF_8);
+
+        testHttpInvalid("text/xml;charset=UTF-16BE", "UTF-16BE-bom", UTF_16BE, null);
+        testHttpInvalid("text/xml;charset=UTF-16BE", "UTF-16BE-bom", UTF_16BE, UTF_16);
+        testHttpInvalid("text/xml;charset=UTF-16BE", "UTF-16BE-bom", UTF_16BE, UTF_16BE);
+        testHttpInvalid("text/xml;charset=UTF-16", "no-bom", UTF_16BE, UTF_16BE);
+        testHttpInvalid("text/xml;charset=UTF-16", "no-bom", UTF_16BE, null);
+
+        testHttpInvalid("text/xml;charset=UTF-32BE", "UTF-32BE-bom", UTF_32BE, null);
+        testHttpInvalid("text/xml;charset=UTF-32BE", "UTF-32BE-bom", UTF_32BE, UTF_32);
+        testHttpInvalid("text/xml;charset=UTF-32BE", "UTF-32BE-bom", UTF_32BE, UTF_32BE);
+        testHttpInvalid("text/xml;charset=UTF-32", "no-bom", UTF_32BE, UTF_32BE);
+        testHttpInvalid("text/xml;charset=UTF-32", "no-bom", UTF_32BE, null);
+
+        testHttpLenient("text/xml", "no-bom", US_ASCII, null, US_ASCII);
+        testHttpLenient("text/xml;charset=UTF-8", "UTF-8-bom", UTF_8, UTF_8, UTF_8);
+        testHttpLenient("text/xml;charset=UTF-8", "UTF-8-bom", UTF_8, null, UTF_8);
+        testHttpLenient("text/xml;charset=UTF-16", "UTF-16BE-bom", UTF_16BE, null, UTF_16BE);
+        testHttpLenient("text/xml;charset=UTF-16", "UTF-16BE-bom", UTF_16BE, UTF_16, UTF_16);
+        testHttpLenient("text/xml;charset=UTF-16", "UTF-16BE-bom", UTF_16BE, UTF_16BE, UTF_16BE);
+        testHttpLenient("text/xml;charset=UTF-32", "UTF-32BE-bom", UTF_32BE, null, UTF_32BE);
+        testHttpLenient("text/xml;charset=UTF-32", "UTF-32BE-bom", UTF_32BE, UTF_32, UTF_32);
+        testHttpLenient("text/xml;charset=UTF-32", "UTF-32BE-bom", UTF_32BE, UTF_32BE, UTF_32BE);
+        testHttpLenient("text/xml", "UTF-8-bom", US_ASCII, null, US_ASCII);
+
+        testHttpLenient("text/xml;charset=UTF-16BE", "UTF-16BE-bom", UTF_16BE, null, UTF_16BE);
+        testHttpLenient("text/xml;charset=UTF-16BE", "UTF-16BE-bom", UTF_16BE, UTF_16, UTF_16);
+        testHttpLenient("text/xml;charset=UTF-16BE", "UTF-16BE-bom", UTF_16BE, UTF_16BE, UTF_16BE);
+        testHttpLenient("text/xml;charset=UTF-16", "no-bom", UTF_16BE, UTF_16BE, UTF_16BE);
+        testHttpLenient("text/xml;charset=UTF-16", "no-bom", UTF_16BE, null, UTF_16);
+
+        testHttpLenient("text/xml;charset=UTF-32BE", "UTF-32BE-bom", UTF_32BE, null, UTF_32BE);
+        testHttpLenient("text/xml;charset=UTF-32BE", "UTF-32BE-bom", UTF_32BE, UTF_32, UTF_32);
+        testHttpLenient("text/xml;charset=UTF-32BE", "UTF-32BE-bom", UTF_32BE, UTF_32BE, UTF_32BE);
+        testHttpLenient("text/xml;charset=UTF-32", "no-bom", UTF_32BE, UTF_32BE, UTF_32BE);
+        testHttpLenient("text/xml;charset=UTF-32", "no-bom", UTF_32BE, null, UTF_32);
+
+        testHttpLenient("text/html", "no-bom", US_ASCII, US_ASCII, US_ASCII);
+        testHttpLenient("text/html", "no-bom", US_ASCII, null, US_ASCII);
+        testHttpLenient("text/html;charset=UTF-8", "no-bom", US_ASCII, UTF_8, UTF_8);
+        testHttpLenient("text/html;charset=UTF-16BE", "no-bom", US_ASCII, UTF_8, UTF_8);
+        testHttpLenient("text/html;charset=UTF-32BE", "no-bom", US_ASCII, UTF_8, UTF_8);
+    }
+
+    @Test
+    public void testHttpContent() throws Exception {
+        final String encoding = UTF_8;
+        final String xml = getXML("no-bom", XML3, encoding, encoding);
+        try (XmlStreamReader xmlReader = new XmlStreamReader(new StringInputStream(xml, encoding))) {
+            assertEquals(xmlReader.getEncoding(), encoding, "Check encoding");
+            assertEquals(xml, IOUtils.toString(xmlReader), "Check content");
+        }
+    }
+
+    protected void testHttpInvalid(final String cT, final String bomEnc, final String streamEnc,
+        final String prologEnc) throws Exception {
+        try (InputStream is = getXmlInputStream(bomEnc, prologEnc == null ? XML2 : XML3, streamEnc, prologEnc)) {
+            try {
+                new XmlStreamReader(is, cT, false).close();
+                fail("It should have failed for HTTP Content-type " + cT + ", BOM " + bomEnc + ", streamEnc " + streamEnc + " and prologEnc " + prologEnc);
+            } catch (final IOException ex) {
+                assertTrue(ex.getMessage().contains("Invalid encoding,"));
+            }
+        }
+    }
+
+    protected void testHttpLenient(final String cT, final String bomEnc, final String streamEnc,
+        final String prologEnc, final String shouldBe) throws Exception {
+        try (InputStream is = getXmlInputStream(bomEnc, prologEnc == null ? XML2 : XML3, streamEnc, prologEnc);
+            XmlStreamReader xmlReader = new XmlStreamReader(is, cT, true)) {
+            assertEquals(xmlReader.getEncoding(), shouldBe);
+        }
+    }
+
+    public void testHttpValid(final String cT, final String bomEnc, final String streamEnc,
+        final String prologEnc) throws Exception {
+        try (InputStream is = getXmlInputStream(bomEnc, prologEnc == null ? XML1 : XML3, streamEnc, prologEnc);
+            XmlStreamReader xmlReader = new XmlStreamReader(is, cT, false)) {
+            if (!streamEnc.equals(UTF_16)) {
+                // we can not assert things here because UTF-8, US-ASCII and
+                // ISO-8859-1 look alike for the chars used for detection
+                // (niallp 2010-10-06 - I re-instated the check below and removed the 2 tests that failed)
+                assertEquals(xmlReader.getEncoding(), streamEnc);
+            } else {
+                assertEquals(xmlReader.getEncoding().substring(0, streamEnc.length()), streamEnc);
+            }
+        }
+    }
+
+    // Turkish language has specific rules to convert dotted and dotless i character.
+    @Test
+    @DefaultLocale(language = "tr")
+    public void testLowerCaseEncodingWithTurkishLocale_IO_557() throws Exception {
+        final String[] encodings = {"iso8859-1", "us-ascii", "utf-8"};
+        for (final String encoding : encodings) {
+            final String xml = getXML("no-bom", XML3, encoding, encoding);
+            try (ByteArrayInputStream is = new ByteArrayInputStream(xml.getBytes(encoding)); XmlStreamReader xmlReader = new XmlStreamReader(is)) {
+                assertTrue(encoding.equalsIgnoreCase(xmlReader.getEncoding()), "Check encoding : " + encoding);
+                assertEquals(xml, IOUtils.toString(xmlReader), "Check content");
+            }
+        }
+    }
+
+    protected void testRawBomInvalid(final String bomEnc, final String streamEnc,
+        final String prologEnc) throws Exception {
+        final InputStream is = getXmlInputStream(bomEnc, XML3, streamEnc, prologEnc);
+        XmlStreamReader xmlReader = null;
+        try {
+            xmlReader = new XmlStreamReader(is, false);
+            final String foundEnc = xmlReader.getEncoding();
+            fail("Expected IOException for BOM " + bomEnc + ", streamEnc " + streamEnc + " and prologEnc " + prologEnc
+                + ": found " + foundEnc);
+        } catch (final IOException ex) {
+            assertTrue(ex.getMessage().contains("Invalid encoding,"));
+        }
+        if (xmlReader != null) {
+            xmlReader.close();
+        }
+    }
+
+    @Test
+    public void testRawBomUtf16() throws Exception {
+        testRawBomValid(UTF_16BE);
+        testRawBomValid(UTF_16LE);
+        testRawBomValid(UTF_16);
+
+        testRawBomInvalid("UTF-16BE-bom", UTF_16BE, UTF_16LE);
+        testRawBomInvalid("UTF-16LE-bom", UTF_16LE, UTF_16BE);
+        testRawBomInvalid("UTF-16LE-bom", UTF_16LE, UTF_8);
+    }
+
+    @Test
+    public void testRawBomUtf32() throws Exception {
+        testRawBomValid(UTF_32BE);
+        testRawBomValid(UTF_32LE);
+        testRawBomValid(UTF_32);
+
+        testRawBomInvalid("UTF-32BE-bom", UTF_32BE, UTF_32LE);
+        testRawBomInvalid("UTF-32LE-bom", UTF_32LE, UTF_32BE);
+        testRawBomInvalid("UTF-32LE-bom", UTF_32LE, UTF_8);
+    }
+
+    @Test
+    public void testRawBomUtf8() throws Exception {
+        testRawBomValid(UTF_8);
+        testRawBomInvalid("UTF-8-bom", US_ASCII, US_ASCII);
+        testRawBomInvalid("UTF-8-bom", ISO_8859_1, ISO_8859_1);
+        testRawBomInvalid("UTF-8-bom", UTF_8, UTF_16);
+        testRawBomInvalid("UTF-8-bom", UTF_8, UTF_16BE);
+        testRawBomInvalid("UTF-8-bom", UTF_8, UTF_16LE);
+        testRawBomInvalid("UTF-16BE-bom", UTF_16BE, UTF_16LE);
+        testRawBomInvalid("UTF-16LE-bom", UTF_16LE, UTF_16BE);
+        testRawBomInvalid("UTF-16LE-bom", UTF_16LE, UTF_8);
+        testRawBomInvalid("UTF-32BE-bom", UTF_32BE, UTF_32LE);
+        testRawBomInvalid("UTF-32LE-bom", UTF_32LE, UTF_32BE);
+        testRawBomInvalid("UTF-32LE-bom", UTF_32LE, UTF_8);
+    }
+
+    protected void testRawBomValid(final String encoding) throws Exception {
+        try (InputStream is = getXmlInputStream(encoding + "-bom", XML3, encoding, encoding);
+            XmlStreamReader xmlReader = new XmlStreamReader(is, false)) {
+            if (!encoding.equals(UTF_16) && !encoding.equals(UTF_32)) {
+                assertEquals(xmlReader.getEncoding(), encoding);
+            } else {
+                assertEquals(xmlReader.getEncoding().substring(0, encoding.length()), encoding);
+            }
+        }
+    }
+
+    @Test
+    public void testRawContent() throws Exception {
+        final String encoding = UTF_8;
+        final String xml = getXML("no-bom", XML3, encoding, encoding);
+        try (XmlStreamReader xmlReader = new XmlStreamReader(new StringInputStream(xml, encoding))) {
+            assertEquals(xmlReader.getEncoding(), encoding, "Check encoding");
+            assertEquals(xml, IOUtils.toString(xmlReader), "Check content");
+        }
+    }
+
+    @Test
+    public void testRawNoBomCp1047() throws Exception {
+        testRawNoBomValid("CP1047");
+    }
+
+    protected void testRawNoBomInvalid(final String encoding) throws Exception {
+        try (final InputStream is = getXmlInputStream("no-bom", XML3, encoding, encoding)) {
+            try {
+                new XmlStreamReader(is, false).close();
+                fail("It should have failed");
+            } catch (final IOException ex) {
+                assertTrue(ex.getMessage().contains("Invalid encoding,"));
+            }
+        }
+    }
+
+    @Test
+    public void testRawNoBomIso8859_1() throws Exception {
+        testRawNoBomValid(ISO_8859_1);
+    }
+
+    @Test
+    public void testRawNoBomUsAscii() throws Exception {
+        testRawNoBomValid(US_ASCII);
+    }
+
+    @Test
+    public void testRawNoBomUtf16BE() throws Exception {
+        testRawNoBomValid(UTF_16BE);
+    }
+
+    @Test
+    public void testRawNoBomUtf16LE() throws Exception {
+        testRawNoBomValid(UTF_16LE);
+    }
+
+    @Test
+    public void testRawNoBomUtf32BE() throws Exception {
+        testRawNoBomValid(UTF_32BE);
+    }
+
+    @Test
+    public void testRawNoBomUtf32LE() throws Exception {
+        testRawNoBomValid(UTF_32LE);
+    }
+
+    @Test
+    public void testRawNoBomUtf8() throws Exception {
+        testRawNoBomValid(UTF_8);
+    }
+
+    protected void testRawNoBomValid(final String encoding) throws Exception {
+        InputStream is = getXmlInputStream("no-bom", XML1, encoding, encoding);
+        XmlStreamReader xmlReader = new XmlStreamReader(is, false);
+        assertEquals(xmlReader.getEncoding(), UTF_8);
+        xmlReader.close();
+
+        is = getXmlInputStream("no-bom", XML2, encoding, encoding);
+        xmlReader = new XmlStreamReader(is);
+        assertEquals(xmlReader.getEncoding(), UTF_8);
+        xmlReader.close();
+
+        is = getXmlInputStream("no-bom", XML3, encoding, encoding);
+        xmlReader = new XmlStreamReader(is);
+        assertEquals(xmlReader.getEncoding(), encoding);
+        xmlReader.close();
+
+        is = getXmlInputStream("no-bom", XML4, encoding, encoding);
+        xmlReader = new XmlStreamReader(is);
+        assertEquals(xmlReader.getEncoding(), encoding);
+        xmlReader.close();
+
+        is = getXmlInputStream("no-bom", XML5, encoding, encoding);
+        xmlReader = new XmlStreamReader(is);
+        assertEquals(xmlReader.getEncoding(), encoding);
+        xmlReader.close();
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/XmlStreamReaderUtilitiesTest.java b/src/test/java/org/apache/commons/io/input/XmlStreamReaderUtilitiesTest.java
new file mode 100644
index 0000000..2f90a26
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/XmlStreamReaderUtilitiesTest.java
@@ -0,0 +1,327 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test the Encoding Utilities part of {@link XmlStreamReader}.
+ */
+public class XmlStreamReaderUtilitiesTest {
+
+    /** Mock {@link XmlStreamReader} implementation */
+    private static class MockXmlStreamReader extends XmlStreamReader {
+        MockXmlStreamReader(final String defaultEncoding) throws IOException {
+            super(new StringInputStream(), null, true, defaultEncoding);
+        }
+    }
+    private static final String RAWMGS1 = "encoding mismatch";
+    private static final String RAWMGS2 = "unknown BOM";
+    private static final String HTTPMGS1 = "BOM must be NULL";
+    private static final String HTTPMGS2 = "encoding mismatch";
+
+    private static final String HTTPMGS3 = "Invalid MIME";
+    private static final String APPXML         = "application/xml";
+    private static final String APPXML_UTF8    = "application/xml;charset=UTF-8";
+    private static final String APPXML_UTF16   = "application/xml;charset=UTF-16";
+    private static final String APPXML_UTF32   = "application/xml;charset=UTF-32";
+    private static final String APPXML_UTF16BE = "application/xml;charset=UTF-16BE";
+    private static final String APPXML_UTF16LE = "application/xml;charset=UTF-16LE";
+    private static final String APPXML_UTF32BE = "application/xml;charset=UTF-32BE";
+    private static final String APPXML_UTF32LE = "application/xml;charset=UTF-32LE";
+
+    private static final String TXTXML = "text/xml";
+
+    protected String calculateHttpEncoding(final String httpContentType, final String bomEnc, final String xmlGuessEnc,
+        final String xmlEnc, final boolean lenient, final String defaultEncoding) throws IOException {
+        try (MockXmlStreamReader mock = new MockXmlStreamReader(defaultEncoding)) {
+            return mock.calculateHttpEncoding(httpContentType, bomEnc, xmlGuessEnc, xmlEnc, lenient);
+        }
+    }
+
+    protected String calculateRawEncoding(final String bomEnc, final String xmlGuessEnc, final String xmlEnc,
+        final String defaultEncoding) throws IOException {
+        try (MockXmlStreamReader mock = new MockXmlStreamReader(defaultEncoding)) {
+            return mock.calculateRawEncoding(bomEnc, xmlGuessEnc, xmlEnc);
+        }
+    }
+
+    @SuppressWarnings("boxing")
+    private void checkAppXml(final boolean expected, final String mime) {
+        assertEquals(expected, XmlStreamReader.isAppXml(mime), "Mime=[" + mime + "]");
+    }
+
+    private void checkContentTypeEncoding(final String expected, final String httpContentType) {
+        assertEquals(expected, XmlStreamReader.getContentTypeEncoding(httpContentType), "ContentTypeEncoding=[" + httpContentType + "]");
+    }
+
+    private void checkContentTypeMime(final String expected, final String httpContentType) {
+        assertEquals(expected, XmlStreamReader.getContentTypeMime(httpContentType), "ContentTypeMime=[" + httpContentType + "]");
+    }
+
+    private void checkHttpEncoding(final String expected, final boolean lenient, final String httpContentType,
+            final String bomEnc, final String xmlGuessEnc, final String xmlEnc, final String defaultEncoding) throws IOException {
+        final StringBuilder builder = new StringBuilder();
+        builder.append("HttpEncoding=[").append(bomEnc).append("], ");
+        builder.append("lenient=[").append(lenient).append("], ");
+        builder.append("httpContentType=[").append(httpContentType).append("], ");
+        builder.append("bomEnc=[").append(bomEnc).append("], ");
+        builder.append("xmlGuessEnc=[").append(xmlGuessEnc).append("], ");
+        builder.append("xmlEnc=[").append(xmlEnc).append("], ");
+        builder.append("defaultEncoding=[").append(defaultEncoding).append("],");
+        final String encoding = calculateHttpEncoding(httpContentType, bomEnc, xmlGuessEnc, xmlEnc, lenient, defaultEncoding);
+        assertEquals(expected, encoding, builder.toString());
+    }
+
+    private void checkHttpError(final String msgSuffix, final boolean lenient, final String httpContentType,
+            final String bomEnc, final String xmlGuessEnc, final String xmlEnc, final String defaultEncoding) {
+        try {
+            checkHttpEncoding("XmlStreamReaderException", lenient, httpContentType, bomEnc, xmlGuessEnc, xmlEnc, defaultEncoding);
+            fail("Expected XmlStreamReaderException");
+        } catch (final XmlStreamReaderException e) {
+            assertTrue(e.getMessage().startsWith("Invalid encoding"), "Msg Start: " + e.getMessage());
+            assertTrue(e.getMessage().endsWith(msgSuffix), "Msg End: " + e.getMessage());
+            assertEquals(bomEnc, e.getBomEncoding(), "bomEnc");
+            assertEquals(xmlGuessEnc, e.getXmlGuessEncoding(), "xmlGuessEnc");
+            assertEquals(xmlEnc, e.getXmlEncoding(), "xmlEnc");
+            assertEquals(XmlStreamReader.getContentTypeEncoding(httpContentType), e.getContentTypeEncoding(),
+                    "ContentTypeEncoding");
+            assertEquals(XmlStreamReader.getContentTypeMime(httpContentType), e.getContentTypeMime(), "ContentTypeMime");
+        } catch (final Exception e) {
+            fail("Expected XmlStreamReaderException, but threw " + e);
+        }
+    }
+
+    private void checkRawEncoding(final String expected,
+            final String bomEnc, final String xmlGuessEnc, final String xmlEnc, final String defaultEncoding) throws IOException {
+        final StringBuilder builder = new StringBuilder();
+        builder.append("RawEncoding: ").append(bomEnc).append("], ");
+        builder.append("bomEnc=[").append(bomEnc).append("], ");
+        builder.append("xmlGuessEnc=[").append(xmlGuessEnc).append("], ");
+        builder.append("xmlEnc=[").append(xmlEnc).append("], ");
+        builder.append("defaultEncoding=[").append(defaultEncoding).append("],");
+        final String encoding = calculateRawEncoding(bomEnc,xmlGuessEnc,xmlEnc, defaultEncoding);
+        assertEquals(expected, encoding, builder.toString());
+    }
+
+    private void checkRawError(final String msgSuffix,
+            final String bomEnc, final String xmlGuessEnc, final String xmlEnc, final String defaultEncoding) {
+        try {
+            checkRawEncoding("XmlStreamReaderException", bomEnc, xmlGuessEnc, xmlEnc, defaultEncoding);
+            fail("Expected XmlStreamReaderException");
+        } catch (final XmlStreamReaderException e) {
+            assertTrue(e.getMessage().startsWith("Invalid encoding"), "Msg Start: " + e.getMessage());
+            assertTrue(e.getMessage().endsWith(msgSuffix), "Msg End: "   + e.getMessage());
+            assertEquals(bomEnc, e.getBomEncoding(), "bomEnc");
+            assertEquals(xmlGuessEnc, e.getXmlGuessEncoding(), "xmlGuessEnc");
+            assertEquals(xmlEnc, e.getXmlEncoding(), "xmlEnc");
+            assertNull(e.getContentTypeEncoding(), "ContentTypeEncoding");
+            assertNull(e.getContentTypeMime(), "ContentTypeMime");
+        } catch (final Exception e) {
+            fail("Expected XmlStreamReaderException, but threw " + e);
+        }
+    }
+
+    @SuppressWarnings("boxing")
+    private void checkTextXml(final boolean expected, final String mime) {
+        assertEquals(expected, XmlStreamReader.isTextXml(mime), "Mime=[" + mime + "]");
+    }
+
+    @Test
+    public void testAppXml() {
+        checkAppXml(false, null);
+        checkAppXml(false, "");
+        checkAppXml(true,  "application/xml");
+        checkAppXml(true,  "application/xml-dtd");
+        checkAppXml(true,  "application/xml-external-parsed-entity");
+        checkAppXml(true,  "application/soap+xml");
+        checkAppXml(true,  "application/atom+xml");
+        checkAppXml(false, "application/atomxml");
+        checkAppXml(false, "text/xml");
+        checkAppXml(false, "text/atom+xml");
+        checkAppXml(true,  "application/xml-dtd");
+        checkAppXml(true,  "application/xml-external-parsed-entity");
+    }
+
+    @Test
+    public void testCalculateHttpEncoding() throws IOException {
+        // No BOM        Expected     Lenient cType           BOM         Guess       XML         Default
+        checkHttpError(HTTPMGS3,      true,   null,           null,       null,       null,       null);
+        checkHttpError(HTTPMGS3,      false,  null,           null,       null,       "UTF-8",    null);
+        checkHttpEncoding("UTF-8",    true,   null,           null,       null,       "UTF-8",    null);
+        checkHttpEncoding("UTF-16LE", true,   null,           null,       null,       "UTF-16LE", null);
+        checkHttpError(HTTPMGS3,      false,  "text/css",     null,       null,       null,       null);
+        checkHttpEncoding("US-ASCII", false,  TXTXML,         null,       null,       null,       null);
+        checkHttpEncoding("UTF-16BE", false,  TXTXML,         null,       null,       null,       "UTF-16BE");
+        checkHttpEncoding("UTF-8",    false,  APPXML,         null,       null,       null,       null);
+        checkHttpEncoding("UTF-16BE", false,  APPXML,         null,       null,       null,       "UTF-16BE");
+        checkHttpEncoding("UTF-8",    false,  APPXML,         "UTF-8",    null,       null,       "UTF-16BE");
+        checkHttpEncoding("UTF-16LE", false,  APPXML_UTF16LE, null,       null,       null,       null);
+        checkHttpEncoding("UTF-16BE", false,  APPXML_UTF16BE, null,       null,       null,       null);
+        checkHttpError(HTTPMGS1,      false,  APPXML_UTF16LE, "UTF-16LE", null,       null,       null);
+        checkHttpError(HTTPMGS1,      false,  APPXML_UTF16BE, "UTF-16BE", null,       null,       null);
+        checkHttpError(HTTPMGS2,      false,  APPXML_UTF16,   null,       null,       null,       null);
+        checkHttpError(HTTPMGS2,      false,  APPXML_UTF16,   "UTF-8",    null,       null,       null);
+        checkHttpEncoding("UTF-16LE", false,  APPXML_UTF16,   "UTF-16LE", null,       null,       null);
+        checkHttpEncoding("UTF-16BE", false,  APPXML_UTF16,   "UTF-16BE", null,       null,       null);
+        checkHttpEncoding("UTF-8",    false,  APPXML_UTF8,    null,       null,       null,       null);
+        checkHttpEncoding("UTF-8",    false,  APPXML_UTF8,    "UTF-16BE", "UTF-16BE", "UTF-16BE", "UTF-16BE");
+    }
+
+    @Test
+    public void testCalculateHttpEncodingUtf32() throws IOException {
+        // No BOM        Expected     Lenient cType           BOM         Guess       XML         Default
+        checkHttpEncoding("UTF-32LE", true,   null,           null,       null,       "UTF-32LE", null);
+        checkHttpEncoding("UTF-32BE", false,  TXTXML,         null,       null,       null,       "UTF-32BE");
+        checkHttpEncoding("UTF-32BE", false,  APPXML,         null,       null,       null,       "UTF-32BE");
+        checkHttpEncoding("UTF-32LE", false,  APPXML_UTF32LE, null,       null,       null,       null);
+        checkHttpEncoding("UTF-32BE", false,  APPXML_UTF32BE, null,       null,       null,       null);
+        checkHttpError(HTTPMGS1,      false,  APPXML_UTF32LE, "UTF-32LE", null,       null,       null);
+        checkHttpError(HTTPMGS1,      false,  APPXML_UTF32BE, "UTF-32BE", null,       null,       null);
+        checkHttpError(HTTPMGS2,      false,  APPXML_UTF32,   null,       null,       null,       null);
+        checkHttpError(HTTPMGS2,      false,  APPXML_UTF32,   "UTF-8",    null,       null,       null);
+        checkHttpEncoding("UTF-32LE", false,  APPXML_UTF32,   "UTF-32LE", null,       null,       null);
+        checkHttpEncoding("UTF-32BE", false,  APPXML_UTF32,   "UTF-32BE", null,       null,       null);
+        checkHttpEncoding("UTF-8",    false,  APPXML_UTF8,    "UTF-32BE", "UTF-32BE", "UTF-32BE", "UTF-32BE");
+    }
+
+    @Test
+    public void testCalculateRawEncodingAdditionalUTF16() throws IOException {
+        //                           BOM         Guess       XML         Default
+        checkRawError(RAWMGS1,       "UTF-16BE", "UTF-16",   null,       null);
+        checkRawEncoding("UTF-16BE", "UTF-16BE", null,       "UTF-16",   null);
+        checkRawEncoding("UTF-16BE", "UTF-16BE", "UTF-16BE", "UTF-16",   null);
+        checkRawError(RAWMGS1,       "UTF-16BE", null,       "UTF-16LE", null);
+        checkRawError(RAWMGS1,       "UTF-16BE", "UTF-16BE", "UTF-16LE", null);
+        checkRawError(RAWMGS1,       "UTF-16LE", "UTF-16",   null,       null);
+        checkRawEncoding("UTF-16LE", "UTF-16LE", null,       "UTF-16",   null);
+        checkRawEncoding("UTF-16LE", "UTF-16LE", "UTF-16LE", "UTF-16",   null);
+        checkRawError(RAWMGS1,       "UTF-16LE", null,       "UTF-16BE", null);
+        checkRawError(RAWMGS1,       "UTF-16LE", "UTF-16LE", "UTF-16BE", null);
+    }
+
+    @Test
+    public void testCalculateRawEncodingAdditionalUTF32() throws IOException {
+        //                           BOM         Guess       XML         Default
+        checkRawError(RAWMGS1,       "UTF-32BE", "UTF-32",   null,       null);
+        checkRawEncoding("UTF-32BE", "UTF-32BE", null,       "UTF-32",   null);
+        checkRawEncoding("UTF-32BE", "UTF-32BE", "UTF-32BE", "UTF-32",   null);
+        checkRawError(RAWMGS1,       "UTF-32BE", null,       "UTF-32LE", null);
+        checkRawError(RAWMGS1,       "UTF-32BE", "UTF-32BE", "UTF-32LE", null);
+        checkRawError(RAWMGS1,       "UTF-32LE", "UTF-32",   null,       null);
+        checkRawEncoding("UTF-32LE", "UTF-32LE", null,       "UTF-32",   null);
+        checkRawEncoding("UTF-32LE", "UTF-32LE", "UTF-32LE", "UTF-32",   null);
+        checkRawError(RAWMGS1,       "UTF-32LE", null,       "UTF-32BE", null);
+        checkRawError(RAWMGS1,       "UTF-32LE", "UTF-32LE", "UTF-32BE", null);
+    }
+
+    @Test
+    public void testCalculateRawEncodingNoBOM() throws IOException {
+        // No BOM        Expected    BOM         Guess       XML         Default
+        checkRawError(RAWMGS2,       "UTF-32",   null,       null,       null);
+        //
+        checkRawEncoding("UTF-8",    null,       null,       null,       null);
+        checkRawEncoding("UTF-8",    null,       "UTF-16BE", null,       null); /* why default & not Guess? */
+        checkRawEncoding("UTF-8",    null,       null,       "UTF-16BE", null); /* why default & not XMLEnc? */
+        checkRawEncoding("UTF-8",    null,       "UTF-8",    "UTF-8",    "UTF-16BE");
+        //
+        checkRawEncoding("UTF-16BE", null,       "UTF-16BE", "UTF-16BE", null);
+        checkRawEncoding("UTF-16BE", null,       null,       null,       "UTF-16BE");
+        checkRawEncoding("UTF-16BE", null,       "UTF-8",    null,       "UTF-16BE"); /* why default & not Guess? */
+        checkRawEncoding("UTF-16BE", null,       null,       "UTF-8",    "UTF-16BE"); /* why default & not Guess? */
+        checkRawEncoding("UTF-16BE", null,       "UTF-16BE", "UTF-16",   null);
+        checkRawEncoding("UTF-16LE", null,       "UTF-16LE", "UTF-16",   null);
+    }
+
+    @Test
+    public void testCalculateRawEncodingStandard() throws IOException {
+        // Standard BOM Checks           BOM         Other       Default
+        testCalculateRawEncodingStandard("UTF-8",    "UTF-16BE", "UTF-16LE");
+        testCalculateRawEncodingStandard("UTF-16BE", "UTF-8",    "UTF-16LE");
+        testCalculateRawEncodingStandard("UTF-16LE", "UTF-8",    "UTF-16BE");
+    }
+
+    private void testCalculateRawEncodingStandard(final String bomEnc, final String otherEnc, final String defaultEnc) throws IOException {
+        //               Expected   BOM        Guess     XMLEnc    Default
+        checkRawEncoding(bomEnc,    bomEnc,    null,     null,     defaultEnc);
+        checkRawEncoding(bomEnc,    bomEnc,    bomEnc,   null,     defaultEnc);
+        checkRawError(RAWMGS1,      bomEnc,    otherEnc, null,     defaultEnc);
+        checkRawEncoding(bomEnc,    bomEnc,    null,     bomEnc,   defaultEnc);
+        checkRawError(RAWMGS1,      bomEnc,    null,     otherEnc, defaultEnc);
+        checkRawEncoding(bomEnc,    bomEnc,    bomEnc,   bomEnc,   defaultEnc);
+        checkRawError(RAWMGS1,      bomEnc,    bomEnc,   otherEnc, defaultEnc);
+        checkRawError(RAWMGS1,      bomEnc,    otherEnc, bomEnc,   defaultEnc);
+
+    }
+
+    @Test
+    public void testCalculateRawEncodingStandardUtf32() throws IOException {
+        // Standard BOM Checks           BOM         Other       Default
+        testCalculateRawEncodingStandard("UTF-8",    "UTF-32BE", "UTF-32LE");
+        testCalculateRawEncodingStandard("UTF-32BE", "UTF-8",    "UTF-32LE");
+        testCalculateRawEncodingStandard("UTF-32LE", "UTF-8",    "UTF-32BE");
+}
+
+    @Test
+    public void testContentTypeEncoding() {
+        checkContentTypeEncoding(null, null);
+        checkContentTypeEncoding(null, "");
+        checkContentTypeEncoding(null, "application/xml");
+        checkContentTypeEncoding(null, "application/xml;");
+        checkContentTypeEncoding(null, "multipart/mixed;boundary=frontier");
+        checkContentTypeEncoding(null, "multipart/mixed;boundary='frontier'");
+        checkContentTypeEncoding(null, "multipart/mixed;boundary=\"frontier\"");
+        checkContentTypeEncoding("UTF-16", "application/xml;charset=utf-16");
+        checkContentTypeEncoding("UTF-16", "application/xml;charset=UTF-16");
+        checkContentTypeEncoding("UTF-16", "application/xml;charset='UTF-16'");
+        checkContentTypeEncoding("UTF-16", "application/xml;charset=\"UTF-16\"");
+        checkContentTypeEncoding("UTF-32", "application/xml;charset=utf-32");
+        checkContentTypeEncoding("UTF-32", "application/xml;charset=UTF-32");
+        checkContentTypeEncoding("UTF-32", "application/xml;charset='UTF-32'");
+        checkContentTypeEncoding("UTF-32", "application/xml;charset=\"UTF-32\"");
+    }
+
+    @Test
+    public void testContentTypeMime() {
+        checkContentTypeMime(null, null);
+        checkContentTypeMime("", "");
+        checkContentTypeMime("application/xml", "application/xml");
+        checkContentTypeMime("application/xml", "application/xml;");
+        checkContentTypeMime("application/xml", "application/xml;charset=utf-16");
+        checkContentTypeMime("application/xml", "application/xml;charset=utf-32");
+    }
+
+    @Test
+    public void testTextXml() {
+        checkTextXml(false, null);
+        checkTextXml(false, "");
+        checkTextXml(true,  "text/xml");
+        checkTextXml(true,  "text/xml-external-parsed-entity");
+        checkTextXml(true,  "text/soap+xml");
+        checkTextXml(true,  "text/atom+xml");
+        checkTextXml(false, "text/atomxml");
+        checkTextXml(false, "application/xml");
+        checkTextXml(false, "application/atom+xml");
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/buffer/CircularBufferInputStreamTest.java b/src/test/java/org/apache/commons/io/input/buffer/CircularBufferInputStreamTest.java
new file mode 100644
index 0000000..a08405e
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/buffer/CircularBufferInputStreamTest.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input.buffer;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.Random;
+
+import org.junit.jupiter.api.Test;
+
+public class CircularBufferInputStreamTest {
+    private final Random rnd = new Random(1530960934483L); // System.currentTimeMillis(), when this test was written.
+                                                           // Always using the same seed should ensure a reproducible test.
+
+    /**
+     * Create a large, but random input buffer.
+     */
+    private byte[] newInputBuffer() {
+        final byte[] buffer = new byte[16 * 512 + rnd.nextInt(512)];
+        rnd.nextBytes(buffer);
+        return buffer;
+    }
+
+    @Test
+    public void testIO683() throws IOException {
+        final byte[] buffer = {0, 1, -2, -2, -1, 4};
+        try (ByteArrayInputStream bais = new ByteArrayInputStream(buffer); final CircularBufferInputStream cbis = new CircularBufferInputStream(bais)) {
+            int b;
+            int i = 0;
+            while ((b = cbis.read()) != -1) {
+                assertEquals(buffer[i] & 0xFF, b, "byte at index " + i + " should be equal");
+                i++;
+            }
+            assertEquals(buffer.length, i, "Should have read all the bytes");
+        }
+    }
+
+    @Test
+    public void testRandomRead() throws Exception {
+        final byte[] inputBuffer = newInputBuffer();
+        final byte[] bufferCopy = new byte[inputBuffer.length];
+        final ByteArrayInputStream bais = new ByteArrayInputStream(inputBuffer);
+        @SuppressWarnings("resource")
+        final CircularBufferInputStream cbis = new CircularBufferInputStream(bais, 253);
+        int offset = 0;
+        final byte[] readBuffer = new byte[256];
+        while (offset < bufferCopy.length) {
+            switch (rnd.nextInt(2)) {
+            case 0: {
+                final int res = cbis.read();
+                if (res == -1) {
+                    throw new IllegalStateException("Unexpected EOF at offset " + offset);
+                }
+                if (inputBuffer[offset] != (byte) res) { // compare as bytes
+                    throw new IllegalStateException("Expected " + inputBuffer[offset] + " at offset " + offset + ", got " + res);
+                }
+                ++offset;
+                break;
+            }
+            case 1: {
+                final int res = cbis.read(readBuffer, 0, rnd.nextInt(readBuffer.length + 1));
+                if (res == -1) {
+                    throw new IllegalStateException("Unexpected EOF at offset " + offset);
+                }
+                if (res == 0) {
+                    throw new IllegalStateException("Unexpected zero-byte-result at offset " + offset);
+                }
+                for (int i = 0; i < res; i++) {
+                    if (inputBuffer[offset] != readBuffer[i]) {
+                        throw new IllegalStateException("Expected " + inputBuffer[offset] + " at offset " + offset + ", got " + readBuffer[i]);
+                    }
+                    ++offset;
+                }
+                break;
+            }
+            default:
+                throw new IllegalStateException("Unexpected random choice value");
+            }
+        }
+        assertTrue(true, "Test finished OK");
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/compatibility/XmlStreamReader.java b/src/test/java/org/apache/commons/io/input/compatibility/XmlStreamReader.java
new file mode 100644
index 0000000..186da40
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/compatibility/XmlStreamReader.java
@@ -0,0 +1,744 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input.compatibility;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.StringReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLConnection;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.text.MessageFormat;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.output.XmlStreamWriter;
+
+/**
+ * Character stream that handles all the necessary Voodoo to figure out the
+ * charset encoding of the XML document within the stream.
+ * <p>
+ * IMPORTANT: This class is not related in any way to the org.xml.sax.XMLReader.
+ * This one IS a character stream.
+ * </p>
+ * <p>
+ * All this has to be done without consuming characters from the stream, if not
+ * the XML parser will not recognized the document as a valid XML. This is not
+ * 100% true, but it's close enough (UTF-8 BOM is not handled by all parsers
+ * right now, XmlStreamReader handles it and things work in all parsers).
+ * </p>
+ * <p>
+ * The XmlStreamReader class handles the charset encoding of XML documents in
+ * Files, raw streams and HTTP streams by offering a wide set of constructors.
+ * </p>
+ * <p>
+ * By default the charset encoding detection is lenient, the constructor with
+ * the lenient flag can be used for a script (following HTTP MIME and XML
+ * specifications). All this is nicely explained by Mark Pilgrim in his blog, <a
+ * href="http://diveintomark.org/archives/2004/02/13/xml-media-types">
+ * Determining the character encoding of a feed</a>.
+ * </p>
+ * <p>
+ * Originally developed for <a href="http://rome.dev.java.net">ROME</a> under
+ * Apache License 2.0.
+ * </p>
+ *
+ * @see XmlStreamWriter
+ */
+public class XmlStreamReader extends Reader {
+
+    private static final String UTF_8 = StandardCharsets.UTF_8.name();
+
+    private static final String US_ASCII = StandardCharsets.US_ASCII.name();
+
+    private static final String UTF_16BE = StandardCharsets.UTF_16BE.name();
+
+    private static final String UTF_16LE = StandardCharsets.UTF_16LE.name();
+
+    private static final String UTF_16 = StandardCharsets.UTF_16.name();
+
+    private static final String UTF_32BE = "UTF-32BE";
+
+    private static final String UTF_32LE = "UTF-32LE";
+
+    private static final String UTF_32 = "UTF-32";
+
+    private static final String EBCDIC = "CP1047";
+
+    private static String staticDefaultEncoding;
+
+    private static final Pattern CHARSET_PATTERN = Pattern
+            .compile("charset=[\"']?([.[^; \"']]*)[\"']?");
+
+    public static final Pattern ENCODING_PATTERN = Pattern.compile(
+            "<\\?xml.*encoding[\\s]*=[\\s]*((?:\".[^\"]*\")|(?:'.[^']*'))",
+            Pattern.MULTILINE);
+
+    private static final MessageFormat RAW_EX_1 = new MessageFormat(
+            "Invalid encoding, BOM [{0}] XML guess [{1}] XML prolog [{2}] encoding mismatch");
+
+    private static final MessageFormat RAW_EX_2 = new MessageFormat(
+            "Invalid encoding, BOM [{0}] XML guess [{1}] XML prolog [{2}] unknown BOM");
+
+    private static final MessageFormat HTTP_EX_1 = new MessageFormat(
+            "Invalid encoding, CT-MIME [{0}] CT-Enc [{1}] BOM [{2}] XML guess [{3}] XML prolog [{4}], BOM must be NULL");
+
+    private static final MessageFormat HTTP_EX_2 = new MessageFormat(
+            "Invalid encoding, CT-MIME [{0}] CT-Enc [{1}] BOM [{2}] XML guess [{3}] XML prolog [{4}], encoding mismatch");
+
+    private static final MessageFormat HTTP_EX_3 = new MessageFormat(
+            "Invalid encoding, CT-MIME [{0}] CT-Enc [{1}] BOM [{2}] XML guess [{3}] XML prolog [{4}], Invalid MIME");
+
+    // returns the BOM in the stream, NULL if not present,
+    // if there was BOM the in the stream it is consumed
+    static String getBOMEncoding(final BufferedInputStream is)
+            throws IOException {
+        String encoding = null;
+        final int[] bytes = new int[3];
+        is.mark(3);
+        bytes[0] = is.read();
+        bytes[1] = is.read();
+        bytes[2] = is.read();
+
+        if (bytes[0] == 0xFE && bytes[1] == 0xFF) {
+            encoding = UTF_16BE;
+            is.reset();
+            is.read();
+            is.read();
+        } else if (bytes[0] == 0xFF && bytes[1] == 0xFE) {
+            encoding = UTF_16LE;
+            is.reset();
+            is.read();
+            is.read();
+        } else if (bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) {
+            encoding = UTF_8;
+        } else {
+            is.reset();
+        }
+        return encoding;
+    }
+
+    // returns charset parameter value, NULL if not present, NULL if
+    // httpContentType is NULL
+    static String getContentTypeEncoding(final String httpContentType) {
+        String encoding = null;
+        if (httpContentType != null) {
+            final int i = httpContentType.indexOf(";");
+            if (i > -1) {
+                final String postMime = httpContentType.substring(i + 1);
+                final Matcher m = CHARSET_PATTERN.matcher(postMime);
+                encoding = m.find() ? m.group(1) : null;
+                encoding = encoding != null ? encoding.toUpperCase(Locale.ROOT) : null;
+            }
+        }
+        return encoding;
+    }
+
+    // returns MIME type or NULL if httpContentType is NULL
+    static String getContentTypeMime(final String httpContentType) {
+        String mime = null;
+        if (httpContentType != null) {
+            final int i = httpContentType.indexOf(";");
+            mime = (i == -1 ? httpContentType : httpContentType.substring(0,
+                    i)).trim();
+        }
+        return mime;
+    }
+
+    /**
+     * Returns the default encoding to use if none is set in HTTP content-type,
+     * XML prolog and the rules based on content-type are not adequate.
+     * <p>
+     * If it is NULL the content-type based rules are used.
+     *
+     * @return the default encoding to use.
+     */
+    public static String getDefaultEncoding() {
+        return staticDefaultEncoding;
+    }
+
+    // returns the best guess for the encoding by looking the first bytes of the
+    // stream, '<?'
+    private static String getXMLGuessEncoding(final BufferedInputStream is)
+            throws IOException {
+        String encoding = null;
+        final int[] bytes = new int[4];
+        is.mark(4);
+        bytes[0] = is.read();
+        bytes[1] = is.read();
+        bytes[2] = is.read();
+        bytes[3] = is.read();
+        is.reset();
+
+        if (bytes[0] == 0x00 && bytes[1] == 0x3C && bytes[2] == 0x00
+                && bytes[3] == 0x3F) {
+            encoding = UTF_16BE;
+        } else if (bytes[0] == 0x3C && bytes[1] == 0x00 && bytes[2] == 0x3F
+                && bytes[3] == 0x00) {
+            encoding = UTF_16LE;
+        } else if (bytes[0] == 0x3C && bytes[1] == 0x3F && bytes[2] == 0x78
+                && bytes[3] == 0x6D) {
+            encoding = UTF_8;
+        } else if (bytes[0] == 0x4C && bytes[1] == 0x6F && bytes[2] == 0xA7
+                && bytes[3] == 0x94) {
+            encoding = EBCDIC;
+        }
+        return encoding;
+    }
+
+    // returns the encoding declared in the <?xml encoding=...?>, NULL if none
+    private static String getXmlProlog(final BufferedInputStream is, final String guessedEnc)
+            throws IOException {
+        String encoding = null;
+        if (guessedEnc != null) {
+            final byte[] bytes = IOUtils.byteArray();
+            is.mark(IOUtils.DEFAULT_BUFFER_SIZE);
+            int offset = 0;
+            int max = IOUtils.DEFAULT_BUFFER_SIZE;
+            int c = is.read(bytes, offset, max);
+            int firstGT = -1;
+            String xmlProlog = ""; // avoid possible NPE warning (cannot happen; this just silences the warning)
+            while (c != -1 && firstGT == -1 && offset < IOUtils.DEFAULT_BUFFER_SIZE) {
+                offset += c;
+                max -= c;
+                c = is.read(bytes, offset, max);
+                xmlProlog = new String(bytes, 0, offset, guessedEnc);
+                firstGT = xmlProlog.indexOf('>');
+            }
+            if (firstGT == -1) {
+                if (c == -1) {
+                    throw new IOException("Unexpected end of XML stream");
+                }
+                throw new IOException(
+                        "XML prolog or ROOT element not found on first "
+                                + offset + " bytes");
+            }
+            final int bytesRead = offset;
+            if (bytesRead > 0) {
+                is.reset();
+                final BufferedReader bReader = new BufferedReader(new StringReader(
+                        xmlProlog.substring(0, firstGT + 1)));
+                final StringBuilder prolog = new StringBuilder();
+                String line;
+                while ((line = bReader.readLine()) != null) {
+                    prolog.append(line);
+                }
+                final Matcher m = ENCODING_PATTERN.matcher(prolog);
+                if (m.find()) {
+                    encoding = m.group(1).toUpperCase(Locale.ROOT);
+                    encoding = encoding.substring(1, encoding.length() - 1);
+                }
+            }
+        }
+        return encoding;
+    }
+
+    // indicates if the MIME type belongs to the APPLICATION XML family
+    static boolean isAppXml(final String mime) {
+        return mime != null
+                && (mime.equals("application/xml")
+                        || mime.equals("application/xml-dtd")
+                        || mime
+                                .equals("application/xml-external-parsed-entity") || mime
+                        .startsWith("application/") && mime.endsWith("+xml"));
+    }
+
+    // indicates if the MIME type belongs to the TEXT XML family
+    static boolean isTextXml(final String mime) {
+        return mime != null
+                && (mime.equals("text/xml")
+                        || mime.equals("text/xml-external-parsed-entity") || mime
+                        .startsWith("text/") && mime.endsWith("+xml"));
+    }
+
+    /**
+     * Sets the default encoding to use if none is set in HTTP content-type, XML
+     * prolog and the rules based on content-type are not adequate.
+     * <p>
+     * If it is set to NULL the content-type based rules are used.
+     * <p>
+     * By default it is NULL.
+     *
+     * @param encoding charset encoding to default to.
+     */
+    public static void setDefaultEncoding(final String encoding) {
+        staticDefaultEncoding = encoding;
+    }
+
+    private Reader reader;
+
+    private String encoding;
+
+    private final String defaultEncoding;
+
+    /**
+     * Creates a Reader for a File.
+     * <p>
+     * It looks for the UTF-8 BOM first, if none sniffs the XML prolog charset,
+     * if this is also missing defaults to UTF-8.
+     * <p>
+     * It does a lenient charset encoding detection, check the constructor with
+     * the lenient parameter for details.
+     *
+     * @param file File to create a Reader from.
+     * @throws IOException thrown if there is a problem reading the file.
+     */
+    @SuppressWarnings("resource") // FileInputStream is closed when this closed when this object is closed.
+    public XmlStreamReader(final File file) throws IOException {
+        this(Files.newInputStream(file.toPath()));
+    }
+
+    /**
+     * Creates a Reader for a raw InputStream.
+     * <p>
+     * It follows the same logic used for files.
+     * <p>
+     * It does a lenient charset encoding detection, check the constructor with
+     * the lenient parameter for details.
+     *
+     * @param inputStream InputStream to create a Reader from.
+     * @throws IOException thrown if there is a problem reading the stream.
+     */
+    public XmlStreamReader(final InputStream inputStream) throws IOException {
+        this(inputStream, true);
+    }
+
+    /**
+     * Creates a Reader for a raw InputStream.
+     * <p>
+     * It follows the same logic used for files.
+     * <p>
+     * If lenient detection is indicated and the detection above fails as per
+     * specifications it then attempts the following:
+     * <p>
+     * If the content type was 'text/html' it replaces it with 'text/xml' and
+     * tries the detection again.
+     * <p>
+     * Else if the XML prolog had a charset encoding that encoding is used.
+     * <p>
+     * Else if the content type had a charset encoding that encoding is used.
+     * <p>
+     * Else 'UTF-8' is used.
+     * <p>
+     * If lenient detection is indicated an XmlStreamReaderException is never
+     * thrown.
+     *
+     * @param inputStream InputStream to create a Reader from.
+     * @param lenient indicates if the charset encoding detection should be
+     *        relaxed.
+     * @throws IOException thrown if there is a problem reading the stream.
+     * @throws XmlStreamReaderException thrown if the charset encoding could not
+     *         be determined according to the specs.
+     */
+    public XmlStreamReader(final InputStream inputStream, final boolean lenient) throws IOException,
+            XmlStreamReaderException {
+        defaultEncoding = staticDefaultEncoding;
+        try {
+            doRawStream(inputStream);
+        } catch (final XmlStreamReaderException ex) {
+            if (!lenient) {
+                throw ex;
+            }
+            doLenientDetection(null, ex);
+        }
+    }
+
+    /**
+     * Creates a Reader using an InputStream and the associated content-type
+     * header.
+     * <p>
+     * First it checks if the stream has BOM. If there is not BOM checks the
+     * content-type encoding. If there is not content-type encoding checks the
+     * XML prolog encoding. If there is not XML prolog encoding uses the default
+     * encoding mandated by the content-type MIME type.
+     * <p>
+     * It does a lenient charset encoding detection, check the constructor with
+     * the lenient parameter for details.
+     *
+     * @param inputStream InputStream to create the reader from.
+     * @param httpContentType content-type header to use for the resolution of
+     *        the charset encoding.
+     * @throws IOException thrown if there is a problem reading the file.
+     */
+    public XmlStreamReader(final InputStream inputStream, final String httpContentType)
+            throws IOException {
+        this(inputStream, httpContentType, true);
+    }
+
+    /**
+     * Creates a Reader using an InputStream and the associated content-type
+     * header. This constructor is lenient regarding the encoding detection.
+     * <p>
+     * First it checks if the stream has BOM. If there is not BOM checks the
+     * content-type encoding. If there is not content-type encoding checks the
+     * XML prolog encoding. If there is not XML prolog encoding uses the default
+     * encoding mandated by the content-type MIME type.
+     * <p>
+     * If lenient detection is indicated and the detection above fails as per
+     * specifications it then attempts the following:
+     * <p>
+     * If the content type was 'text/html' it replaces it with 'text/xml' and
+     * tries the detection again.
+     * <p>
+     * Else if the XML prolog had a charset encoding that encoding is used.
+     * <p>
+     * Else if the content type had a charset encoding that encoding is used.
+     * <p>
+     * Else 'UTF-8' is used.
+     * <p>
+     * If lenient detection is indicated an XmlStreamReaderException is never
+     * thrown.
+     *
+     * @param inputStream InputStream to create the reader from.
+     * @param httpContentType content-type header to use for the resolution of
+     *        the charset encoding.
+     * @param lenient indicates if the charset encoding detection should be
+     *        relaxed.
+     * @throws IOException thrown if there is a problem reading the file.
+     * @throws XmlStreamReaderException thrown if the charset encoding could not
+     *         be determined according to the specs.
+     */
+    public XmlStreamReader(final InputStream inputStream, final String httpContentType,
+            final boolean lenient) throws IOException, XmlStreamReaderException {
+        this(inputStream, httpContentType, lenient, null);
+    }
+
+    /**
+     * Creates a Reader using an InputStream and the associated content-type
+     * header. This constructor is lenient regarding the encoding detection.
+     * <p>
+     * First it checks if the stream has BOM. If there is not BOM checks the
+     * content-type encoding. If there is not content-type encoding checks the
+     * XML prolog encoding. If there is not XML prolog encoding uses the default
+     * encoding mandated by the content-type MIME type.
+     * <p>
+     * If lenient detection is indicated and the detection above fails as per
+     * specifications it then attempts the following:
+     * <p>
+     * If the content type was 'text/html' it replaces it with 'text/xml' and
+     * tries the detection again.
+     * <p>
+     * Else if the XML prolog had a charset encoding that encoding is used.
+     * <p>
+     * Else if the content type had a charset encoding that encoding is used.
+     * <p>
+     * Else 'UTF-8' is used.
+     * <p>
+     * If lenient detection is indicated an XmlStreamReaderException is never
+     * thrown.
+     *
+     * @param inputStream InputStream to create the reader from.
+     * @param httpContentType content-type header to use for the resolution of
+     *        the charset encoding.
+     * @param lenient indicates if the charset encoding detection should be
+     *        relaxed.
+     * @param defaultEncoding the default encoding to use
+     * @throws IOException thrown if there is a problem reading the file.
+     * @throws XmlStreamReaderException thrown if the charset encoding could not
+     *         be determined according to the specs.
+     */
+    public XmlStreamReader(final InputStream inputStream, final String httpContentType,
+            final boolean lenient, final String defaultEncoding) throws IOException,
+            XmlStreamReaderException {
+        this.defaultEncoding = defaultEncoding == null ? staticDefaultEncoding
+                : defaultEncoding;
+        try {
+            doHttpStream(inputStream, httpContentType, lenient);
+        } catch (final XmlStreamReaderException ex) {
+            if (!lenient) {
+                throw ex;
+            }
+            doLenientDetection(httpContentType, ex);
+        }
+    }
+
+    /**
+     * Creates a Reader using the InputStream of a URL.
+     * <p>
+     * If the URL is not of type HTTP and there is not 'content-type' header in
+     * the fetched data it uses the same logic used for Files.
+     * <p>
+     * If the URL is a HTTP Url or there is a 'content-type' header in the
+     * fetched data it uses the same logic used for an InputStream with
+     * content-type.
+     * <p>
+     * It does a lenient charset encoding detection, check the constructor with
+     * the lenient parameter for details.
+     *
+     * @param url URL to create a Reader from.
+     * @throws IOException thrown if there is a problem reading the stream of
+     *         the URL.
+     */
+    public XmlStreamReader(final URL url) throws IOException {
+        // TODO URLConnection leak.
+        this(url.openConnection());
+    }
+
+    /**
+     * Creates a Reader using the InputStream of a URLConnection.
+     * <p>
+     * If the URLConnection is not of type HttpURLConnection and there is not
+     * 'content-type' header in the fetched data it uses the same logic used for
+     * files.
+     * <p>
+     * If the URLConnection is a HTTP Url or there is a 'content-type' header in
+     * the fetched data it uses the same logic used for an InputStream with
+     * content-type.
+     * <p>
+     * It does a lenient charset encoding detection, check the constructor with
+     * the lenient parameter for details.
+     *
+     * @param conn URLConnection to create a Reader from.
+     * @throws IOException thrown if there is a problem reading the stream of
+     *         the URLConnection.
+     */
+    public XmlStreamReader(final URLConnection conn) throws IOException {
+        defaultEncoding = staticDefaultEncoding;
+        final boolean lenient = true;
+        if (conn instanceof HttpURLConnection) {
+            try {
+                doHttpStream(conn.getInputStream(), conn.getContentType(),
+                        lenient);
+            } catch (final XmlStreamReaderException ex) {
+                doLenientDetection(conn.getContentType(), ex);
+            }
+        } else if (conn.getContentType() != null) {
+            try {
+                doHttpStream(conn.getInputStream(), conn.getContentType(),
+                        lenient);
+            } catch (final XmlStreamReaderException ex) {
+                doLenientDetection(conn.getContentType(), ex);
+            }
+        } else {
+            try {
+                doRawStream(conn.getInputStream());
+            } catch (final XmlStreamReaderException ex) {
+                doLenientDetection(null, ex);
+            }
+        }
+    }
+
+    // InputStream is passed for XmlStreamReaderException creation only
+    String calculateHttpEncoding(final String cTMime, final String cTEnc,
+            final String bomEnc, final String xmlGuessEnc, final String xmlEnc, final InputStream is,
+            final boolean lenient) throws IOException {
+        final String encoding;
+        if (lenient && xmlEnc != null) {
+            encoding = xmlEnc;
+        } else {
+            final boolean appXml = isAppXml(cTMime);
+            final boolean textXml = isTextXml(cTMime);
+            if (!appXml && !textXml) {
+                throw new XmlStreamReaderException(HTTP_EX_3
+                        .format(new Object[] { cTMime, cTEnc, bomEnc,
+                                xmlGuessEnc, xmlEnc }), cTMime, cTEnc, bomEnc,
+                        xmlGuessEnc, xmlEnc, is);
+            }
+            if (cTEnc == null) {
+                if (appXml) {
+                    encoding = calculateRawEncoding(bomEnc, xmlGuessEnc,
+                            xmlEnc, is);
+                } else {
+                    encoding = defaultEncoding == null ? US_ASCII
+                            : defaultEncoding;
+                }
+            } else if (bomEnc != null
+                    && (cTEnc.equals(UTF_16BE) || cTEnc.equals(UTF_16LE))) {
+                throw new XmlStreamReaderException(HTTP_EX_1
+                        .format(new Object[] { cTMime, cTEnc, bomEnc,
+                                xmlGuessEnc, xmlEnc }), cTMime, cTEnc,
+                        bomEnc, xmlGuessEnc, xmlEnc, is);
+            } else if (cTEnc.equals(UTF_16)) {
+                if (bomEnc == null || !bomEnc.startsWith(UTF_16)) {
+                    throw new XmlStreamReaderException(HTTP_EX_2
+                            .format(new Object[] { cTMime, cTEnc, bomEnc,
+                                    xmlGuessEnc, xmlEnc }), cTMime, cTEnc,
+                            bomEnc, xmlGuessEnc, xmlEnc, is);
+                }
+                encoding = bomEnc;
+            } else if (bomEnc != null
+                    && (cTEnc.equals(UTF_32BE) || cTEnc.equals(UTF_32LE))) {
+                throw new XmlStreamReaderException(HTTP_EX_1
+                        .format(new Object[] { cTMime, cTEnc, bomEnc,
+                                xmlGuessEnc, xmlEnc }), cTMime, cTEnc,
+                        bomEnc, xmlGuessEnc, xmlEnc, is);
+            } else if (cTEnc.equals(UTF_32)) {
+                if (bomEnc == null || !bomEnc.startsWith(UTF_32)) {
+                    throw new XmlStreamReaderException(HTTP_EX_2
+                            .format(new Object[] { cTMime, cTEnc, bomEnc,
+                                    xmlGuessEnc, xmlEnc }), cTMime, cTEnc,
+                            bomEnc, xmlGuessEnc, xmlEnc, is);
+                }
+                encoding = bomEnc;
+            } else {
+                encoding = cTEnc;
+            }
+        }
+        return encoding;
+    }
+
+    // InputStream is passed for XmlStreamReaderException creation only
+    String calculateRawEncoding(final String bomEnc, final String xmlGuessEnc,
+            final String xmlEnc, final InputStream is) throws IOException {
+        final String encoding;
+        if (bomEnc == null) {
+            if (xmlGuessEnc == null || xmlEnc == null) {
+                encoding = defaultEncoding == null ? UTF_8 : defaultEncoding;
+            } else if (xmlEnc.equals(UTF_16)
+                    && (xmlGuessEnc.equals(UTF_16BE) || xmlGuessEnc
+                            .equals(UTF_16LE))) {
+                encoding = xmlGuessEnc;
+            } else if (xmlEnc.equals(UTF_32)
+                    && (xmlGuessEnc.equals(UTF_32BE) || xmlGuessEnc
+                            .equals(UTF_32LE))) {
+                encoding = xmlGuessEnc;
+            } else {
+                encoding = xmlEnc;
+            }
+        } else if (bomEnc.equals(UTF_8)) {
+            if (xmlGuessEnc != null && !xmlGuessEnc.equals(UTF_8)) {
+                throw new XmlStreamReaderException(RAW_EX_1
+                        .format(new Object[] { bomEnc, xmlGuessEnc, xmlEnc }),
+                        bomEnc, xmlGuessEnc, xmlEnc, is);
+            }
+            if (xmlEnc != null && !xmlEnc.equals(UTF_8)) {
+                throw new XmlStreamReaderException(RAW_EX_1
+                        .format(new Object[] { bomEnc, xmlGuessEnc, xmlEnc }),
+                        bomEnc, xmlGuessEnc, xmlEnc, is);
+            }
+            encoding = UTF_8;
+        } else {
+            if (bomEnc.equals(UTF_16BE) || bomEnc.equals(UTF_16LE)) {
+                if (xmlGuessEnc != null && !xmlGuessEnc.equals(bomEnc)) {
+                    throw new XmlStreamReaderException(RAW_EX_1.format(new Object[] { bomEnc,
+                            xmlGuessEnc, xmlEnc }), bomEnc, xmlGuessEnc, xmlEnc, is);
+                }
+                if (xmlEnc != null && !xmlEnc.equals(UTF_16)
+                        && !xmlEnc.equals(bomEnc)) {
+                    throw new XmlStreamReaderException(RAW_EX_1
+                            .format(new Object[] { bomEnc, xmlGuessEnc, xmlEnc }),
+                            bomEnc, xmlGuessEnc, xmlEnc, is);
+                }
+            } else if (bomEnc.equals(UTF_32BE) || bomEnc.equals(UTF_32LE)) {
+                if (xmlGuessEnc != null && !xmlGuessEnc.equals(bomEnc)) {
+                    throw new XmlStreamReaderException(RAW_EX_1.format(new Object[] { bomEnc,
+                            xmlGuessEnc, xmlEnc }), bomEnc, xmlGuessEnc, xmlEnc, is);
+                }
+                if (xmlEnc != null && !xmlEnc.equals(UTF_32)
+                        && !xmlEnc.equals(bomEnc)) {
+                    throw new XmlStreamReaderException(RAW_EX_1
+                            .format(new Object[] { bomEnc, xmlGuessEnc, xmlEnc }),
+                            bomEnc, xmlGuessEnc, xmlEnc, is);
+                }
+            } else {
+                throw new XmlStreamReaderException(RAW_EX_2.format(new Object[] {
+                        bomEnc, xmlGuessEnc, xmlEnc }), bomEnc, xmlGuessEnc,
+                        xmlEnc, is);
+            }
+            encoding = bomEnc;
+        }
+        return encoding;
+    }
+
+    /**
+     * Closes the XmlStreamReader stream.
+     *
+     * @throws IOException thrown if there was a problem closing the stream.
+     */
+    @Override
+    public void close() throws IOException {
+        reader.close();
+    }
+
+    private void doHttpStream(final InputStream inputStream, final String httpContentType,
+            final boolean lenient) throws IOException {
+        final BufferedInputStream pis = new BufferedInputStream(inputStream, IOUtils.DEFAULT_BUFFER_SIZE);
+        final String cTMime = getContentTypeMime(httpContentType);
+        final String cTEnc = getContentTypeEncoding(httpContentType);
+        final String bomEnc = getBOMEncoding(pis);
+        final String xmlGuessEnc = getXMLGuessEncoding(pis);
+        final String xmlEnc = getXmlProlog(pis, xmlGuessEnc);
+        final String encoding = calculateHttpEncoding(cTMime, cTEnc, bomEnc,
+                xmlGuessEnc, xmlEnc, pis, lenient);
+        prepareReader(pis, encoding);
+    }
+
+    private void doLenientDetection(String httpContentType,
+            XmlStreamReaderException ex) throws IOException {
+        if (httpContentType != null && httpContentType.startsWith("text/html")) {
+            httpContentType = httpContentType.substring("text/html"
+                    .length());
+            httpContentType = "text/xml" + httpContentType;
+            try {
+                doHttpStream(ex.getInputStream(), httpContentType, true);
+                ex = null;
+            } catch (final XmlStreamReaderException ex2) {
+                ex = ex2;
+            }
+        }
+        if (ex != null) {
+            String encoding = ex.getXmlEncoding();
+            if (encoding == null) {
+                encoding = ex.getContentTypeEncoding();
+            }
+            if (encoding == null) {
+                encoding = defaultEncoding == null ? UTF_8 : defaultEncoding;
+            }
+            prepareReader(ex.getInputStream(), encoding);
+        }
+    }
+
+    private void doRawStream(final InputStream inputStream)
+            throws IOException {
+        final BufferedInputStream pis = new BufferedInputStream(inputStream, IOUtils.DEFAULT_BUFFER_SIZE);
+        final String bomEnc = getBOMEncoding(pis);
+        final String xmlGuessEnc = getXMLGuessEncoding(pis);
+        final String xmlEnc = getXmlProlog(pis, xmlGuessEnc);
+        final String encoding = calculateRawEncoding(bomEnc, xmlGuessEnc, xmlEnc, pis);
+        prepareReader(pis, encoding);
+    }
+
+    /**
+     * Returns the charset encoding of the XmlStreamReader.
+     *
+     * @return charset encoding.
+     */
+    public String getEncoding() {
+        return encoding;
+    }
+
+    private void prepareReader(final InputStream inputStream, final String encoding)
+            throws IOException {
+        reader = new InputStreamReader(inputStream, encoding);
+        this.encoding = encoding;
+    }
+
+    @Override
+    public int read(final char[] buf, final int offset, final int len) throws IOException {
+        return reader.read(buf, offset, len);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/input/compatibility/XmlStreamReaderException.java b/src/test/java/org/apache/commons/io/input/compatibility/XmlStreamReaderException.java
new file mode 100644
index 0000000..3d35bbb
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/compatibility/XmlStreamReaderException.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input.compatibility;
+
+import java.io.InputStream;
+
+/**
+ * The XmlStreamReaderException is thrown by the XmlStreamReader constructors if
+ * the charset encoding can not be determined according to the XML 1.0
+ * specification and RFC 3023.
+ * <p>
+ * The exception returns the unconsumed InputStream to allow the application to
+ * do an alternate processing with the stream. Note that the original
+ * InputStream given to the XmlStreamReader cannot be used as that one has been
+ * already read.
+ * </p>
+ *
+ */
+public class XmlStreamReaderException extends org.apache.commons.io.input.XmlStreamReaderException {
+
+    private static final long serialVersionUID = 1L;
+
+    private final InputStream inputStream;
+
+    /**
+     * Creates an exception instance if the charset encoding could not be
+     * determined.
+     * <p>
+     * Instances of this exception are thrown by the XmlStreamReader.
+     *
+     * @param msg message describing the reason for the exception.
+     * @param bomEnc BOM encoding.
+     * @param xmlGuessEnc XML guess encoding.
+     * @param xmlEnc XML prolog encoding.
+     * @param is the unconsumed InputStream.
+     */
+    public XmlStreamReaderException(final String msg, final String bomEnc,
+            final String xmlGuessEnc, final String xmlEnc, final InputStream is) {
+        this(msg, null, null, bomEnc, xmlGuessEnc, xmlEnc, is);
+    }
+
+    /**
+     * Creates an exception instance if the charset encoding could not be
+     * determined.
+     * <p>
+     * Instances of this exception are thrown by the XmlStreamReader.
+     *
+     * @param msg message describing the reason for the exception.
+     * @param ctMime MIME type in the content-type.
+     * @param ctEnc encoding in the content-type.
+     * @param bomEnc BOM encoding.
+     * @param xmlGuessEnc XML guess encoding.
+     * @param xmlEnc XML prolog encoding.
+     * @param inputStream the unconsumed InputStream.
+     */
+    public XmlStreamReaderException(final String msg, final String ctMime, final String ctEnc,
+            final String bomEnc, final String xmlGuessEnc, final String xmlEnc, final InputStream inputStream) {
+        super(msg, ctMime, ctEnc, bomEnc, xmlGuessEnc, xmlEnc);
+        this.inputStream = inputStream;
+    }
+
+    /**
+     * Returns the unconsumed InputStream to allow the application to do an
+     * alternate encoding detection on the InputStream.
+     *
+     * @return the unconsumed InputStream.
+     */
+    public InputStream getInputStream() {
+        return inputStream;
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/input/compatibility/XmlStreamReaderUtilitiesCompatibilityTest.java b/src/test/java/org/apache/commons/io/input/compatibility/XmlStreamReaderUtilitiesCompatibilityTest.java
new file mode 100644
index 0000000..1127de4
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/input/compatibility/XmlStreamReaderUtilitiesCompatibilityTest.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input.compatibility;
+
+import java.io.IOException;
+
+import org.apache.commons.io.input.StringInputStream;
+import org.apache.commons.io.input.XmlStreamReaderUtilitiesTest;
+
+/**
+ * Test compatibility of the original XmlStreamReader (before all the refactoring).
+ */
+public class XmlStreamReaderUtilitiesCompatibilityTest extends XmlStreamReaderUtilitiesTest {
+
+    /** Mock {@link XmlStreamReader} implementation */
+    private static class MockXmlStreamReader extends XmlStreamReader {
+        MockXmlStreamReader(final String defaultEncoding) throws IOException {
+            super(new StringInputStream(), null, true, defaultEncoding);
+        }
+    }
+    @Override
+    protected String calculateHttpEncoding(final String httpContentType, final String bomEnc, final String xmlGuessEnc,
+            final String xmlEnc, final boolean lenient, final String defaultEncoding) throws IOException {
+        try (MockXmlStreamReader mock = new MockXmlStreamReader(defaultEncoding)) {
+                return mock.calculateHttpEncoding(
+                        XmlStreamReader.getContentTypeMime(httpContentType),
+                        XmlStreamReader.getContentTypeEncoding(httpContentType),
+                        bomEnc, xmlGuessEnc, xmlEnc, null, lenient);
+        }
+    }
+
+    @Override
+    protected String calculateRawEncoding(final String bomEnc, final String xmlGuessEnc, final String xmlEnc,
+            final String defaultEncoding) throws IOException {
+        try (MockXmlStreamReader mock = new MockXmlStreamReader(defaultEncoding)) {
+            return mock.calculateRawEncoding(bomEnc, xmlGuessEnc, xmlEnc, null);
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/jmh/IOUtilsContentEqualsInputStreamsBenchmark.java b/src/test/java/org/apache/commons/io/jmh/IOUtilsContentEqualsInputStreamsBenchmark.java
new file mode 100644
index 0000000..6823e84
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/jmh/IOUtilsContentEqualsInputStreamsBenchmark.java
@@ -0,0 +1,237 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.jmh;
+
+import static org.apache.commons.io.IOUtils.DEFAULT_BUFFER_SIZE;
+import static org.apache.commons.io.IOUtils.EOF;
+import static org.apache.commons.io.IOUtils.buffer;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+
+/**
+ * Test different implementations of {@link IOUtils#contentEquals(InputStream, InputStream)}.
+ *
+ * <pre>
+ * IOUtilsContentEqualsInputStreamsBenchmark.testFileCurrent          avgt    5      1518342.821 ▒     201890.705  ns/op
+ * IOUtilsContentEqualsInputStreamsBenchmark.testFilePr118            avgt    5      1578606.938 ▒      66980.718  ns/op
+ * IOUtilsContentEqualsInputStreamsBenchmark.testFileRelease_2_8_0    avgt    5      2439163.068 ▒     265765.294  ns/op
+ * IOUtilsContentEqualsInputStreamsBenchmark.testStringCurrent        avgt    5  10389834700.000 ▒  330301175.219  ns/op
+ * IOUtilsContentEqualsInputStreamsBenchmark.testStringPr118          avgt    5  10890915400.000 ▒ 3251289634.067  ns/op
+ * IOUtilsContentEqualsInputStreamsBenchmark.testStringRelease_2_8_0  avgt    5  12522802960.000 ▒  111147669.527  ns/op
+ * </pre>
+ */
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@State(Scope.Thread)
+@Warmup(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS)
+@Fork(value = 1, jvmArgs = {"-server"})
+public class IOUtilsContentEqualsInputStreamsBenchmark {
+
+    private static final String TEST_PATH_A = "/org/apache/commons/io/testfileBOM.xml";
+    private static final String TEST_PATH_16K_A = "/org/apache/commons/io/abitmorethan16k.txt";
+    private static final String TEST_PATH_16K_A_COPY = "/org/apache/commons/io/abitmorethan16kcopy.txt";
+    private static final String TEST_PATH_B = "/org/apache/commons/io/testfileNoBOM.xml";
+    private static final Charset DEFAULT_CHARSET = Charset.defaultCharset();
+    static String[] STRINGS = new String[5];
+
+    static {
+        STRINGS[0] = StringUtils.repeat("ab", 1 << 24);
+        STRINGS[1] = STRINGS[0] + 'c';
+        STRINGS[2] = STRINGS[0] + 'd';
+        STRINGS[3] = StringUtils.repeat("ab\rab\n", 1 << 24);
+        STRINGS[4] = StringUtils.repeat("ab\r\nab\r", 1 << 24);
+    }
+
+    static String SPECIAL_CASE_STRING_0 = StringUtils.repeat(StringUtils.repeat("ab", 1 << 24) + '\n', 2);
+    static String SPECIAL_CASE_STRING_1 = StringUtils.repeat(StringUtils.repeat("cd", 1 << 24) + '\n', 2);
+
+    @SuppressWarnings("resource")
+    public static boolean contentEquals_release_2_8_0(final InputStream input1, final InputStream input2)
+        throws IOException {
+        if (input1 == input2) {
+            return true;
+        }
+        if (input1 == null ^ input2 == null) {
+            return false;
+        }
+        final BufferedInputStream bufferedInput1 = buffer(input1);
+        final BufferedInputStream bufferedInput2 = buffer(input2);
+        int ch = bufferedInput1.read();
+        while (EOF != ch) {
+            final int ch2 = bufferedInput2.read();
+            if (ch != ch2) {
+                return false;
+            }
+            ch = bufferedInput1.read();
+        }
+        return bufferedInput2.read() == EOF;
+
+    }
+
+    public static boolean contentEqualsPr118(final InputStream input1, final InputStream input2) throws IOException {
+        if (input1 == input2) {
+            return true;
+        }
+        if (input1 == null || input2 == null) {
+            return false;
+        }
+
+        final byte[] array1 = new byte[DEFAULT_BUFFER_SIZE];
+        final byte[] array2 = new byte[DEFAULT_BUFFER_SIZE];
+        int pos1;
+        int pos2;
+        int count1;
+        int count2;
+        while (true) {
+            pos1 = 0;
+            pos2 = 0;
+            for (int index = 0; index < DEFAULT_BUFFER_SIZE; index++) {
+                if (pos1 == index) {
+                    do {
+                        count1 = input1.read(array1, pos1, DEFAULT_BUFFER_SIZE - pos1);
+                    } while (count1 == 0);
+                    if (count1 == EOF) {
+                        return pos2 == index && input2.read() == EOF;
+                    }
+                    pos1 += count1;
+                }
+                if (pos2 == index) {
+                    do {
+                        count2 = input2.read(array2, pos2, DEFAULT_BUFFER_SIZE - pos2);
+                    } while (count2 == 0);
+                    if (count2 == EOF) {
+                        return pos1 == index && input1.read() == EOF;
+                    }
+                    pos2 += count2;
+                }
+                if (array1[index] != array2[index]) {
+                    return false;
+                }
+            }
+        }
+    }
+
+    @Benchmark
+    public boolean[] testFileCurrent() throws IOException {
+        final boolean[] res = new boolean[3];
+        try (InputStream input1 = getClass().getResourceAsStream(TEST_PATH_A);
+            InputStream input2 = getClass().getResourceAsStream(TEST_PATH_B)) {
+            res[0] = IOUtils.contentEquals(input1, input1);
+        }
+        try (InputStream input1 = getClass().getResourceAsStream(TEST_PATH_A);
+            InputStream input2 = getClass().getResourceAsStream(TEST_PATH_A);) {
+            res[1] = IOUtils.contentEquals(input1, input2);
+        }
+        try (InputStream input1 = getClass().getResourceAsStream(TEST_PATH_16K_A);
+            InputStream input2 = getClass().getResourceAsStream(TEST_PATH_16K_A_COPY);) {
+            res[2] = IOUtils.contentEquals(input1, input2);
+        }
+        return res;
+    }
+
+    @Benchmark
+    public boolean[] testFilePr118() throws IOException {
+        final boolean[] res = new boolean[3];
+        try (InputStream input1 = getClass().getResourceAsStream(TEST_PATH_A);
+            InputStream input2 = getClass().getResourceAsStream(TEST_PATH_B)) {
+            res[0] = contentEqualsPr118(input1, input1);
+        }
+        try (InputStream input1 = getClass().getResourceAsStream(TEST_PATH_A);
+            InputStream input2 = getClass().getResourceAsStream(TEST_PATH_A)) {
+            res[1] = contentEqualsPr118(input1, input2);
+        }
+        try (InputStream input1 = getClass().getResourceAsStream(TEST_PATH_16K_A);
+            InputStream input2 = getClass().getResourceAsStream(TEST_PATH_16K_A_COPY)) {
+            res[2] = contentEqualsPr118(input1, input2);
+        }
+        return res;
+    }
+
+    @Benchmark
+    public boolean[] testFileRelease_2_8_0() throws IOException {
+        final boolean[] res = new boolean[3];
+        try (InputStream input1 = getClass().getResourceAsStream(TEST_PATH_A);
+            InputStream input2 = getClass().getResourceAsStream(TEST_PATH_B)) {
+            res[0] = contentEquals_release_2_8_0(input1, input1);
+        }
+        try (InputStream input1 = getClass().getResourceAsStream(TEST_PATH_A);
+            InputStream input2 = getClass().getResourceAsStream(TEST_PATH_A);) {
+            res[1] = contentEquals_release_2_8_0(input1, input2);
+        }
+        try (InputStream input1 = getClass().getResourceAsStream(TEST_PATH_16K_A);
+            InputStream input2 = getClass().getResourceAsStream(TEST_PATH_16K_A_COPY)) {
+            res[2] = contentEquals_release_2_8_0(input1, input2);
+        }
+        return res;
+    }
+
+    @Benchmark
+    public void testStringCurrent(final Blackhole blackhole) throws IOException {
+        for (int i = 0; i < 5; i++) {
+            for (int j = 0; j < 5; j++) {
+                try (InputStream inputReader1 = IOUtils.toInputStream(STRINGS[i], DEFAULT_CHARSET);
+                    InputStream inputReader2 = IOUtils.toInputStream(STRINGS[j], DEFAULT_CHARSET)) {
+                    blackhole.consume(IOUtils.contentEquals(inputReader1, inputReader2));
+                }
+            }
+        }
+    }
+
+    @Benchmark
+    public void testStringPr118(final Blackhole blackhole) throws IOException {
+        for (int i = 0; i < 5; i++) {
+            for (int j = 0; j < 5; j++) {
+                try (InputStream input1 = IOUtils.toInputStream(STRINGS[i], DEFAULT_CHARSET);
+                    InputStream input2 = IOUtils.toInputStream(STRINGS[j], DEFAULT_CHARSET)) {
+                    blackhole.consume(contentEqualsPr118(input1, input2));
+                }
+            }
+        }
+    }
+
+    @Benchmark
+    public void testStringRelease_2_8_0(final Blackhole blackhole) throws IOException {
+        for (int i = 0; i < 5; i++) {
+            for (int j = 0; j < 5; j++) {
+                try (InputStream input1 = IOUtils.toInputStream(STRINGS[i], DEFAULT_CHARSET);
+                    InputStream input2 = IOUtils.toInputStream(STRINGS[j], DEFAULT_CHARSET)) {
+                    blackhole.consume(contentEquals_release_2_8_0(input1, input2));
+                }
+            }
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/jmh/IOUtilsContentEqualsReadersBenchmark.java b/src/test/java/org/apache/commons/io/jmh/IOUtilsContentEqualsReadersBenchmark.java
new file mode 100644
index 0000000..1d36550
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/jmh/IOUtilsContentEqualsReadersBenchmark.java
@@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.jmh;
+
+import static org.apache.commons.io.IOUtils.DEFAULT_BUFFER_SIZE;
+import static org.apache.commons.io.IOUtils.EOF;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.StringReader;
+import java.nio.charset.Charset;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+
+/**
+ * Test different implementations of {@link IOUtils#contentEquals(Reader, Reader)}.
+ *
+ * <pre>
+ * IOUtilsContentEqualsReadersBenchmark.testFileCurrent               avgt    5      1670968.050 ▒     67526.308  ns/op
+ * IOUtilsContentEqualsReadersBenchmark.testFilePr118                 avgt    5      1660143.543 ▒    733178.893  ns/op
+ * IOUtilsContentEqualsReadersBenchmark.testFileRelease_2_8_0         avgt    5      1785283.975 ▒    214177.764  ns/op
+ * IOUtilsContentEqualsReadersBenchmark.testStringCurrent             avgt    5   1144495273.333 ▒  50706166.907  ns/op
+ * IOUtilsContentEqualsReadersBenchmark.testStringPr118               avgt    5   1075059231.455 ▒ 275364676.487  ns/op
+ * IOUtilsContentEqualsReadersBenchmark.testStringRelease_2_8_0       avgt    5   4767157193.333 ▒ 139567775.251  ns/op
+ * </pre>
+ */
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@State(Scope.Thread)
+@Warmup(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS)
+@Fork(value = 1, jvmArgs = {"-server"})
+public class IOUtilsContentEqualsReadersBenchmark {
+
+    private static final int STRING_LEN = 1 << 24;
+    private static final String TEST_PATH_A = "/org/apache/commons/io/testfileBOM.xml";
+    private static final String TEST_PATH_16K_A = "/org/apache/commons/io/abitmorethan16k.txt";
+    private static final String TEST_PATH_16K_A_COPY = "/org/apache/commons/io/abitmorethan16kcopy.txt";
+    private static final String TEST_PATH_B = "/org/apache/commons/io/testfileNoBOM.xml";
+    private static final Charset DEFAULT_CHARSET = Charset.defaultCharset();
+    static String[] STRINGS = new String[5];
+
+    static {
+        STRINGS[0] = StringUtils.repeat("ab", STRING_LEN);
+        STRINGS[1] = STRINGS[0] + 'c';
+        STRINGS[2] = STRINGS[0] + 'd';
+        STRINGS[3] = StringUtils.repeat("ab\rab\n", STRING_LEN);
+        STRINGS[4] = StringUtils.repeat("ab\r\nab\r", STRING_LEN);
+    }
+
+    static String SPECIAL_CASE_STRING_0 = StringUtils.repeat(StringUtils.repeat("ab", STRING_LEN) + '\n', 2);
+    static String SPECIAL_CASE_STRING_1 = StringUtils.repeat(StringUtils.repeat("cd", STRING_LEN) + '\n', 2);
+
+    @SuppressWarnings("resource")
+    public static boolean contentEquals_release_2_8_0(final Reader input1, final Reader input2) throws IOException {
+        if (input1 == input2) {
+            return true;
+        }
+        if (input1 == null ^ input2 == null) {
+            return false;
+        }
+        final BufferedReader bufferedInput1 = IOUtils.toBufferedReader(input1);
+        final BufferedReader bufferedInput2 = IOUtils.toBufferedReader(input2);
+
+        int ch = bufferedInput1.read();
+        while (EOF != ch) {
+            final int ch2 = bufferedInput2.read();
+            if (ch != ch2) {
+                return false;
+            }
+            ch = bufferedInput1.read();
+        }
+
+        return bufferedInput2.read() == EOF;
+    }
+
+    public static boolean contentEqualsPr118(final Reader input1, final Reader input2) throws IOException {
+        if (input1 == input2) {
+            return true;
+        }
+        if (input1 == null || input2 == null) {
+            return false;
+        }
+
+        final char[] array1 = new char[DEFAULT_BUFFER_SIZE];
+        final char[] array2 = new char[DEFAULT_BUFFER_SIZE];
+        int pos1;
+        int pos2;
+        int count1;
+        int count2;
+        while (true) {
+            pos1 = 0;
+            pos2 = 0;
+            for (int index = 0; index < DEFAULT_BUFFER_SIZE; index++) {
+                if (pos1 == index) {
+                    do {
+                        count1 = input1.read(array1, pos1, DEFAULT_BUFFER_SIZE - pos1);
+                    } while (count1 == 0);
+                    if (count1 == EOF) {
+                        return pos2 == index && input2.read() == EOF;
+                    }
+                    pos1 += count1;
+                }
+                if (pos2 == index) {
+                    do {
+                        count2 = input2.read(array2, pos2, DEFAULT_BUFFER_SIZE - pos2);
+                    } while (count2 == 0);
+                    if (count2 == EOF) {
+                        return pos1 == index && input1.read() == EOF;
+                    }
+                    pos2 += count2;
+                }
+                if (array1[index] != array2[index]) {
+                    return false;
+                }
+            }
+        }
+    }
+
+    @Benchmark
+    public boolean[] testFileCurrent() throws IOException {
+        final boolean[] res = new boolean[3];
+        try (Reader input1 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_A), DEFAULT_CHARSET);
+            Reader input2 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_B), DEFAULT_CHARSET)) {
+            res[0] = IOUtils.contentEquals(input1, input1);
+        }
+        try (Reader input1 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_A), DEFAULT_CHARSET);
+            Reader input2 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_A), DEFAULT_CHARSET)) {
+            res[1] = IOUtils.contentEquals(input1, input2);
+        }
+        try (Reader input1 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_16K_A), DEFAULT_CHARSET);
+            Reader input2 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_16K_A_COPY),
+                DEFAULT_CHARSET)) {
+            res[2] = IOUtils.contentEquals(input1, input2);
+        }
+        return res;
+    }
+
+    @Benchmark
+    public boolean[] testFilePr118() throws IOException {
+        final boolean[] res = new boolean[3];
+        try (Reader input1 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_A), DEFAULT_CHARSET);
+            Reader input2 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_B), DEFAULT_CHARSET)) {
+            res[0] = contentEqualsPr118(input1, input1);
+        }
+        try (Reader input1 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_A), DEFAULT_CHARSET);
+            Reader input2 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_A), DEFAULT_CHARSET)) {
+            res[1] = contentEqualsPr118(input1, input2);
+        }
+        try (Reader input1 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_16K_A));
+            Reader input2 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_16K_A_COPY))) {
+            res[2] = contentEqualsPr118(input1, input2);
+        }
+        return res;
+    }
+
+    @Benchmark
+    public boolean[] testFileRelease_2_8_0() throws IOException {
+        final boolean[] res = new boolean[3];
+        try (Reader input1 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_A), DEFAULT_CHARSET);
+            Reader input2 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_B), DEFAULT_CHARSET)) {
+            res[0] = contentEquals_release_2_8_0(input1, input1);
+        }
+        try (Reader input1 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_A), DEFAULT_CHARSET);
+            Reader input2 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_A), DEFAULT_CHARSET)) {
+            res[1] = contentEquals_release_2_8_0(input1, input2);
+        }
+        try (Reader input1 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_16K_A), DEFAULT_CHARSET);
+            Reader input2 = new InputStreamReader(getClass().getResourceAsStream(TEST_PATH_16K_A_COPY),
+                DEFAULT_CHARSET)) {
+            res[2] = contentEquals_release_2_8_0(input1, input2);
+        }
+        return res;
+    }
+
+    @Benchmark
+    public void testStringCurrent(final Blackhole blackhole) throws IOException {
+        for (int i = 0; i < 5; i++) {
+            for (int j = 0; j < 5; j++) {
+                try (StringReader input1 = new StringReader(STRINGS[i]);
+                    StringReader input2 = new StringReader(STRINGS[j])) {
+                    blackhole.consume(IOUtils.contentEquals(input1, input2));
+                }
+            }
+        }
+    }
+
+    @Benchmark
+    public void testStringPr118(final Blackhole blackhole) throws IOException {
+        for (int i = 0; i < 5; i++) {
+            for (int j = 0; j < 5; j++) {
+                try (StringReader input1 = new StringReader(STRINGS[i]);
+                    StringReader input2 = new StringReader(STRINGS[j])) {
+                    blackhole.consume(contentEqualsPr118(input1, input2));
+                }
+            }
+        }
+    }
+
+    @Benchmark
+    public void testStringRelease_2_8_0(final Blackhole blackhole) throws IOException {
+        for (int i = 0; i < 5; i++) {
+            for (int j = 0; j < 5; j++) {
+                try (StringReader input1 = new StringReader(STRINGS[i]);
+                    StringReader input2 = new StringReader(STRINGS[j])) {
+                    blackhole.consume(contentEquals_release_2_8_0(input1, input2));
+                }
+            }
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/monitor/AbstractMonitorTest.java b/src/test/java/org/apache/commons/io/monitor/AbstractMonitorTest.java
new file mode 100644
index 0000000..6f076d0
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/monitor/AbstractMonitorTest.java
@@ -0,0 +1,152 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.monitor;
+
+import static org.apache.commons.io.test.TestUtils.sleepQuietly;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.IOException;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.filefilter.FileFilterUtils;
+import org.apache.commons.io.filefilter.HiddenFileFilter;
+import org.apache.commons.io.filefilter.IOFileFilter;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * {@link FileAlterationObserver} Test Case.
+ */
+public abstract class AbstractMonitorTest {
+
+    /** File observer */
+    protected FileAlterationObserver observer;
+
+    /** Listener which collects file changes */
+    protected CollectionFileListener listener;
+
+    /** Directory for test files */
+    @TempDir
+    protected File testDir;
+
+    /** Time in milliseconds to pause in tests */
+    protected final long pauseTime = 100L;
+
+    /**
+     * Check all the Collections are empty
+     *
+     * @param label the label to use for this check
+     */
+    protected void checkCollectionsEmpty(final String label) {
+        checkCollectionSizes("EMPTY-" + label, 0, 0, 0, 0, 0, 0);
+    }
+
+    /**
+     * Check all the Collections have the expected sizes.
+     *
+     * @param label the label to use for this check
+     * @param dirCreate expected number of dirs created
+     * @param dirChange expected number of dirs changed
+     * @param dirDelete expected number of dirs deleted
+     * @param fileCreate expected number of files created
+     * @param fileChange expected number of files changed
+     * @param fileDelete expected number of files deleted
+     */
+    protected void checkCollectionSizes(String label,
+                                        final int dirCreate,
+                                        final int dirChange,
+                                        final int dirDelete,
+                                        final int fileCreate,
+                                        final int fileChange,
+                                        final int fileDelete) {
+        label = label + "[" + listener.getCreatedDirectories().size() +
+                        " " + listener.getChangedDirectories().size() +
+                        " " + listener.getDeletedDirectories().size() +
+                        " " + listener.getCreatedFiles().size() +
+                        " " + listener.getChangedFiles().size() +
+                        " " + listener.getDeletedFiles().size() + "]";
+        assertEquals(dirCreate, listener.getCreatedDirectories().size(), label + ": No. of directories created");
+        assertEquals(dirChange, listener.getChangedDirectories().size(), label + ": No. of directories changed");
+        assertEquals(dirDelete, listener.getDeletedDirectories().size(), label + ": No. of directories deleted");
+        assertEquals(fileCreate, listener.getCreatedFiles().size(), label + ": No. of files created");
+        assertEquals(fileChange, listener.getChangedFiles().size(), label + ": No. of files changed");
+        assertEquals(fileDelete, listener.getDeletedFiles().size(), label + ": No. of files deleted");
+    }
+
+    /**
+     * Create a {@link FileAlterationObserver}.
+     *
+     * @param file The directory to observe
+     * @param fileFilter The file filter to apply
+     */
+    protected void createObserver(final File file, final FileFilter fileFilter) {
+        observer = new FileAlterationObserver(file, fileFilter);
+        observer.addListener(listener);
+        observer.addListener(new FileAlterationListenerAdaptor());
+        try {
+            observer.initialize();
+        } catch (final Exception e) {
+            fail("Observer init() threw " + e);
+        }
+    }
+
+    @BeforeEach
+    public void setUp() {
+        final IOFileFilter files = FileFilterUtils.fileFileFilter();
+        final IOFileFilter javaSuffix = FileFilterUtils.suffixFileFilter(".java");
+        final IOFileFilter fileFilter = FileFilterUtils.and(files, javaSuffix);
+
+        final IOFileFilter directories = FileFilterUtils.directoryFileFilter();
+        final IOFileFilter visible = HiddenFileFilter.VISIBLE;
+        final IOFileFilter dirFilter = FileFilterUtils.and(directories, visible);
+
+        final IOFileFilter filter = FileFilterUtils.or(dirFilter, fileFilter);
+
+        createObserver(testDir, filter);
+    }
+
+    /**
+     * Either creates a file if it doesn't exist or updates the last modified date/time
+     * if it does.
+     *
+     * @param file The file to touch
+     * @return The file
+     * @throws IOException if an I/O error occurs.
+     */
+    protected File touch(File file) throws IOException {
+        final long lastModified = file.exists() ? FileUtils.lastModified(file) : 0;
+        try {
+            FileUtils.touch(file);
+            assertTrue(file.exists());
+            file = new File(file.getParent(), file.getName());
+            while (lastModified == FileUtils.lastModified(file)) {
+                sleepQuietly(pauseTime);
+                FileUtils.touch(file);
+                file = new File(file.getParent(), file.getName());
+            }
+        } catch (final Exception e) {
+            fail("Touching " + file + ": " + e);
+        }
+        sleepQuietly(pauseTime);
+        return file;
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/monitor/CollectionFileListener.java b/src/test/java/org/apache/commons/io/monitor/CollectionFileListener.java
new file mode 100644
index 0000000..8192226
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/monitor/CollectionFileListener.java
@@ -0,0 +1,196 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.monitor;
+
+import java.io.File;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collection;
+
+/**
+ * {@link FileAlterationListener} implementation that adds created, changed and deleted
+ * files/directories to a set of {@link Collection}s.
+ */
+public class CollectionFileListener implements FileAlterationListener, Serializable {
+
+    private static final long serialVersionUID = 939724715678693963L;
+    private final boolean clearOnStart;
+    private final Collection<File> createdFiles = new ArrayList<>();
+    private final Collection<File> changedFiles = new ArrayList<>();
+    private final Collection<File> deletedFiles = new ArrayList<>();
+    private final Collection<File> createdDirectories = new ArrayList<>();
+    private final Collection<File> changedDirectories = new ArrayList<>();
+    private final Collection<File> deletedDirectories = new ArrayList<>();
+
+    /**
+     * Create a new observer.
+     *
+     * @param clearOnStart true if clear() should be called by onStart().
+     */
+    public CollectionFileListener(final boolean clearOnStart) {
+        this.clearOnStart = clearOnStart;
+    }
+
+    /**
+     * Clear file collections.
+     */
+    public void clear() {
+        createdFiles.clear();
+        changedFiles.clear();
+        deletedFiles.clear();
+        createdDirectories.clear();
+        changedDirectories.clear();
+        deletedDirectories.clear();
+    }
+
+    /**
+     * Return the set of changed directories.
+     *
+     * @return Directories which have changed
+     */
+    public Collection<File> getChangedDirectories() {
+        return changedDirectories;
+    }
+
+    /**
+     * Return the set of changed files.
+     *
+     * @return Files which have changed
+     */
+    public Collection<File> getChangedFiles() {
+        return changedFiles;
+    }
+
+    /**
+     * Return the set of created directories.
+     *
+     * @return Directories which have been created
+     */
+    public Collection<File> getCreatedDirectories() {
+        return createdDirectories;
+    }
+
+    /**
+     * Return the set of created files.
+     *
+     * @return Files which have been created
+     */
+    public Collection<File> getCreatedFiles() {
+        return createdFiles;
+    }
+
+    /**
+     * Return the set of deleted directories.
+     *
+     * @return Directories which been deleted
+     */
+    public Collection<File> getDeletedDirectories() {
+        return deletedDirectories;
+    }
+
+    /**
+     * Return the set of deleted files.
+     *
+     * @return Files which been deleted
+     */
+    public Collection<File> getDeletedFiles() {
+        return deletedFiles;
+    }
+
+    /**
+     * Directory changed Event.
+     *
+     * @param directory The directory changed
+     */
+    @Override
+    public void onDirectoryChange(final File directory) {
+        changedDirectories.add(directory);
+    }
+
+    /**
+     * Directory created Event.
+     *
+     * @param directory The directory created
+     */
+    @Override
+    public void onDirectoryCreate(final File directory) {
+        createdDirectories.add(directory);
+    }
+
+    /**
+     * Directory deleted Event.
+     *
+     * @param directory The directory deleted
+     */
+    @Override
+    public void onDirectoryDelete(final File directory) {
+        deletedDirectories.add(directory);
+    }
+
+    /**
+     * File changed Event.
+     *
+     * @param file The file changed
+     */
+    @Override
+    public void onFileChange(final File file) {
+        changedFiles.add(file);
+    }
+
+    /**
+     * File created Event.
+     *
+     * @param file The file created
+     */
+    @Override
+    public void onFileCreate(final File file) {
+        createdFiles.add(file);
+    }
+
+    /**
+     * File deleted Event.
+     *
+     * @param file The file deleted
+     */
+    @Override
+    public void onFileDelete(final File file) {
+        deletedFiles.add(file);
+    }
+
+    /**
+     * File system observer started checking event.
+     *
+     * @param observer The file system observer
+     */
+    @Override
+    public void onStart(final FileAlterationObserver observer) {
+        if (clearOnStart) {
+            clear();
+        }
+    }
+
+    /**
+     * File system observer finished checking event.
+     *
+     * @param observer The file system observer
+     */
+    @Override
+    public void onStop(final FileAlterationObserver observer) {
+        // noop
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/monitor/FileAlterationMonitorTest.java b/src/test/java/org/apache/commons/io/monitor/FileAlterationMonitorTest.java
new file mode 100644
index 0000000..9399bf1
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/monitor/FileAlterationMonitorTest.java
@@ -0,0 +1,231 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.monitor;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.File;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+
+import org.apache.commons.io.ThreadUtils;
+import org.apache.commons.io.test.TestUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * {@link FileAlterationMonitor} Test Case.
+ */
+public class FileAlterationMonitorTest extends AbstractMonitorTest {
+
+    /**
+     * Construct a new test case.
+     *
+     */
+    public FileAlterationMonitorTest() {
+        listener = new CollectionFileListener(false);
+    }
+
+    /**
+     * Check all the File Collections have the expected sizes.
+     */
+    private void checkFile(final String label, final File file, final Collection<File> files) {
+        for (int i = 0; i < 20; i++) {
+            if (files.contains(file)) {
+                return; // found, test passes
+            }
+            TestUtils.sleepQuietly(pauseTime);
+        }
+        fail(label + " " + file + " not found");
+    }
+
+    /**
+     * Test add/remove observers.
+     */
+    @Test
+    public void testAddRemoveObservers() {
+        FileAlterationObserver[] observers = null;
+
+        // Null Observers
+        FileAlterationMonitor monitor = new FileAlterationMonitor(123, observers);
+        assertEquals(123, monitor.getInterval(), "Interval");
+        assertFalse(monitor.getObservers().iterator().hasNext(), "Observers[1]");
+
+        // Null Observer
+        observers = new FileAlterationObserver[1]; // observer is null
+        monitor = new FileAlterationMonitor(456, observers);
+        assertFalse(monitor.getObservers().iterator().hasNext(), "Observers[2]");
+
+        // Null Observer
+        monitor.addObserver(null);
+        assertFalse(monitor.getObservers().iterator().hasNext(), "Observers[3]");
+        monitor.removeObserver(null);
+
+        // Add Observer
+        final FileAlterationObserver observer = new FileAlterationObserver("foo");
+        monitor.addObserver(observer);
+        final Iterator<FileAlterationObserver> it = monitor.getObservers().iterator();
+        assertTrue(it.hasNext(), "Observers[4]");
+        assertEquals(observer, it.next(), "Added");
+        assertFalse(it.hasNext(), "Observers[5]");
+
+        // Remove Observer
+        monitor.removeObserver(observer);
+        assertFalse(monitor.getObservers().iterator().hasNext(), "Observers[6]");
+    }
+
+    @Test
+    public void testCollectionConstructor() {
+        observer = new FileAlterationObserver("foo");
+        final Collection<FileAlterationObserver> observers = Arrays.asList(observer);
+        final FileAlterationMonitor monitor = new FileAlterationMonitor(0, observers);
+        final Iterator<FileAlterationObserver> iterator = monitor.getObservers().iterator();
+        assertEquals(observer, iterator.next());
+    }
+
+    @Test
+    public void testCollectionConstructorShouldDoNothingWithNullCollection() {
+        final Collection<FileAlterationObserver> observers = null;
+        final FileAlterationMonitor monitor = new FileAlterationMonitor(0, observers);
+        assertFalse(monitor.getObservers().iterator().hasNext());
+    }
+
+    @Test
+    public void testCollectionConstructorShouldDoNothingWithNullObservers() {
+        final Collection<FileAlterationObserver> observers = new ArrayList<>(5);
+        final FileAlterationMonitor monitor = new FileAlterationMonitor(0, observers);
+        assertFalse(monitor.getObservers().iterator().hasNext());
+    }
+
+    /**
+     * Test default constructor.
+     */
+    @Test
+    public void testDefaultConstructor() {
+        final FileAlterationMonitor monitor = new FileAlterationMonitor();
+        assertEquals(10000, monitor.getInterval(), "Interval");
+    }
+
+    /**
+     * Test checkAndNotify() method
+     * @throws Exception
+     */
+    @Test
+    public void testMonitor() throws Exception {
+        final long interval = 100;
+        listener.clear();
+        final FileAlterationMonitor monitor = new FileAlterationMonitor(interval, observer);
+        assertEquals(interval, monitor.getInterval(), "Interval");
+        monitor.start();
+
+        // try and start again
+        assertThrows(IllegalStateException.class, () -> monitor.start());
+
+        // Create a File
+        checkCollectionsEmpty("A");
+        File file1 = touch(new File(testDir, "file1.java"));
+        checkFile("Create", file1, listener.getCreatedFiles());
+        listener.clear();
+
+        // Update a file
+        checkCollectionsEmpty("B");
+        file1 = touch(file1);
+        checkFile("Update", file1, listener.getChangedFiles());
+        listener.clear();
+
+        // Delete a file
+        checkCollectionsEmpty("C");
+        file1.delete();
+        checkFile("Delete", file1, listener.getDeletedFiles());
+        listener.clear();
+
+        // Stop monitoring
+        monitor.stop();
+
+        // try and stop again
+        assertThrows(IllegalStateException.class, () -> monitor.stop());
+    }
+
+    /**
+     * Test case for IO-535
+     *
+     * Verify that {@link FileAlterationMonitor#stop()} stops the created thread
+     */
+    @Test
+    public void testStopWhileWaitingForNextInterval() throws Exception {
+        final Collection<Thread> createdThreads = new ArrayList<>(1);
+        final ThreadFactory threadFactory = new ThreadFactory() {
+            private final ThreadFactory delegate = Executors.defaultThreadFactory();
+
+            @Override
+            public Thread newThread(final Runnable r) {
+                final Thread thread = delegate.newThread(r);
+                thread.setDaemon(true); //do not leak threads if the test fails
+                createdThreads.add(thread);
+                return thread;
+            }
+        };
+
+        final FileAlterationMonitor monitor = new FileAlterationMonitor(1_000);
+        monitor.setThreadFactory(threadFactory);
+
+        monitor.start();
+        assertFalse(createdThreads.isEmpty());
+
+        ThreadUtils.sleep(Duration.ofMillis(10)); // wait until the watcher thread enters Thread.sleep()
+        monitor.stop(100);
+
+        createdThreads.forEach(thread -> assertFalse(thread.isAlive(), "The FileAlterationMonitor did not stop the threads it created."));
+    }
+
+    /**
+     * Test using a thread factory.
+     * @throws Exception
+     */
+    @Test
+    public void testThreadFactory() throws Exception {
+        final long interval = 100;
+        listener.clear();
+        final FileAlterationMonitor monitor = new FileAlterationMonitor(interval, observer);
+        monitor.setThreadFactory(Executors.defaultThreadFactory());
+        assertEquals(interval, monitor.getInterval(), "Interval");
+        monitor.start();
+
+        // Create a File
+        checkCollectionsEmpty("A");
+        final File file2 = touch(new File(testDir, "file2.java"));
+        checkFile("Create", file2, listener.getCreatedFiles());
+        listener.clear();
+
+        // Delete a file
+        checkCollectionsEmpty("B");
+        file2.delete();
+        checkFile("Delete", file2, listener.getDeletedFiles());
+        listener.clear();
+
+        // Stop monitoring
+        monitor.stop();
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/monitor/FileAlterationObserverTest.java b/src/test/java/org/apache/commons/io/monitor/FileAlterationObserverTest.java
new file mode 100644
index 0000000..5edc6bb
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/monitor/FileAlterationObserverTest.java
@@ -0,0 +1,384 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.monitor;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.IOException;
+import java.util.Iterator;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.filefilter.CanReadFileFilter;
+import org.apache.commons.io.filefilter.FileFilterUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * {@link FileAlterationObserver} Test Case.
+ */
+public class FileAlterationObserverTest extends AbstractMonitorTest {
+
+    /**
+     * Construct a new test case.
+     *
+     */
+    public FileAlterationObserverTest() {
+        listener = new CollectionFileListener(true);
+    }
+
+    /**
+     * Call {@link FileAlterationObserver#checkAndNotify()}.
+     */
+    protected void checkAndNotify() {
+        observer.checkAndNotify();
+    }
+
+    /**
+     * Test add/remove listeners.
+     */
+    @Test
+    public void testAddRemoveListeners() {
+        final FileAlterationObserver observer = new FileAlterationObserver("/foo");
+        // Null Listener
+        observer.addListener(null);
+        assertFalse(observer.getListeners().iterator().hasNext(), "Listeners[1]");
+        observer.removeListener(null);
+        assertFalse(observer.getListeners().iterator().hasNext(), "Listeners[2]");
+
+        // Add Listener
+        final FileAlterationListenerAdaptor listener = new FileAlterationListenerAdaptor();
+        observer.addListener(listener);
+        final Iterator<FileAlterationListener> it = observer.getListeners().iterator();
+        assertTrue(it.hasNext(), "Listeners[3]");
+        assertEquals(listener, it.next(), "Added");
+        assertFalse(it.hasNext(), "Listeners[4]");
+
+        // Remove Listener
+        observer.removeListener(listener);
+        assertFalse(observer.getListeners().iterator().hasNext(), "Listeners[5]");
+    }
+
+    /**
+     * Test checkAndNotify() method
+     * @throws Exception
+     */
+    @Test
+    public void testDirectory() throws Exception {
+        checkAndNotify();
+        checkCollectionsEmpty("A");
+        final File testDirA = new File(testDir, "test-dir-A");
+        final File testDirB = new File(testDir, "test-dir-B");
+        final File testDirC = new File(testDir, "test-dir-C");
+        testDirA.mkdir();
+        testDirB.mkdir();
+        testDirC.mkdir();
+        final File testDirAFile1 = touch(new File(testDirA, "A-file1.java"));
+        final File testDirAFile2 = touch(new File(testDirA, "A-file2.txt")); // filter should ignore this
+        final File testDirAFile3 = touch(new File(testDirA, "A-file3.java"));
+        File testDirAFile4 = touch(new File(testDirA, "A-file4.java"));
+        final File testDirBFile1 = touch(new File(testDirB, "B-file1.java"));
+
+        checkAndNotify();
+        checkCollectionSizes("B", 3, 0, 0, 4, 0, 0);
+        assertTrue(listener.getCreatedDirectories().contains(testDirA), "B testDirA");
+        assertTrue(listener.getCreatedDirectories().contains(testDirB), "B testDirB");
+        assertTrue(listener.getCreatedDirectories().contains(testDirC), "B testDirC");
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile1), "B testDirAFile1");
+        assertFalse(listener.getCreatedFiles().contains(testDirAFile2), "B testDirAFile2");
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile3), "B testDirAFile3");
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile4), "B testDirAFile4");
+        assertTrue(listener.getCreatedFiles().contains(testDirBFile1), "B testDirBFile1");
+
+        checkAndNotify();
+        checkCollectionsEmpty("C");
+
+        testDirAFile4 = touch(testDirAFile4);
+        FileUtils.deleteDirectory(testDirB);
+        checkAndNotify();
+        checkCollectionSizes("D", 0, 0, 1, 0, 1, 1);
+        assertTrue(listener.getDeletedDirectories().contains(testDirB), "D testDirB");
+        assertTrue(listener.getChangedFiles().contains(testDirAFile4), "D testDirAFile4");
+        assertTrue(listener.getDeletedFiles().contains(testDirBFile1), "D testDirBFile1");
+
+        FileUtils.deleteDirectory(testDir);
+        checkAndNotify();
+        checkCollectionSizes("E", 0, 0, 2, 0, 0, 3);
+        assertTrue(listener.getDeletedDirectories().contains(testDirA), "E testDirA");
+        assertTrue(listener.getDeletedFiles().contains(testDirAFile1), "E testDirAFile1");
+        assertFalse(listener.getDeletedFiles().contains(testDirAFile2), "E testDirAFile2");
+        assertTrue(listener.getDeletedFiles().contains(testDirAFile3), "E testDirAFile3");
+        assertTrue(listener.getDeletedFiles().contains(testDirAFile4), "E testDirAFile4");
+
+        testDir.mkdir();
+        checkAndNotify();
+        checkCollectionsEmpty("F");
+
+        checkAndNotify();
+        checkCollectionsEmpty("G");
+    }
+
+    /**
+     * Test checkAndNotify() creating
+     * @throws IOException if an I/O error occurs.
+     */
+    @Test
+    public void testFileCreate() throws IOException {
+        checkAndNotify();
+        checkCollectionsEmpty("A");
+        File testDirA = new File(testDir, "test-dir-A");
+        testDirA.mkdir();
+        testDir  = touch(testDir);
+        testDirA = touch(testDirA);
+        File testDirAFile1 =       new File(testDirA, "A-file1.java");
+        final File testDirAFile2 = touch(new File(testDirA, "A-file2.java"));
+        File testDirAFile3 =       new File(testDirA, "A-file3.java");
+        final File testDirAFile4 = touch(new File(testDirA, "A-file4.java"));
+        File testDirAFile5 =       new File(testDirA, "A-file5.java");
+
+        checkAndNotify();
+        checkCollectionSizes("B", 1, 0, 0, 2, 0, 0);
+        assertFalse(listener.getCreatedFiles().contains(testDirAFile1), "B testDirAFile1");
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile2), "B testDirAFile2");
+        assertFalse(listener.getCreatedFiles().contains(testDirAFile3), "B testDirAFile3");
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile4), "B testDirAFile4");
+        assertFalse(listener.getCreatedFiles().contains(testDirAFile5), "B testDirAFile5");
+
+        assertFalse(testDirAFile1.exists(), "B testDirAFile1 exists");
+        assertTrue(testDirAFile2.exists(), "B testDirAFile2 exists");
+        assertFalse(testDirAFile3.exists(), "B testDirAFile3 exists");
+        assertTrue(testDirAFile4.exists(), "B testDirAFile4 exists");
+        assertFalse(testDirAFile5.exists(), "B testDirAFile5 exists");
+
+        checkAndNotify();
+        checkCollectionsEmpty("C");
+
+        // Create file with name < first entry
+        testDirAFile1 = touch(testDirAFile1);
+        testDirA      = touch(testDirA);
+        checkAndNotify();
+        checkCollectionSizes("D", 0, 1, 0, 1, 0, 0);
+        assertTrue(testDirAFile1.exists(), "D testDirAFile1 exists");
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile1), "D testDirAFile1");
+
+        // Create file with name between 2 entries
+        testDirAFile3 = touch(testDirAFile3);
+        testDirA      = touch(testDirA);
+        checkAndNotify();
+        checkCollectionSizes("E", 0, 1, 0, 1, 0, 0);
+        assertTrue(testDirAFile3.exists(), "E testDirAFile3 exists");
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile3), "E testDirAFile3");
+
+        // Create file with name > last entry
+        testDirAFile5 = touch(testDirAFile5);
+        testDirA      = touch(testDirA);
+        checkAndNotify();
+        checkCollectionSizes("F", 0, 1, 0, 1, 0, 0);
+        assertTrue(testDirAFile5.exists(), "F testDirAFile5 exists");
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile5), "F testDirAFile5");
+    }
+
+    /**
+     * Test checkAndNotify() deleting
+     * @throws IOException if an I/O error occurs.
+     */
+    @Test
+    public void testFileDelete() throws IOException {
+        checkAndNotify();
+        checkCollectionsEmpty("A");
+        File testDirA = new File(testDir, "test-dir-A");
+        testDirA.mkdir();
+        testDir  = touch(testDir);
+        testDirA = touch(testDirA);
+        final File testDirAFile1 = touch(new File(testDirA, "A-file1.java"));
+        final File testDirAFile2 = touch(new File(testDirA, "A-file2.java"));
+        final File testDirAFile3 = touch(new File(testDirA, "A-file3.java"));
+        final File testDirAFile4 = touch(new File(testDirA, "A-file4.java"));
+        final File testDirAFile5 = touch(new File(testDirA, "A-file5.java"));
+
+        assertTrue(testDirAFile1.exists(), "B testDirAFile1 exists");
+        assertTrue(testDirAFile2.exists(), "B testDirAFile2 exists");
+        assertTrue(testDirAFile3.exists(), "B testDirAFile3 exists");
+        assertTrue(testDirAFile4.exists(), "B testDirAFile4 exists");
+        assertTrue(testDirAFile5.exists(), "B testDirAFile5 exists");
+
+        checkAndNotify();
+        checkCollectionSizes("B", 1, 0, 0, 5, 0, 0);
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile1), "B testDirAFile1");
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile2), "B testDirAFile2");
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile3), "B testDirAFile3");
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile4), "B testDirAFile4");
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile5), "B testDirAFile5");
+
+        checkAndNotify();
+        checkCollectionsEmpty("C");
+
+        // Delete first entry
+        FileUtils.deleteQuietly(testDirAFile1);
+        testDirA = touch(testDirA);
+        checkAndNotify();
+        checkCollectionSizes("D", 0, 1, 0, 0, 0, 1);
+        assertFalse(testDirAFile1.exists(), "D testDirAFile1 exists");
+        assertTrue(listener.getDeletedFiles().contains(testDirAFile1), "D testDirAFile1");
+
+        // Delete file with name between 2 entries
+        FileUtils.deleteQuietly(testDirAFile3);
+        testDirA = touch(testDirA);
+        checkAndNotify();
+        checkCollectionSizes("E", 0, 1, 0, 0, 0, 1);
+        assertFalse(testDirAFile3.exists(), "E testDirAFile3 exists");
+        assertTrue(listener.getDeletedFiles().contains(testDirAFile3), "E testDirAFile3");
+
+        // Delete last entry
+        FileUtils.deleteQuietly(testDirAFile5);
+        testDirA = touch(testDirA);
+        checkAndNotify();
+        checkCollectionSizes("F", 0, 1, 0, 0, 0, 1);
+        assertFalse(testDirAFile5.exists(), "F testDirAFile5 exists");
+        assertTrue(listener.getDeletedFiles().contains(testDirAFile5), "F testDirAFile5");
+    }
+
+    /**
+     * Test checkAndNotify() creating
+     * @throws IOException if an I/O error occurs.
+     */
+    @Test
+    public void testFileUpdate() throws IOException {
+        checkAndNotify();
+        checkCollectionsEmpty("A");
+        File testDirA = new File(testDir, "test-dir-A");
+        testDirA.mkdir();
+        testDir  = touch(testDir);
+        testDirA = touch(testDirA);
+        File testDirAFile1 = touch(new File(testDirA, "A-file1.java"));
+        final File testDirAFile2 = touch(new File(testDirA, "A-file2.java"));
+        File testDirAFile3 = touch(new File(testDirA, "A-file3.java"));
+        final File testDirAFile4 = touch(new File(testDirA, "A-file4.java"));
+        File testDirAFile5 = touch(new File(testDirA, "A-file5.java"));
+
+        checkAndNotify();
+        checkCollectionSizes("B", 1, 0, 0, 5, 0, 0);
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile1), "B testDirAFile1");
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile2), "B testDirAFile2");
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile3), "B testDirAFile3");
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile4), "B testDirAFile4");
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile5), "B testDirAFile5");
+
+        assertTrue(testDirAFile1.exists(), "B testDirAFile1 exists");
+        assertTrue(testDirAFile2.exists(), "B testDirAFile2 exists");
+        assertTrue(testDirAFile3.exists(), "B testDirAFile3 exists");
+        assertTrue(testDirAFile4.exists(), "B testDirAFile4 exists");
+        assertTrue(testDirAFile5.exists(), "B testDirAFile5 exists");
+
+        checkAndNotify();
+        checkCollectionsEmpty("C");
+
+        // Update first entry
+        testDirAFile1 = touch(testDirAFile1);
+        testDirA      = touch(testDirA);
+        checkAndNotify();
+        checkCollectionSizes("D", 0, 1, 0, 0, 1, 0);
+        assertTrue(listener.getChangedFiles().contains(testDirAFile1), "D testDirAFile1");
+
+        // Update file with name between 2 entries
+        testDirAFile3 = touch(testDirAFile3);
+        testDirA      = touch(testDirA);
+        checkAndNotify();
+        checkCollectionSizes("E", 0, 1, 0, 0, 1, 0);
+        assertTrue(listener.getChangedFiles().contains(testDirAFile3), "E testDirAFile3");
+
+        // Update last entry
+        testDirAFile5 = touch(testDirAFile5);
+        testDirA      = touch(testDirA);
+        checkAndNotify();
+        checkCollectionSizes("F", 0, 1, 0, 0, 1, 0);
+        assertTrue(listener.getChangedFiles().contains(testDirAFile5), "F testDirAFile5");
+    }
+
+    /**
+     * Test checkAndNotify() method
+     * @throws IOException if an I/O error occurs.
+     */
+    @Test
+    public void testObserveSingleFile() throws IOException {
+        final File testDirA = new File(testDir, "test-dir-A");
+        File testDirAFile1 = new File(testDirA, "A-file1.java");
+        testDirA.mkdir();
+
+        final FileFilter nameFilter = FileFilterUtils.nameFileFilter(testDirAFile1.getName());
+        createObserver(testDirA, nameFilter);
+        checkAndNotify();
+        checkCollectionsEmpty("A");
+        assertFalse(testDirAFile1.exists(), "A testDirAFile1 exists");
+
+        // Create
+        testDirAFile1 = touch(testDirAFile1);
+        File testDirAFile2 = touch(new File(testDirA, "A-file2.txt"));  /* filter should ignore */
+        File testDirAFile3 = touch(new File(testDirA, "A-file3.java")); /* filter should ignore */
+        assertTrue(testDirAFile1.exists(), "B testDirAFile1 exists");
+        assertTrue(testDirAFile2.exists(), "B testDirAFile2 exists");
+        assertTrue(testDirAFile3.exists(), "B testDirAFile3 exists");
+        checkAndNotify();
+        checkCollectionSizes("C", 0, 0, 0, 1, 0, 0);
+        assertTrue(listener.getCreatedFiles().contains(testDirAFile1), "C created");
+        assertFalse(listener.getCreatedFiles().contains(testDirAFile2), "C created");
+        assertFalse(listener.getCreatedFiles().contains(testDirAFile3), "C created");
+
+        // Modify
+        testDirAFile1 = touch(testDirAFile1);
+        testDirAFile2 = touch(testDirAFile2);
+        testDirAFile3 = touch(testDirAFile3);
+        checkAndNotify();
+        checkCollectionSizes("D", 0, 0, 0, 0, 1, 0);
+        assertTrue(listener.getChangedFiles().contains(testDirAFile1), "D changed");
+        assertFalse(listener.getChangedFiles().contains(testDirAFile2), "D changed");
+        assertFalse(listener.getChangedFiles().contains(testDirAFile3), "D changed");
+
+        // Delete
+        FileUtils.deleteQuietly(testDirAFile1);
+        FileUtils.deleteQuietly(testDirAFile2);
+        FileUtils.deleteQuietly(testDirAFile3);
+        assertFalse(testDirAFile1.exists(), "E testDirAFile1 exists");
+        assertFalse(testDirAFile2.exists(), "E testDirAFile2 exists");
+        assertFalse(testDirAFile3.exists(), "E testDirAFile3 exists");
+        checkAndNotify();
+        checkCollectionSizes("E", 0, 0, 0, 0, 0, 1);
+        assertTrue(listener.getDeletedFiles().contains(testDirAFile1), "E deleted");
+        assertFalse(listener.getDeletedFiles().contains(testDirAFile2), "E deleted");
+        assertFalse(listener.getDeletedFiles().contains(testDirAFile3), "E deleted");
+    }
+
+    /**
+     * Test toString().
+     */
+    @Test
+    public void testToString() {
+        final File file = new File("/foo");
+
+        FileAlterationObserver observer = new FileAlterationObserver(file);
+        assertEquals("FileAlterationObserver[file='" + file.getPath() +  "', listeners=0]",
+                observer.toString());
+
+        observer = new FileAlterationObserver(file, CanReadFileFilter.CAN_READ);
+        assertEquals("FileAlterationObserver[file='" + file.getPath() +  "', CanReadFileFilter, listeners=0]",
+                observer.toString());
+
+        assertEquals(file, observer.getDirectory());
+  }
+}
diff --git a/src/test/java/org/apache/commons/io/monitor/FileEntryTest.java b/src/test/java/org/apache/commons/io/monitor/FileEntryTest.java
new file mode 100644
index 0000000..a42d8f7
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/monitor/FileEntryTest.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.monitor;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.lang3.SerializationUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link FileEntry}.
+ */
+public class FileEntryTest {
+
+    @Test
+    public void testSerializable() {
+        final FileEntry fe = new FileEntry(FileUtils.current());
+        assertEquals(fe.getChildren(), SerializationUtils.roundtrip(fe).getChildren());
+        assertEquals(fe.getClass(), SerializationUtils.roundtrip(fe).getClass());
+        assertEquals(fe.getFile(), SerializationUtils.roundtrip(fe).getFile());
+        assertEquals(fe.getLastModified(), SerializationUtils.roundtrip(fe).getLastModified());
+        assertEquals(fe.getLastModifiedFileTime(), SerializationUtils.roundtrip(fe).getLastModifiedFileTime());
+        assertEquals(fe.getLength(), SerializationUtils.roundtrip(fe).getLength());
+        assertEquals(fe.getLevel(), SerializationUtils.roundtrip(fe).getLevel());
+        assertEquals(fe.getName(), SerializationUtils.roundtrip(fe).getName());
+        assertEquals(fe.getParent(), SerializationUtils.roundtrip(fe).getParent());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/monitor/SerializableFileTimeTest.java b/src/test/java/org/apache/commons/io/monitor/SerializableFileTimeTest.java
new file mode 100644
index 0000000..2924935
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/monitor/SerializableFileTimeTest.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.monitor;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.attribute.FileTime;
+
+import org.apache.commons.io.file.PathUtils;
+import org.apache.commons.lang3.SerializationUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link SerializableFileTime}.
+ */
+public class SerializableFileTimeTest {
+
+    @Test
+    public void testSerializable() throws IOException {
+        final SerializableFileTime expected = new SerializableFileTime(Files.getLastModifiedTime(PathUtils.current()));
+        final SerializableFileTime actual = SerializationUtils.roundtrip(expected);
+        assertEquals(expected, actual);
+        final FileTime expectedFt = expected.unwrap();
+        assertEquals(expectedFt, actual.unwrap());
+        assertEquals(0, actual.compareTo(expectedFt));
+        assertEquals(expectedFt.hashCode(), actual.hashCode());
+        assertEquals(expectedFt.toInstant(), actual.toInstant());
+        assertEquals(expectedFt.toMillis(), actual.toMillis());
+        assertEquals(expectedFt.toString(), actual.toString());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/AppendableOutputStreamTest.java b/src/test/java/org/apache/commons/io/output/AppendableOutputStreamTest.java
new file mode 100644
index 0000000..69b8902
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/AppendableOutputStreamTest.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link AppendableOutputStream}.
+ *
+ */
+public class AppendableOutputStreamTest {
+
+    private AppendableOutputStream<StringBuilder> out;
+
+    @BeforeEach
+    public void setUp() {
+        out = new AppendableOutputStream<>(new StringBuilder());
+    }
+
+    @Test
+    public void testWriteInt() throws Exception {
+        out.write('F');
+
+        assertEquals("F", out.getAppendable().toString());
+    }
+
+    @Test
+    public void testWriteStringBuilder() throws Exception {
+        final String testData = "ABCD";
+
+        out.write(testData.getBytes());
+
+        assertEquals(testData, out.getAppendable().toString());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/output/AppendableWriterTest.java b/src/test/java/org/apache/commons/io/output/AppendableWriterTest.java
new file mode 100644
index 0000000..5b30722
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/AppendableWriterTest.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link AppendableWriter}.
+ *
+ */
+public class AppendableWriterTest {
+
+    private AppendableWriter<StringBuilder> out;
+
+    @BeforeEach
+    public void setUp() {
+        out = new AppendableWriter<>(new StringBuilder());
+    }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void testAppendChar() throws Exception {
+        out.append('F');
+
+        assertEquals("F", out.getAppendable().toString());
+    }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void testAppendCharSequence() throws Exception {
+        final String testData = "ABCD";
+
+        out.append(testData);
+        out.append(null);
+
+        assertEquals(testData + "null", out.getAppendable().toString());
+    }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void testAppendSubSequence() throws Exception {
+        final String testData = "ABCD";
+
+        out.append(testData, 1, 3);
+        out.append(null, 1, 3);
+
+        assertEquals(testData.substring(1, 3) + "ul", out.getAppendable().toString());
+    }
+
+    @Test
+    public void testWriteChars() throws Exception {
+        final String testData = "ABCD";
+
+        out.write(testData.toCharArray());
+
+        assertEquals(testData, out.getAppendable().toString());
+    }
+
+    @Test
+    public void testWriteInt() throws Exception {
+        out.write('F');
+
+        assertEquals("F", out.getAppendable().toString());
+    }
+
+    @Test
+    public void testWriteString() throws Exception {
+        final String testData = "ABCD";
+
+        out.write(testData);
+
+        assertEquals(testData, out.getAppendable().toString());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/output/BrokenOutputStreamTest.java b/src/test/java/org/apache/commons/io/output/BrokenOutputStreamTest.java
new file mode 100644
index 0000000..1f56c27
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/BrokenOutputStreamTest.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link BrokenOutputStream}.
+ */
+public class BrokenOutputStreamTest {
+
+    private IOException exception;
+
+    private OutputStream stream;
+
+    @BeforeEach
+    public void setUp() {
+        exception = new IOException("test exception");
+        stream = new BrokenOutputStream(exception);
+    }
+
+    @Test
+    public void testClose() {
+        assertEquals(exception, assertThrows(IOException.class, () -> stream.close()));
+    }
+
+    @Test
+    public void testFlush() {
+        assertEquals(exception, assertThrows(IOException.class, () -> stream.flush()));
+    }
+
+    @Test
+    public void testTryWithResources() {
+        final IOException thrown = assertThrows(IOException.class, () -> {
+            try (OutputStream newStream = new BrokenOutputStream()) {
+                newStream.write(1);
+            }
+        });
+        assertEquals("Broken output stream", thrown.getMessage());
+
+        final Throwable[] suppressed = thrown.getSuppressed();
+        assertEquals(1, suppressed.length);
+        assertEquals(IOException.class, suppressed[0].getClass());
+        assertEquals("Broken output stream", suppressed[0].getMessage());
+    }
+
+    @Test
+    public void testWriteByteArray() {
+        assertEquals(exception, assertThrows(IOException.class, () -> stream.write(new byte[1])));
+    }
+
+    @Test
+    public void testWriteByteArrayIndexed() {
+        assertEquals(exception, assertThrows(IOException.class, () -> stream.write(new byte[1], 0, 1)));
+    }
+
+    @Test
+    public void testWriteInt() {
+        assertEquals(exception, assertThrows(IOException.class, () -> stream.write(1)));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/BrokenWriterTest.java b/src/test/java/org/apache/commons/io/output/BrokenWriterTest.java
new file mode 100644
index 0000000..f508b24
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/BrokenWriterTest.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.Writer;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link BrokenWriter}.
+ */
+public class BrokenWriterTest {
+
+    private IOException exception;
+
+    private Writer brokenWriter;
+
+    @BeforeEach
+    public void setUp() {
+        exception = new IOException("test exception");
+        brokenWriter = new BrokenWriter(exception);
+    }
+
+    @Test
+    public void testAppendChar() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenWriter.append('1')));
+    }
+
+    @Test
+    public void testAppendCharSequence() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenWriter.append("01")));
+    }
+
+    @Test
+    public void testAppendCharSequenceIndexed() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenWriter.append("01", 0, 1)));
+    }
+
+    @Test
+    public void testClose() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenWriter.close()));
+    }
+
+    @Test
+    @Disabled("What should happen here?")
+    public void testEquals() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenWriter.equals(null)));
+    }
+
+    @Test
+    public void testFlush() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenWriter.flush()));
+    }
+
+    @Test
+    @Disabled("What should happen here?")
+    public void testHashCode() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenWriter.hashCode()));
+    }
+
+    @Test
+    @Disabled("What should happen here?")
+    public void testToString() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenWriter.toString()));
+    }
+
+    @Test
+    public void testTryWithResources() {
+        final IOException thrown = assertThrows(IOException.class, () -> {
+            try (Writer newWriter = new BrokenWriter()) {
+                newWriter.write(1);
+            }
+        });
+        assertEquals("Broken writer", thrown.getMessage());
+
+        final Throwable[] suppressed = thrown.getSuppressed();
+        assertEquals(1, suppressed.length);
+        assertEquals(IOException.class, suppressed[0].getClass());
+        assertEquals("Broken writer", suppressed[0].getMessage());
+    }
+
+    @Test
+    public void testWriteCharArray() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenWriter.write(new char[1])));
+    }
+
+    @Test
+    public void testWriteCharArrayIndexed() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenWriter.write(new char[1], 0, 1)));
+    }
+
+    @Test
+    public void testWriteInt() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenWriter.write(1)));
+    }
+
+    @Test
+    public void testWriteString() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenWriter.write("01")));
+    }
+
+    @Test
+    public void testWriteStringIndexed() {
+        assertEquals(exception, assertThrows(IOException.class, () -> brokenWriter.write("01", 0, 1)));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/ByteArrayOutputStreamTest.java b/src/test/java/org/apache/commons/io/output/ByteArrayOutputStreamTest.java
new file mode 100644
index 0000000..4217e02
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/ByteArrayOutputStreamTest.java
@@ -0,0 +1,374 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.function.IOFunction;
+import org.apache.commons.io.input.ClosedInputStream;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Basic unit tests for the alternative ByteArrayOutputStream implementations.
+ */
+public class ByteArrayOutputStreamTest {
+
+    private interface BAOSFactory<T extends AbstractByteArrayOutputStream> {
+        T newInstance();
+
+        T newInstance(final int size);
+    }
+
+    private static class ByteArrayOutputStreamFactory implements BAOSFactory<ByteArrayOutputStream> {
+        @Override
+        public ByteArrayOutputStream newInstance() {
+            return new ByteArrayOutputStream();
+        }
+
+        @Override
+        public ByteArrayOutputStream newInstance(final int size) {
+            return new ByteArrayOutputStream(size);
+        }
+    }
+
+    private static class UnsynchronizedByteArrayOutputStreamFactory implements BAOSFactory<UnsynchronizedByteArrayOutputStream> {
+        @Override
+        public UnsynchronizedByteArrayOutputStream newInstance() {
+            return new UnsynchronizedByteArrayOutputStream();
+        }
+
+        @Override
+        public UnsynchronizedByteArrayOutputStream newInstance(final int size) {
+            return new UnsynchronizedByteArrayOutputStream(size);
+        }
+    }
+
+    private static final byte[] DATA;
+
+    static {
+        DATA = new byte[64];
+        for (byte i = 0; i < 64; i++) {
+            DATA[i] = i;
+        }
+    }
+
+    private static Stream<Arguments> baosFactories() {
+        return Stream.of(Arguments.of(ByteArrayOutputStream.class.getSimpleName(), new ByteArrayOutputStreamFactory()),
+            Arguments.of(UnsynchronizedByteArrayOutputStream.class.getSimpleName(), new UnsynchronizedByteArrayOutputStreamFactory()));
+    }
+
+    private static boolean byteCmp(final byte[] src, final byte[] cmp) {
+        for (int i = 0; i < cmp.length; i++) {
+            if (src[i] != cmp[i]) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static Stream<Arguments> toBufferedInputStreamFunctionFactories() {
+        final IOFunction<InputStream, InputStream> syncBaosToBufferedInputStream = ByteArrayOutputStream::toBufferedInputStream;
+        final IOFunction<InputStream, InputStream> syncBaosToBufferedInputStreamWithSize = is -> ByteArrayOutputStream.toBufferedInputStream(is, 1024);
+        final IOFunction<InputStream, InputStream> unSyncBaosToBufferedInputStream = UnsynchronizedByteArrayOutputStream::toBufferedInputStream;
+        final IOFunction<InputStream, InputStream> unSyncBaosToBufferedInputStreamWithSize = is -> UnsynchronizedByteArrayOutputStream.toBufferedInputStream(is,
+            1024);
+
+        return Stream.of(Arguments.of("ByteArrayOutputStream.toBufferedInputStream(InputStream)", syncBaosToBufferedInputStream),
+            Arguments.of("ByteArrayOutputStream.toBufferedInputStream(InputStream, int)", syncBaosToBufferedInputStreamWithSize),
+            Arguments.of("UnsynchronizedByteArrayOutputStream.toBufferedInputStream(InputStream)", unSyncBaosToBufferedInputStream),
+            Arguments.of("UnsynchronizedByteArrayOutputStream.toBufferedInputStream(InputStream, int)", unSyncBaosToBufferedInputStreamWithSize));
+    }
+
+    private void checkByteArrays(final byte[] expected, final byte[] actual) {
+        if (expected.length != actual.length) {
+            fail("Resulting byte arrays are not equally long");
+        }
+        if (!byteCmp(expected, actual)) {
+            fail("Resulting byte arrays are not equal");
+        }
+    }
+
+    private void checkStreams(final AbstractByteArrayOutputStream actual, final java.io.ByteArrayOutputStream expected) {
+        assertEquals(expected.size(), actual.size(), "Sizes are not equal");
+        final byte[] buf = actual.toByteArray();
+        final byte[] refbuf = expected.toByteArray();
+        checkByteArrays(buf, refbuf);
+    }
+
+    @ParameterizedTest(name = "[{index}] {0}")
+    @MethodSource("baosFactories")
+    public void testInvalidParameterizedConstruction(final String baosName, final BAOSFactory<?> baosFactory) {
+        assertThrows(IllegalArgumentException.class, () -> baosFactory.newInstance(-1));
+    }
+
+    @ParameterizedTest(name = "[{index}] {0}")
+    @MethodSource("baosFactories")
+    public void testInvalidWriteLenUnder(final String baosName, final BAOSFactory<?> baosFactory) throws IOException {
+        try (AbstractByteArrayOutputStream baout = baosFactory.newInstance()) {
+            assertThrows(IndexOutOfBoundsException.class, () -> baout.write(new byte[1], 0, -1));
+        }
+    }
+
+    @ParameterizedTest(name = "[{index}] {0}")
+    @MethodSource("baosFactories")
+    public void testInvalidWriteOffsetAndLenOver(final String baosName, final BAOSFactory<?> baosFactory) throws IOException {
+        try (AbstractByteArrayOutputStream baout = baosFactory.newInstance()) {
+            assertThrows(IndexOutOfBoundsException.class, () -> baout.write(new byte[1], 0, 2));
+        }
+    }
+
+    @ParameterizedTest(name = "[{index}] {0}")
+    @MethodSource("baosFactories")
+    public void testInvalidWriteOffsetAndLenUnder(final String baosName, final BAOSFactory<?> baosFactory) throws IOException {
+        try (AbstractByteArrayOutputStream baout = baosFactory.newInstance()) {
+            assertThrows(IndexOutOfBoundsException.class, () -> baout.write(new byte[1], 1, -2));
+        }
+    }
+
+    @ParameterizedTest(name = "[{index}] {0}")
+    @MethodSource("baosFactories")
+    public void testInvalidWriteOffsetOver(final String baosName, final BAOSFactory<?> baosFactory) throws IOException {
+        try (AbstractByteArrayOutputStream baout = baosFactory.newInstance()) {
+            assertThrows(IndexOutOfBoundsException.class, () -> baout.write(IOUtils.EMPTY_BYTE_ARRAY, 1, 0));
+        }
+    }
+
+    @ParameterizedTest(name = "[{index}] {0}")
+    @MethodSource("baosFactories")
+    public void testInvalidWriteOffsetUnder(final String baosName, final BAOSFactory<?> baosFactory) throws IOException {
+        try (AbstractByteArrayOutputStream baout = baosFactory.newInstance()) {
+            assertThrows(IndexOutOfBoundsException.class, () -> baout.write(null, -1, 0));
+        }
+    }
+
+    @ParameterizedTest(name = "[{index}] {0}")
+    @MethodSource("baosFactories")
+    public void testStream(final String baosName, final BAOSFactory<?> baosFactory) throws Exception {
+        int written;
+
+        // The ByteArrayOutputStream is initialized with 32 bytes to match
+        // the original more closely for this test.
+        try (AbstractByteArrayOutputStream baout = baosFactory.newInstance(32);
+            final java.io.ByteArrayOutputStream ref = new java.io.ByteArrayOutputStream()) {
+
+            // First three writes
+            written = writeData(baout, ref, new int[] {4, 10, 22});
+            assertEquals(36, written);
+            checkStreams(baout, ref);
+
+            // Another two writes to see if there are any bad effects after toByteArray()
+            written = writeData(baout, ref, new int[] {20, 12});
+            assertEquals(32, written);
+            checkStreams(baout, ref);
+
+            // Now reset the streams
+            baout.reset();
+            ref.reset();
+
+            // Test again to see if reset() had any bad effects
+            written = writeData(baout, ref, new int[] {5, 47, 33, 60, 1, 0, 8});
+            assertEquals(155, written);
+            checkStreams(baout, ref);
+
+            // Test the readFrom(InputStream) method
+            baout.reset();
+            written = baout.write(new ByteArrayInputStream(ref.toByteArray()));
+            assertEquals(155, written);
+            checkStreams(baout, ref);
+
+            // Write the commons Byte[]OutputStream to a java.io.Byte[]OutputStream
+            // and vice-versa to test the writeTo() method.
+            try (AbstractByteArrayOutputStream baout1 = baosFactory.newInstance(32)) {
+                ref.writeTo(baout1);
+                final java.io.ByteArrayOutputStream ref1 = new java.io.ByteArrayOutputStream();
+                baout.writeTo(ref1);
+                checkStreams(baout1, ref1);
+
+                // Testing toString(String)
+                final String baoutString = baout.toString("ASCII");
+                final String refString = ref.toString("ASCII");
+                assertEquals(refString, baoutString, "ASCII decoded String must be equal");
+
+                // Make sure that empty ByteArrayOutputStreams really don't create garbage
+                // on toByteArray()
+                try (AbstractByteArrayOutputStream baos1 = baosFactory.newInstance();
+                    final AbstractByteArrayOutputStream baos2 = baosFactory.newInstance()) {
+                    assertSame(baos1.toByteArray(), baos2.toByteArray());
+                }
+            }
+        }
+    }
+
+    @ParameterizedTest(name = "[{index}] {0}")
+    @MethodSource("toBufferedInputStreamFunctionFactories")
+    public void testToBufferedInputStream(final String baosName, final IOFunction<InputStream, InputStream> toBufferedInputStreamFunction) throws IOException {
+        final byte data[] = {(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE};
+
+        try (ByteArrayInputStream bain = new ByteArrayInputStream(data)) {
+            assertEquals(data.length, bain.available());
+
+            try (InputStream buffered = toBufferedInputStreamFunction.apply(bain)) {
+                assertEquals(data.length, buffered.available());
+
+                assertArrayEquals(data, IOUtils.toByteArray(buffered));
+
+            }
+        }
+    }
+
+    @ParameterizedTest(name = "[{index}] {0}")
+    @MethodSource("toBufferedInputStreamFunctionFactories")
+    public void testToBufferedInputStreamEmpty(final String baosName, final IOFunction<InputStream, InputStream> toBufferedInputStreamFunction)
+        throws IOException {
+        try (ByteArrayInputStream bain = new ByteArrayInputStream(IOUtils.EMPTY_BYTE_ARRAY)) {
+            assertEquals(0, bain.available());
+
+            try (InputStream buffered = toBufferedInputStreamFunction.apply(bain)) {
+                assertEquals(0, buffered.available());
+
+            }
+        }
+    }
+
+    @ParameterizedTest(name = "[{index}] {0}")
+    @MethodSource("baosFactories")
+    public void testToInputStream(final String baosName, final BAOSFactory<?> baosFactory) throws IOException {
+        try (AbstractByteArrayOutputStream baout = baosFactory.newInstance();
+            final java.io.ByteArrayOutputStream ref = new java.io.ByteArrayOutputStream()) {
+
+            // Write 8224 bytes
+            writeData(baout, ref, 32);
+            for (int i = 0; i < 128; i++) {
+                writeData(baout, ref, 64);
+            }
+
+            // Get data before more writes
+            try (InputStream in = baout.toInputStream()) {
+                byte refData[] = ref.toByteArray();
+
+                // Write some more data
+                writeData(baout, ref, new int[] {2, 4, 8, 16});
+
+                // Check original data
+                byte baoutData[] = IOUtils.toByteArray(in);
+                assertEquals(8224, baoutData.length);
+                checkByteArrays(refData, baoutData);
+
+                // Check all data written
+                try (InputStream in2 = baout.toInputStream()) {
+                    baoutData = IOUtils.toByteArray(in2);
+                }
+                refData = ref.toByteArray();
+                assertEquals(8254, baoutData.length);
+                checkByteArrays(refData, baoutData);
+            }
+        }
+    }
+
+    @ParameterizedTest(name = "[{index}] {0}")
+    @MethodSource("baosFactories")
+    public void testToInputStreamEmpty(final String baosName, final BAOSFactory<?> baosFactory) throws IOException {
+        try (AbstractByteArrayOutputStream baout = baosFactory.newInstance();
+            // Get data before more writes
+            final InputStream in = baout.toInputStream()) {
+            assertEquals(0, in.available());
+            assertTrue(in instanceof ClosedInputStream);
+        }
+    }
+
+    @ParameterizedTest(name = "[{index}] {0}")
+    @MethodSource("baosFactories")
+    public void testToInputStreamWithReset(final String baosName, final BAOSFactory<?> baosFactory) throws IOException {
+        // Make sure reset() do not destroy InputStream returned from toInputStream()
+        try (AbstractByteArrayOutputStream baout = baosFactory.newInstance();
+            final java.io.ByteArrayOutputStream ref = new java.io.ByteArrayOutputStream()) {
+
+            // Write 8224 bytes
+            writeData(baout, ref, 32);
+            for (int i = 0; i < 128; i++) {
+                writeData(baout, ref, 64);
+            }
+
+            // Get data before reset
+            try (InputStream in = baout.toInputStream()) {
+                byte refData[] = ref.toByteArray();
+
+                // Reset and write some new data
+                baout.reset();
+                ref.reset();
+                writeData(baout, ref, new int[] {2, 4, 8, 16});
+
+                // Check original data
+                byte baoutData[] = IOUtils.toByteArray(in);
+                assertEquals(8224, baoutData.length);
+                checkByteArrays(refData, baoutData);
+
+                // Check new data written after reset
+                try (InputStream in2 = baout.toInputStream()) {
+                    baoutData = IOUtils.toByteArray(in2);
+                }
+                refData = ref.toByteArray();
+                assertEquals(30, baoutData.length);
+                checkByteArrays(refData, baoutData);
+            }
+        }
+    }
+
+    @ParameterizedTest(name = "[{index}] {0}")
+    @MethodSource("baosFactories")
+    public void testWriteZero(final String baosName, final BAOSFactory<?> baosFactory) throws IOException {
+        try (AbstractByteArrayOutputStream baout = baosFactory.newInstance()) {
+            baout.write(IOUtils.EMPTY_BYTE_ARRAY, 0, 0);
+            assertTrue(true, "Dummy");
+        }
+    }
+
+    private int writeData(final AbstractByteArrayOutputStream baout, final java.io.ByteArrayOutputStream ref, final int count) {
+        if (count > DATA.length) {
+            throw new IllegalArgumentException("Requesting too many bytes");
+        }
+        if (count == 0) {
+            baout.write(100);
+            ref.write(100);
+            return 1;
+        }
+        baout.write(DATA, 0, count);
+        ref.write(DATA, 0, count);
+        return count;
+    }
+
+    private int writeData(final AbstractByteArrayOutputStream baout, final java.io.ByteArrayOutputStream ref, final int[] instructions) {
+        int written = 0;
+        for (final int instruction : instructions) {
+            written += writeData(baout, ref, instruction);
+        }
+        return written;
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/output/ChunkedOutputStreamTest.java b/src/test/java/org/apache/commons/io/output/ChunkedOutputStreamTest.java
new file mode 100644
index 0000000..11eacbf
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/ChunkedOutputStreamTest.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test the chunked output stream
+ */
+public class ChunkedOutputStreamTest {
+
+    @Test
+    public void defaultConstructor() throws IOException {
+        final AtomicInteger numWrites = new AtomicInteger();
+        try (ByteArrayOutputStream baos = newByteArrayOutputStream(numWrites);
+            final ChunkedOutputStream chunked = new ChunkedOutputStream(baos)) {
+            chunked.write(new byte[1024 * 4 + 1]);
+            assertEquals(2, numWrites.get());
+        }
+    }
+
+    @Test
+    public void negative_chunkSize_not_permitted() {
+        assertThrows(IllegalArgumentException.class, () -> new ChunkedOutputStream(new ByteArrayOutputStream(), 0));
+    }
+
+    private ByteArrayOutputStream newByteArrayOutputStream(final AtomicInteger numWrites) {
+        return new ByteArrayOutputStream() {
+            @Override
+            public void write(final byte[] b, final int off, final int len) {
+                numWrites.incrementAndGet();
+                super.write(b, off, len);
+            }
+        };
+    }
+
+    @Test
+    public void write_four_chunks() throws Exception {
+        final AtomicInteger numWrites = new AtomicInteger();
+        try (ByteArrayOutputStream baos = newByteArrayOutputStream(numWrites);
+            final ChunkedOutputStream chunked = new ChunkedOutputStream(baos, 10)) {
+            chunked.write("0123456789012345678901234567891".getBytes());
+            assertEquals(4, numWrites.get());
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/ChunkedWriterTest.java b/src/test/java/org/apache/commons/io/output/ChunkedWriterTest.java
new file mode 100644
index 0000000..96e89ae
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/ChunkedWriterTest.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+public class ChunkedWriterTest {
+    private OutputStreamWriter getOutputStreamWriter(final AtomicInteger numWrites) {
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        return new OutputStreamWriter(baos) {
+            @Override
+            public void write(final char[] cbuf, final int off, final int len) throws IOException {
+                numWrites.incrementAndGet();
+                super.write(cbuf, off, len);
+            }
+        };
+    }
+
+    @Test
+    public void negative_chunkSize_not_permitted() {
+        assertThrows(IllegalArgumentException.class,
+               () -> new ChunkedWriter(new OutputStreamWriter(new ByteArrayOutputStream()), 0));
+    }
+
+    @Test
+    public void write_four_chunks() throws Exception {
+        final AtomicInteger numWrites = new AtomicInteger();
+        try (OutputStreamWriter osw = getOutputStreamWriter(numWrites)) {
+            try (ChunkedWriter chunked = new ChunkedWriter(osw, 10)) {
+                chunked.write("0123456789012345678901234567891".toCharArray());
+                chunked.flush();
+                assertEquals(4, numWrites.get());
+            }
+        }
+    }
+
+    @Test
+    public void write_two_chunks_default_constructor() throws Exception {
+        final AtomicInteger numWrites = new AtomicInteger();
+        try (OutputStreamWriter osw = getOutputStreamWriter(numWrites)) {
+            try (ChunkedWriter chunked = new ChunkedWriter(osw)) {
+                chunked.write(new char[1024 * 4 + 1]);
+                chunked.flush();
+                assertEquals(2, numWrites.get());
+            }
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/output/CloseShieldOutputStreamTest.java b/src/test/java/org/apache/commons/io/output/CloseShieldOutputStreamTest.java
new file mode 100644
index 0000000..bd9d45f
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/CloseShieldOutputStreamTest.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link CloseShieldOutputStream}.
+ */
+public class CloseShieldOutputStreamTest {
+
+    private ByteArrayOutputStream original;
+
+    private OutputStream shielded;
+
+    private boolean closed;
+
+    @BeforeEach
+    public void setUp() {
+        original = new ByteArrayOutputStream() {
+            @Override
+            public void close() {
+                closed = true;
+            }
+        };
+        shielded = CloseShieldOutputStream.wrap(original);
+        closed = false;
+    }
+
+    @Test
+    public void testClose() throws IOException {
+        shielded.close();
+        assertFalse(closed, "closed");
+        assertThrows(IOException.class, () -> shielded.write('x'), "write(b)");
+        original.write('y');
+        assertEquals(1, original.size());
+        assertEquals('y', original.toByteArray()[0]);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/CloseShieldWriterTest.java b/src/test/java/org/apache/commons/io/output/CloseShieldWriterTest.java
new file mode 100644
index 0000000..b1069ef
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/CloseShieldWriterTest.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+import java.io.IOException;
+import java.io.Writer;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link CloseShieldWriter}.
+ */
+public class CloseShieldWriterTest {
+
+    private StringBuilderWriter original;
+
+    private Writer shielded;
+
+    @SuppressWarnings("resource")
+    @BeforeEach
+    public void setUp() {
+        original = spy(new StringBuilderWriter());
+        shielded = CloseShieldWriter.wrap(original);
+    }
+
+    @Test
+    public void testClose() throws IOException {
+        shielded.close();
+        verify(original, never()).close();
+        assertThrows(IOException.class, () -> shielded.write('x'), "write(c)");
+        original.write('y');
+        assertEquals(1, original.getBuilder().length());
+        assertEquals('y', original.toString().charAt(0));
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/ClosedOutputStreamTest.java b/src/test/java/org/apache/commons/io/output/ClosedOutputStreamTest.java
new file mode 100644
index 0000000..3195db4
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/ClosedOutputStreamTest.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link ClosedOutputStream}.
+ */
+public class ClosedOutputStreamTest {
+
+    /**
+     * Test the {@code flush()} method.
+     */
+    @Test
+    public void testFlush() throws IOException {
+        try (ClosedOutputStream cos = new ClosedOutputStream()) {
+            assertThrows(IOException.class, () -> cos.flush());
+        }
+    }
+
+    /**
+     * Test the {@code write(b)} method.
+     */
+    @Test
+    public void testWrite() throws IOException {
+        try (ClosedOutputStream cos = new ClosedOutputStream()) {
+            assertThrows(IOException.class, () -> cos.write('x'));
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/ClosedWriterTest.java b/src/test/java/org/apache/commons/io/output/ClosedWriterTest.java
new file mode 100644
index 0000000..555f2f6
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/ClosedWriterTest.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link ClosedWriter}.
+ */
+public class ClosedWriterTest {
+
+    /**
+     * Test the {@code flush()} method.
+     */
+    @Test
+    public void testFlush() throws IOException {
+        try (ClosedWriter cw = new ClosedWriter()) {
+            assertThrows(IOException.class, () -> cw.flush());
+        }
+    }
+
+    /**
+     * Test the {@code write(cbuf, off, len)} method.
+     */
+    @Test
+    public void testWrite() throws IOException {
+        try (ClosedWriter cw = new ClosedWriter()) {
+            assertThrows(IOException.class, () -> cw.write(new char[0], 0, 0));
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/CountingOutputStreamTest.java b/src/test/java/org/apache/commons/io/output/CountingOutputStreamTest.java
new file mode 100644
index 0000000..b265cfe
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/CountingOutputStreamTest.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.input.NullInputStream;
+import org.junit.jupiter.api.Test;
+
+/**
+ *
+ */
+public class CountingOutputStreamTest {
+
+    private void assertByteArrayEquals(final String msg, final byte[] array, final int start, final int end) {
+        for (int i = start; i < end; i++) {
+            assertEquals(array[i], i-start, msg+": array[" + i + "] mismatch");
+        }
+    }
+
+    @Test
+    public void testCounting() throws IOException {
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        try (CountingOutputStream cos = new CountingOutputStream(baos)) {
+
+            for (int i = 0; i < 20; i++) {
+                cos.write(i);
+            }
+            assertByteArrayEquals("CountingOutputStream.write(int)", baos.toByteArray(), 0, 20);
+            assertEquals(cos.getCount(), 20, "CountingOutputStream.getCount()");
+
+            final byte[] array = new byte[10];
+            for (int i = 20; i < 30; i++) {
+                array[i - 20] = (byte) i;
+            }
+            cos.write(array);
+            assertByteArrayEquals("CountingOutputStream.write(byte[])", baos.toByteArray(), 0, 30);
+            assertEquals(cos.getCount(), 30, "CountingOutputStream.getCount()");
+
+            for (int i = 25; i < 35; i++) {
+                array[i - 25] = (byte) i;
+            }
+            cos.write(array, 5, 5);
+            assertByteArrayEquals("CountingOutputStream.write(byte[], int, int)", baos.toByteArray(), 0, 35);
+            assertEquals(cos.getCount(), 35, "CountingOutputStream.getCount()");
+
+            final int count = cos.resetCount();
+            assertEquals(count, 35, "CountingOutputStream.resetCount()");
+
+            for (int i = 0; i < 10; i++) {
+                cos.write(i);
+            }
+            assertByteArrayEquals("CountingOutputStream.write(int)", baos.toByteArray(), 35, 45);
+            assertEquals(cos.getCount(), 10, "CountingOutputStream.getCount()");
+        }
+    }
+
+    /*
+     * Test for files > 2GB in size - see issue IO-84
+     */
+    @Test
+    public void testLargeFiles_IO84() throws Exception {
+        final long size = (long) Integer.MAX_VALUE + (long) 1;
+
+        final NullInputStream mock = new NullInputStream(size);
+        final CountingOutputStream cos = new CountingOutputStream(NullOutputStream.INSTANCE);
+
+        // Test integer methods
+        IOUtils.copyLarge(mock, cos);
+        assertThrows(ArithmeticException.class, () -> cos.getCount());
+        assertThrows(ArithmeticException.class, () -> cos.resetCount());
+
+        mock.close();
+
+        // Test long methods
+        IOUtils.copyLarge(mock, cos);
+        assertEquals(size, cos.getByteCount(), "getByteCount()");
+        assertEquals(size, cos.resetByteCount(), "resetByteCount()");
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/DeferredFileOutputStreamTest.java b/src/test/java/org/apache/commons/io/output/DeferredFileOutputStreamTest.java
new file mode 100644
index 0000000..0dfb4ec
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/DeferredFileOutputStreamTest.java
@@ -0,0 +1,360 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.stream.IntStream;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Unit tests for the {@code DeferredFileOutputStream} class.
+ *
+ */
+public class DeferredFileOutputStreamTest {
+
+    public static IntStream data() {
+        return IntStream.of(1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096);
+    }
+
+    /**
+     * The test data as a string (which is the simplest form).
+     */
+    private final String testString = "0123456789";
+
+    /**
+     * The test data as a byte array, derived from the string.
+     */
+    private final byte[] testBytes = testString.getBytes();
+
+    /**
+     * Tests the case where the amount of data exceeds the threshold, and is therefore written to disk. The actual data
+     * written to disk is verified, as is the file itself.
+     */
+    @ParameterizedTest(name = "initialBufferSize = {0}")
+    @MethodSource("data")
+    public void testAboveThreshold(final int initialBufferSize) throws IOException {
+        final File testFile = new File("testAboveThreshold.dat");
+
+        // Ensure that the test starts from a clean base.
+        testFile.delete();
+
+        final DeferredFileOutputStream dfos = new DeferredFileOutputStream(testBytes.length - 5, initialBufferSize, testFile);
+        dfos.write(testBytes, 0, testBytes.length);
+        dfos.close();
+        assertFalse(dfos.isInMemory());
+        assertNull(dfos.getData());
+
+        verifyResultFile(testFile);
+
+        // Ensure that the test starts from a clean base.
+        testFile.delete();
+    }
+
+    /**
+     * Tests the case where the amount of data exceeds the threshold, and is therefore written to disk. The actual data
+     * written to disk is verified, as is the file itself.
+     * Testing the getInputStream() method.
+     */
+    @ParameterizedTest(name = "initialBufferSize = {0}")
+    @MethodSource("data")
+    public void testAboveThresholdGetInputStream(final int initialBufferSize, final @TempDir Path tempDir) throws IOException {
+        final File testFile = tempDir.resolve("testAboveThreshold.dat").toFile();
+
+        final DeferredFileOutputStream dfos = new DeferredFileOutputStream(testBytes.length - 5, initialBufferSize,
+            testFile);
+        dfos.write(testBytes, 0, testBytes.length);
+        dfos.close();
+        assertFalse(dfos.isInMemory());
+
+        try (InputStream is = dfos.toInputStream()) {
+            assertArrayEquals(testBytes, IOUtils.toByteArray(is));
+        }
+
+        verifyResultFile(testFile);
+    }
+
+    /**
+     * Tests the case where the amount of data is exactly the same as the threshold. The behavior should be the same as
+     * that for the amount of data being below (i.e. not exceeding) the threshold.
+     */
+    @ParameterizedTest(name = "initialBufferSize = {0}")
+    @MethodSource("data")
+    public void testAtThreshold(final int initialBufferSize) throws IOException {
+        final DeferredFileOutputStream dfos = new DeferredFileOutputStream(testBytes.length, initialBufferSize, null);
+        dfos.write(testBytes, 0, testBytes.length);
+        dfos.close();
+        assertTrue(dfos.isInMemory());
+
+        final byte[] resultBytes = dfos.getData();
+        assertEquals(testBytes.length, resultBytes.length);
+        assertArrayEquals(resultBytes, testBytes);
+    }
+
+    /**
+     * Tests the case where the amount of data falls below the threshold, and is therefore confined to memory.
+     * @throws IOException
+     */
+    @ParameterizedTest(name = "initialBufferSize = {0}")
+    @MethodSource("data")
+    public void testBelowThreshold(final int initialBufferSize) throws IOException {
+        final DeferredFileOutputStream dfos = new DeferredFileOutputStream(testBytes.length + 42, initialBufferSize, null);
+        dfos.write(testBytes, 0, testBytes.length);
+        dfos.close();
+        assertTrue(dfos.isInMemory());
+
+        final byte[] resultBytes = dfos.getData();
+        assertEquals(testBytes.length, resultBytes.length);
+        assertArrayEquals(resultBytes, testBytes);
+    }
+
+    /**
+     * Tests the case where the amount of data falls below the threshold, and is therefore confined to memory.
+     * Testing the getInputStream() method.
+     */
+    @ParameterizedTest(name = "initialBufferSize = {0}")
+    @MethodSource("data")
+    public void testBelowThresholdGetInputStream(final int initialBufferSize) throws IOException {
+        final DeferredFileOutputStream dfos = new DeferredFileOutputStream(testBytes.length + 42, initialBufferSize,
+            null);
+        dfos.write(testBytes, 0, testBytes.length);
+        dfos.close();
+        assertTrue(dfos.isInMemory());
+
+        try (InputStream is = dfos.toInputStream()) {
+            assertArrayEquals(testBytes, IOUtils.toByteArray(is));
+        }
+    }
+
+    /**
+     * Test specifying a temporary file and the threshold is reached.
+     */
+    @ParameterizedTest(name = "initialBufferSize = {0}")
+    @MethodSource("data")
+    public void testTempFileAboveThreshold(final int initialBufferSize) throws IOException {
+
+        final String prefix = "commons-io-test";
+        final String suffix = ".out";
+        final File tempDir = FileUtils.current();
+        final DeferredFileOutputStream dfos = new DeferredFileOutputStream(testBytes.length - 5, initialBufferSize, prefix, suffix, tempDir);
+        assertNull(dfos.getFile(), "Check file is null-A");
+        dfos.write(testBytes, 0, testBytes.length);
+        dfos.close();
+        assertFalse(dfos.isInMemory());
+        assertNull(dfos.getData());
+        assertNotNull(dfos.getFile(), "Check file not null");
+        assertTrue(dfos.getFile().exists(), "Check file exists");
+        assertTrue(dfos.getFile().getName().startsWith(prefix), "Check prefix");
+        assertTrue(dfos.getFile().getName().endsWith(suffix), "Check suffix");
+        assertEquals(tempDir.getPath(), dfos.getFile().getParent(), "Check dir");
+
+        verifyResultFile(dfos.getFile());
+
+        // Delete the temporary file.
+        dfos.getFile().delete();
+    }
+
+    /**
+     * Test specifying a temporary file and the threshold is reached.
+     * @throws IOException
+     */
+    @ParameterizedTest(name = "initialBufferSize = {0}")
+    @MethodSource("data")
+    public void testTempFileAboveThresholdPrefixOnly(final int initialBufferSize) throws IOException {
+
+        final String prefix = "commons-io-test";
+        final String suffix = null;
+        final File tempDir = null;
+        final DeferredFileOutputStream dfos = new DeferredFileOutputStream(testBytes.length - 5, initialBufferSize, prefix, suffix, tempDir);
+        assertNull(dfos.getFile(), "Check file is null-A");
+        dfos.write(testBytes, 0, testBytes.length);
+        dfos.close();
+        assertFalse(dfos.isInMemory());
+        assertNull(dfos.getData());
+        assertNotNull(dfos.getFile(), "Check file not null");
+        assertTrue(dfos.getFile().exists(), "Check file exists");
+        assertTrue(dfos.getFile().getName().startsWith(prefix), "Check prefix");
+        assertTrue(dfos.getFile().getName().endsWith(".tmp"), "Check suffix"); // ".tmp" is default
+
+        verifyResultFile(dfos.getFile());
+
+        // Delete the temporary file.
+        dfos.getFile().delete();
+    }
+
+    /**
+     * Test specifying a temporary file and the threshold not reached.
+     * @throws IOException
+     */
+    @ParameterizedTest(name = "initialBufferSize = {0}")
+    @MethodSource("data")
+    public void testTempFileBelowThreshold(final int initialBufferSize) throws IOException {
+
+        final String prefix = "commons-io-test";
+        final String suffix = ".out";
+        final File tempDir = FileUtils.current();
+        final DeferredFileOutputStream dfos = new DeferredFileOutputStream(testBytes.length + 42, initialBufferSize, prefix, suffix, tempDir);
+        assertNull(dfos.getFile(), "Check file is null-A");
+        dfos.write(testBytes, 0, testBytes.length);
+        dfos.close();
+        assertTrue(dfos.isInMemory());
+        assertNull(dfos.getFile(), "Check file is null-B");
+    }
+
+    /**
+     * Test specifying a temporary file and the threshold is reached.
+     *
+     * @throws Exception
+     */
+    @Test
+    public void testTempFileError() throws Exception {
+        final String prefix = null;
+        final String suffix = ".out";
+        final File tempDir = FileUtils.current();
+        assertThrows(NullPointerException.class, () -> new DeferredFileOutputStream(testBytes.length - 5, prefix, suffix, tempDir).close());
+    }
+
+    /**
+     * Tests the case where there are multiple writes beyond the threshold, to ensure that the
+     * {@code thresholdReached()} method is only called once, as the threshold is crossed for the first time.
+     * @throws IOException
+     */
+    @ParameterizedTest(name = "initialBufferSize = {0}")
+    @MethodSource("data")
+    public void testThresholdReached(final int initialBufferSize) throws IOException {
+        final File testFile = new File("testThresholdReached.dat");
+
+        // Ensure that the test starts from a clean base.
+        testFile.delete();
+
+        final DeferredFileOutputStream dfos = new DeferredFileOutputStream(testBytes.length / 2, initialBufferSize, testFile);
+        final int chunkSize = testBytes.length / 3;
+        dfos.write(testBytes, 0, chunkSize);
+        dfos.write(testBytes, chunkSize, chunkSize);
+        dfos.write(testBytes, chunkSize * 2, testBytes.length - chunkSize * 2);
+        dfos.close();
+        assertFalse(dfos.isInMemory());
+        assertNull(dfos.getData());
+
+        verifyResultFile(testFile);
+
+        // Ensure that the test starts from a clean base.
+        testFile.delete();
+    }
+
+    /**
+     * Test whether writeTo() properly writes large content.
+     */
+    @ParameterizedTest(name = "initialBufferSize = {0}")
+    @MethodSource("data")
+    public void testWriteToLarge(final int initialBufferSize) throws IOException {
+        final File testFile = new File("testWriteToFile.dat");
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream(initialBufferSize);
+        // Ensure that the test starts from a clean base.
+        testFile.delete();
+
+        final DeferredFileOutputStream dfos = new DeferredFileOutputStream(testBytes.length / 2, testFile);
+        dfos.write(testBytes);
+
+        assertTrue(testFile.exists());
+        assertFalse(dfos.isInMemory());
+
+        assertThrows(IOException.class, () -> dfos.writeTo(baos));
+
+        dfos.close();
+        dfos.writeTo(baos);
+        final byte[] copiedBytes = baos.toByteArray();
+        assertArrayEquals(testBytes, copiedBytes);
+        verifyResultFile(testFile);
+        testFile.delete();
+    }
+
+    /**
+     * Test whether writeTo() properly writes small content.
+     * @throws IOException
+     */
+    @ParameterizedTest(name = "initialBufferSize = {0}")
+    @MethodSource("data")
+    public void testWriteToSmall(final int initialBufferSize) throws IOException {
+        final File testFile = new File("testWriteToMem.dat");
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream(initialBufferSize);
+        // Ensure that the test starts from a clean base.
+        testFile.delete();
+
+        final DeferredFileOutputStream dfos = new DeferredFileOutputStream(testBytes.length * 2, initialBufferSize, testFile);
+        dfos.write(testBytes);
+
+        assertFalse(testFile.exists());
+        assertTrue(dfos.isInMemory());
+
+        assertThrows(IOException.class, () -> dfos.writeTo(baos));
+
+        dfos.close();
+        dfos.writeTo(baos);
+        final byte[] copiedBytes = baos.toByteArray();
+        assertArrayEquals(testBytes, copiedBytes);
+
+        testFile.delete();
+    }
+
+    /**
+     * Verifies that the specified file contains the same data as the original test data.
+     *
+     * @param testFile The file containing the test output.
+     */
+    private void verifyResultFile(final File testFile) {
+        try {
+            final InputStream fis = Files.newInputStream(testFile.toPath());
+            assertEquals(testBytes.length, fis.available());
+
+            final byte[] resultBytes = new byte[testBytes.length];
+            assertEquals(testBytes.length, fis.read(resultBytes));
+
+            assertArrayEquals(resultBytes, testBytes);
+            assertEquals(-1, fis.read(resultBytes));
+
+            try {
+                fis.close();
+            } catch (final IOException e) {
+                // Ignore an exception on close
+            }
+        } catch (final FileNotFoundException e) {
+            fail("Unexpected FileNotFoundException");
+        } catch (final IOException e) {
+            fail("Unexpected IOException");
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/output/FileWriterWithEncodingTest.java b/src/test/java/org/apache/commons/io/output/FileWriterWithEncodingTest.java
new file mode 100644
index 0000000..4bf6b68
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/FileWriterWithEncodingTest.java
@@ -0,0 +1,265 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.apache.commons.io.test.TestUtils.checkFile;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetEncoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Tests that the encoding is actually set and used.
+ *
+ */
+public class FileWriterWithEncodingTest {
+
+    @TempDir
+    public File temporaryFolder;
+
+    private String defaultEncoding;
+    private File file1;
+    private File file2;
+    private String textContent;
+    private final char[] anotherTestContent = {'f', 'z', 'x'};
+
+    @Test
+    public void constructor_File_directory() {
+        assertThrows(IOException.class, () -> {
+            try (Writer writer = new FileWriterWithEncoding(temporaryFolder, defaultEncoding)) {
+                // empty
+            }
+        });
+        assertFalse(file1.exists());
+    }
+
+    @Test
+    public void constructor_File_encoding_badEncoding() {
+        assertThrows(IOException.class, () -> {
+            try (Writer writer = new FileWriterWithEncoding(file1, "BAD-ENCODE")) {
+                // empty
+            }
+        });
+        assertFalse(file1.exists());
+    }
+
+    @Test
+    public void constructor_File_existingFile_withContent() throws Exception {
+        try (FileWriter fw1 = new FileWriter(file1);) {
+            fw1.write(textContent);
+            fw1.write(65);
+        }
+        assertEquals(1025, file1.length());
+
+        try (FileWriterWithEncoding fw1 = new FileWriterWithEncoding(file1, defaultEncoding)) {
+            fw1.write("ABcd");
+        }
+
+        assertEquals(4, file1.length());
+    }
+
+    @Test
+    public void constructor_File_nullFile() {
+        assertThrows(NullPointerException.class, () -> {
+            try (Writer writer = new FileWriterWithEncoding((File) null, defaultEncoding)) {
+                // empty
+            }
+        });
+        assertFalse(file1.exists());
+    }
+
+    @Test
+    public void constructor_fileName_nullFile() {
+        assertThrows(NullPointerException.class, () -> {
+            try (Writer writer = new FileWriterWithEncoding((String) null, defaultEncoding)) {
+                // empty
+            }
+        });
+        assertFalse(file1.exists());
+    }
+
+    @Test
+    public void constructorAppend_File_existingFile_withContent() throws Exception {
+        try (FileWriter fw1 = new FileWriter(file1)) {
+            fw1.write("ABcd");
+        }
+        assertEquals(4, file1.length());
+
+        try (FileWriterWithEncoding fw1 = new FileWriterWithEncoding(file1, defaultEncoding, true)) {
+            fw1.write("XyZ");
+        }
+
+        assertEquals(7, file1.length());
+    }
+
+    @Test
+    public void sameEncoding_Charset_constructor() throws Exception {
+        try (FileWriterWithEncoding writer = new FileWriterWithEncoding(file2, Charset.defaultCharset())) {
+            successfulRun(writer);
+        }
+    }
+
+    @Test
+    public void sameEncoding_CharsetEncoder_constructor() throws Exception {
+        try (FileWriterWithEncoding writer = new FileWriterWithEncoding(file2, Charset.defaultCharset().newEncoder())) {
+            successfulRun(writer);
+        }
+    }
+
+    @Test
+    public void sameEncoding_null_Charset_constructor() throws Exception {
+        try (FileWriterWithEncoding writer = new FileWriterWithEncoding(file2, (Charset) null)) {
+            successfulRun(writer);
+        }
+    }
+
+    @Test
+    public void sameEncoding_null_CharsetEncoder_constructor() throws Exception {
+        try (FileWriterWithEncoding writer = new FileWriterWithEncoding(file2.getPath(), (CharsetEncoder) null)) {
+            successfulRun(writer);
+        }
+    }
+
+    @Test
+    public void sameEncoding_null_CharsetName_constructor() throws Exception {
+        try (FileWriterWithEncoding writer = new FileWriterWithEncoding(file2.getPath(), (String) null)) {
+            successfulRun(writer);
+        }
+    }
+
+    @Test
+    public void sameEncoding_string_Charset_constructor() throws Exception {
+        try (FileWriterWithEncoding writer = new FileWriterWithEncoding(file2.getPath(), Charset.defaultCharset())) {
+            successfulRun(writer);
+        }
+    }
+
+    @Test
+    public void sameEncoding_string_CharsetEncoder_constructor() throws Exception {
+        try (FileWriterWithEncoding writer = new FileWriterWithEncoding(file2.getPath(), Charset.defaultCharset().newEncoder())) {
+            successfulRun(writer);
+        }
+    }
+
+    @Test
+    public void sameEncoding_string_constructor() throws Exception {
+        try (FileWriterWithEncoding writer = new FileWriterWithEncoding(file2, defaultEncoding)) {
+            successfulRun(writer);
+        }
+    }
+
+    @Test
+    public void sameEncoding_string_string_constructor() throws Exception {
+        try (FileWriterWithEncoding writer = new FileWriterWithEncoding(file2.getPath(), defaultEncoding)) {
+            successfulRun(writer);
+        }
+    }
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        final File encodingFinder = new File(temporaryFolder, "finder.txt");
+        try (OutputStreamWriter out = new OutputStreamWriter(Files.newOutputStream(encodingFinder.toPath()))) {
+            defaultEncoding = out.getEncoding();
+        }
+        file1 = new File(temporaryFolder, "testfile1.txt");
+        file2 = new File(temporaryFolder, "testfile2.txt");
+        final char[] arr = new char[1024];
+        final char[] chars = "ABCDEFGHIJKLMNOPQabcdefgihklmnopq".toCharArray();
+        for (int i = 0; i < arr.length; i++) {
+            arr[i] = chars[i % chars.length];
+        }
+        textContent = new String(arr);
+    }
+
+    private void successfulRun(final FileWriterWithEncoding fw21) throws Exception {
+        try (FileWriter fw1 = new FileWriter(file1); // default encoding
+            FileWriterWithEncoding fw2 = fw21) {
+            writeTestPayload(fw1, fw2);
+            checkFile(file1, file2);
+        }
+        assertTrue(file1.exists());
+        assertTrue(file2.exists());
+    }
+
+    @Test
+    public void testDifferentEncoding() throws Exception {
+        if (Charset.isSupported(StandardCharsets.UTF_16BE.name())) {
+            try (FileWriter fw1 = new FileWriter(file1); // default encoding
+                FileWriterWithEncoding fw2 = new FileWriterWithEncoding(file2, defaultEncoding)) {
+                writeTestPayload(fw1, fw2);
+                try {
+                    checkFile(file1, file2);
+                    fail();
+                } catch (final AssertionError ex) {
+                    // success
+                }
+
+            }
+            assertTrue(file1.exists());
+            assertTrue(file2.exists());
+        }
+        if (Charset.isSupported(StandardCharsets.UTF_16LE.name())) {
+            try (FileWriter fw1 = new FileWriter(file1); // default encoding
+                FileWriterWithEncoding fw2 = new FileWriterWithEncoding(file2, defaultEncoding)) {
+                writeTestPayload(fw1, fw2);
+                try {
+                    checkFile(file1, file2);
+                    fail();
+                } catch (final AssertionError ex) {
+                    // success
+                }
+
+            }
+            assertTrue(file1.exists());
+            assertTrue(file2.exists());
+        }
+    }
+
+    private void writeTestPayload(final FileWriter fw1, final FileWriterWithEncoding fw2) throws IOException {
+        assertTrue(file1.exists());
+        assertTrue(file2.exists());
+
+        fw1.write(textContent);
+        fw2.write(textContent);
+        fw1.write(65);
+        fw2.write(65);
+        fw1.write(anotherTestContent);
+        fw2.write(anotherTestContent);
+        fw1.write(anotherTestContent, 1, 2);
+        fw2.write(anotherTestContent, 1, 2);
+        fw1.write("CAFE", 1, 2);
+        fw2.write("CAFE", 1, 2);
+
+        fw1.flush();
+        fw2.flush();
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/output/LockableFileWriterTest.java b/src/test/java/org/apache/commons/io/output/LockableFileWriterTest.java
new file mode 100644
index 0000000..4fb008a
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/LockableFileWriterTest.java
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.charset.UnsupportedCharsetException;
+
+import org.apache.commons.io.FileUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Tests that files really lock, although no writing is done as the locking is tested only on construction.
+ *
+ */
+public class LockableFileWriterTest {
+
+    @TempDir
+    public File temporaryFolder;
+
+    private File file;
+    private File lockDir;
+    private File lockFile;
+    private File altLockDir;
+    private File altLockFile;
+
+    @BeforeEach
+    public void setUp() {
+        file = new File(temporaryFolder, "testlockfile");
+        lockDir = FileUtils.getTempDirectory();
+        lockFile = new File(lockDir, file.getName() + ".lck");
+        altLockDir = temporaryFolder;
+        altLockFile = new File(altLockDir, file.getName() + ".lck");
+    }
+
+    @Test
+    public void testAlternateLockDir() throws IOException {
+        // open a valid lockable writer
+        try (LockableFileWriter lfw1 = new LockableFileWriter(file, StandardCharsets.UTF_8, true, altLockDir.getAbsolutePath())) {
+            assertTrue(file.exists());
+            assertTrue(altLockFile.exists());
+
+            // try to open a second writer
+            try (LockableFileWriter lfw2 = new LockableFileWriter(file, StandardCharsets.UTF_8, true, altLockDir.getAbsolutePath())) {
+                fail("Somehow able to open a locked file. ");
+            } catch (final IOException ioe) {
+                final String msg = ioe.getMessage();
+                assertTrue(msg.startsWith("Can't write file, lock "), "Exception message does not start correctly. ");
+                assertTrue(file.exists());
+                assertTrue(altLockFile.exists());
+            }
+        }
+        assertTrue(file.exists());
+        assertFalse(altLockFile.exists());
+    }
+
+    @Test
+    public void testConstructor_File_directory() {
+        assertThrows(IOException.class, () -> new LockableFileWriter(temporaryFolder));
+        assertFalse(file.exists());
+        assertFalse(lockFile.exists());
+        // again
+        assertFalse(file.exists());
+        assertFalse(lockFile.exists());
+    }
+
+    @Test
+    public void testConstructor_File_encoding_badEncoding() {
+        assertThrows(UnsupportedCharsetException.class, () -> new LockableFileWriter(file, "BAD-ENCODE"));
+        assertFalse(file.exists());
+        assertFalse(lockFile.exists());
+        // again
+        assertFalse(file.exists());
+        assertFalse(lockFile.exists());
+    }
+
+    @Test
+    public void testConstructor_File_nullFile() {
+        assertThrows(NullPointerException.class, () -> new LockableFileWriter((File) null));
+        assertFalse(file.exists());
+        assertFalse(lockFile.exists());
+        // again
+        assertFalse(file.exists());
+        assertFalse(lockFile.exists());
+    }
+
+    @Test
+    public void testConstructor_fileName_nullFile() {
+        assertThrows(NullPointerException.class, () -> new LockableFileWriter((String) null));
+        assertFalse(file.exists());
+        assertFalse(lockFile.exists());
+        // again
+        assertFalse(file.exists());
+        assertFalse(lockFile.exists());
+    }
+
+    @Test
+    public void testFileLocked() throws IOException {
+
+        // open a valid lockable writer
+        try (LockableFileWriter lfw1 = new LockableFileWriter(file)) {
+            assertTrue(file.exists());
+            assertTrue(lockFile.exists());
+
+            // try to open a second writer
+            try (LockableFileWriter lfw2 = new LockableFileWriter(file)) {
+                fail("Somehow able to open a locked file. ");
+            } catch (final IOException ioe) {
+                final String msg = ioe.getMessage();
+                assertTrue(msg.startsWith("Can't write file, lock "), "Exception message does not start correctly. ");
+                assertTrue(file.exists());
+                assertTrue(lockFile.exists());
+            }
+
+            // try to open a third writer
+            try (LockableFileWriter lfw3 = new LockableFileWriter(file)) {
+                fail("Somehow able to open a locked file. ");
+            } catch (final IOException ioe) {
+                final String msg = ioe.getMessage();
+                assertTrue(msg.startsWith("Can't write file, lock "), "Exception message does not start correctly. ");
+                assertTrue(file.exists());
+                assertTrue(lockFile.exists());
+            }
+        }
+        assertTrue(file.exists());
+        assertFalse(lockFile.exists());
+    }
+
+    @Test
+    public void testFileNotLocked() throws IOException {
+        // open a valid lockable writer
+        try (LockableFileWriter lfw1 = new LockableFileWriter(file)) {
+            assertTrue(file.exists());
+            assertTrue(lockFile.exists());
+        }
+        assertTrue(file.exists());
+        assertFalse(lockFile.exists());
+
+        // open a second valid writer on the same file
+        try (LockableFileWriter lfw2 = new LockableFileWriter(file)) {
+            assertTrue(file.exists());
+            assertTrue(lockFile.exists());
+        }
+        assertTrue(file.exists());
+        assertFalse(lockFile.exists());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/NullAppendableTest.java b/src/test/java/org/apache/commons/io/output/NullAppendableTest.java
new file mode 100644
index 0000000..d776064
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/NullAppendableTest.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Really not a lot to do here, but checking that no Exceptions are thrown.
+ */
+public class NullAppendableTest {
+
+    @Test
+    public void testNull() throws IOException {
+        final Appendable appendable = NullAppendable.INSTANCE;
+        appendable.append('a');
+        appendable.append("A");
+        appendable.append("A", 0, 1);
+        appendable.append(null, 0, 1);
+        appendable.append(null, -1, -1);
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/NullOutputStreamTest.java b/src/test/java/org/apache/commons/io/output/NullOutputStreamTest.java
new file mode 100644
index 0000000..0991da0
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/NullOutputStreamTest.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Really not a lot to do here, but checking that no Exceptions are thrown.
+ */
+public class NullOutputStreamTest {
+
+    private void process(final NullOutputStream nos) throws IOException {
+        nos.write("string".getBytes());
+        nos.write("some string".getBytes(), 3, 5);
+        nos.write(1);
+        nos.write(0x0f);
+        nos.flush();
+        nos.close();
+        nos.write("allowed".getBytes());
+        nos.write(255);
+    }
+
+    @Test
+    public void testNewInstance() throws IOException {
+        try (NullOutputStream nos = NullOutputStream.INSTANCE) {
+            process(nos);
+        }
+    }
+
+    @Test
+    public void testSingleton() throws IOException {
+        try (NullOutputStream nos = NullOutputStream.INSTANCE) {
+            process(nos);
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/NullPrintStreamTest.java b/src/test/java/org/apache/commons/io/output/NullPrintStreamTest.java
new file mode 100644
index 0000000..80513fe
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/NullPrintStreamTest.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Really not a lot to do here, but checking that no Exceptions are thrown.
+ */
+public class NullPrintStreamTest {
+
+    private void process(final NullPrintStream nos) throws IOException {
+        nos.write("string".getBytes());
+        nos.write("some string".getBytes(), 3, 5);
+        nos.write(1);
+        nos.write(0x0f);
+        nos.flush();
+        nos.close();
+        nos.write("allowed".getBytes());
+        nos.write(255);
+    }
+
+    @Test
+    public void testNullNewInstance() throws IOException {
+        try (NullPrintStream nos = new NullPrintStream()) {
+            process(nos);
+        }
+    }
+
+    @Test
+    public void testNullSingleton() throws IOException {
+        try (NullPrintStream nos = NullPrintStream.INSTANCE) {
+            process(nos);
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/NullWriterTest.java b/src/test/java/org/apache/commons/io/output/NullWriterTest.java
new file mode 100644
index 0000000..d657546
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/NullWriterTest.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Really not a lot to do here, but checking that no
+ * Exceptions are thrown.
+ */
+public class NullWriterTest {
+
+    @Test
+    public void testNull() {
+        final char[] chars = { 'A', 'B', 'C' };
+        try (NullWriter writer = NullWriter.INSTANCE) {
+            writer.write(1);
+            writer.write(chars);
+            writer.write(chars, 1, 1);
+            writer.write("some string");
+            writer.write("some string", 2, 2);
+            writer.flush();
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/ProxyCollectionWriterTest.java b/src/test/java/org/apache/commons/io/output/ProxyCollectionWriterTest.java
new file mode 100644
index 0000000..02e01b9
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/ProxyCollectionWriterTest.java
@@ -0,0 +1,470 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.util.Arrays;
+import java.util.Collection;
+
+import org.apache.commons.io.IOExceptionList;
+import org.apache.commons.io.IOIndexedException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link ProxyCollectionWriter}.
+ */
+public class ProxyCollectionWriterTest {
+
+    @Test
+    public void testArrayIOExceptionOnAppendChar1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(badW, goodW, null);
+        final char data = 'A';
+        try {
+            tw.append(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).append(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnAppendChar2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(goodW, badW, null);
+        final char data = 'A';
+        try {
+            tw.append(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).append(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnAppendCharSequence1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(badW, goodW, null);
+        final CharSequence data = "A";
+        try {
+            tw.append(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).append(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnAppendCharSequence2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(goodW, badW, null);
+        final CharSequence data = "A";
+        try {
+            tw.append(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).append(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnAppendCharSequenceIntInt1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(badW, goodW, null);
+        final CharSequence data = "A";
+        try {
+            tw.append(data, 0, 0);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).append(data, 0, 0);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnAppendCharSequenceIntInt2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(goodW, badW, null);
+        final CharSequence data = "A";
+        try {
+            tw.append(data, 0, 0);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).append(data, 0, 0);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnClose1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(badW, goodW, null);
+        try {
+            tw.close();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).close();
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnClose2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(goodW, badW, null);
+        try {
+            tw.close();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).close();
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnFlush1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(badW, goodW, null);
+        try {
+            tw.flush();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).flush();
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnFlush2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(goodW, badW, null);
+        try {
+            tw.flush();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).flush();
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteCharArray1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(badW, goodW, null);
+        final char[] data = { 'a' };
+        try {
+            tw.write(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteCharArray2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(goodW, badW, null);
+        final char[] data = { 'a' };
+        try {
+            tw.write(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteCharArrayIntInt1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(badW, goodW, null);
+        final char[] data = { 'a' };
+        try {
+            tw.write(data, 0, 0);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data, 0, 0);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteCharArrayIntInt2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(goodW, badW, null);
+        final char[] data = { 'a' };
+        try {
+            tw.write(data, 0, 0);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data, 0, 0);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteInt1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(badW, goodW, null);
+        final int data = 32;
+        try {
+            tw.write(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteInt2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(goodW, badW, null);
+        try {
+            tw.write(32);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(32);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteString1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(badW, goodW, null);
+        final String data = "A";
+        try {
+            tw.write(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteString2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(goodW, badW, null);
+        final String data = "A";
+        try {
+            tw.write(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteStringIntInt1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(badW, goodW, null);
+        final String data = "A";
+        try {
+            tw.write(data, 0, 0);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data, 0, 0);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteStringIntInt2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(goodW, badW, null);
+        final String data = "A";
+        try {
+            tw.write(data, 0, 0);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data, 0, 0);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+
+        }
+    }
+
+    @Test
+    public void testCollectionCloseBranchIOException() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        @SuppressWarnings("resource") // not necessary to close this
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(Arrays.asList(goodW, badW, null));
+        try {
+            tw.close();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).close();
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+
+        }
+    }
+
+    @Test
+    public void testConstructorsNull() throws IOException {
+        try (ProxyCollectionWriter teeWriter = new ProxyCollectionWriter((Writer[]) null)) {
+            // Call any method, should not throw
+            teeWriter.append('a');
+            teeWriter.flush();
+        }
+        try (ProxyCollectionWriter teeWriter = new ProxyCollectionWriter((Collection<Writer>) null)) {
+            // Call any method, should not throw
+            teeWriter.append('a');
+            teeWriter.flush();
+        }
+        assertTrue(true, "Dummy to show test completed OK");
+    }
+
+    @Test
+    public void testTee() throws IOException {
+        try (StringBuilderWriter sbw1 = new StringBuilderWriter();
+                StringBuilderWriter sbw2 = new StringBuilderWriter();
+                StringBuilderWriter expected = new StringBuilderWriter();
+                ProxyCollectionWriter tw = new ProxyCollectionWriter(sbw1, sbw2, null)) {
+            for (int i = 0; i < 20; i++) {
+                tw.write(i);
+                expected.write(i);
+            }
+            assertEquals(expected.toString(), sbw1.toString(), "ProxyCollectionWriter.write(int)");
+            assertEquals(expected.toString(), sbw2.toString(), "ProxyCollectionWriter.write(int)");
+
+            final char[] array = new char[10];
+            for (int i = 20; i < 30; i++) {
+                array[i - 20] = (char) i;
+            }
+            tw.write(array);
+            expected.write(array);
+            assertEquals(expected.toString(), sbw1.toString(), "ProxyCollectionWriter.write(char[])");
+            assertEquals(expected.toString(), sbw2.toString(), "ProxyCollectionWriter.write(char[])");
+
+            for (int i = 25; i < 35; i++) {
+                array[i - 25] = (char) i;
+            }
+            tw.write(array, 5, 5);
+            expected.write(array, 5, 5);
+            assertEquals(expected.toString(), sbw1.toString(), "TeeOutputStream.write(byte[], int, int)");
+            assertEquals(expected.toString(), sbw2.toString(), "TeeOutputStream.write(byte[], int, int)");
+
+            for (int i = 0; i < 20; i++) {
+                tw.append((char) i);
+                expected.append((char) i);
+            }
+            assertEquals(expected.toString(), sbw1.toString(), "ProxyCollectionWriter.append(char)");
+            assertEquals(expected.toString(), sbw2.toString(), "ProxyCollectionWriter.append(char)");
+
+            for (int i = 20; i < 30; i++) {
+                array[i - 20] = (char) i;
+            }
+            tw.append(new String(array));
+            expected.append(new String(array));
+            assertEquals(expected.toString(), sbw1.toString(), "ProxyCollectionWriter.append(CharSequence)");
+            assertEquals(expected.toString(), sbw2.toString(), "ProxyCollectionWriter.write(CharSequence)");
+
+            for (int i = 25; i < 35; i++) {
+                array[i - 25] = (char) i;
+            }
+            tw.append(new String(array), 5, 5);
+            expected.append(new String(array), 5, 5);
+            assertEquals(expected.toString(), sbw1.toString(), "ProxyCollectionWriter.append(CharSequence, int, int)");
+            assertEquals(expected.toString(), sbw2.toString(), "ProxyCollectionWriter.append(CharSequence, int, int)");
+
+            expected.flush();
+            expected.close();
+
+            tw.flush();
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/ProxyOutputStreamTest.java b/src/test/java/org/apache/commons/io/output/ProxyOutputStreamTest.java
new file mode 100644
index 0000000..c49d38b
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/ProxyOutputStreamTest.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link CloseShieldOutputStream}.
+ */
+public class ProxyOutputStreamTest {
+
+    private ByteArrayOutputStream original;
+
+    private OutputStream proxied;
+
+    @BeforeEach
+    public void setUp() {
+        original = new ByteArrayOutputStream(){
+            @Override
+            public void write(final byte[] ba) throws IOException {
+                if (ba != null){
+                    super.write(ba);
+                }
+            }
+        };
+        proxied = new ProxyOutputStream(original);
+    }
+
+    @Test
+    public void testWrite() throws Exception {
+        proxied.write('y');
+        assertEquals(1, original.size());
+        assertEquals('y', original.toByteArray()[0]);
+    }
+
+    @Test
+    public void testWriteNullBaSucceeds() throws Exception {
+        final byte[] ba = null;
+        original.write(ba);
+        proxied.write(ba);
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/output/ProxyWriterTest.java b/src/test/java/org/apache/commons/io/output/ProxyWriterTest.java
new file mode 100644
index 0000000..07a747c
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/ProxyWriterTest.java
@@ -0,0 +1,273 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.UnsupportedEncodingException;
+import java.io.Writer;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test {@link ProxyWriter}.
+ *
+ */
+public class ProxyWriterTest {
+
+    @Test
+    public void appendChar() throws Exception {
+        try (StringBuilderWriter writer = new StringBuilderWriter();
+                final ProxyWriter proxy = new ProxyWriter(writer)) {
+            proxy.append('c');
+            assertEquals("c", writer.toString());
+        }
+    }
+
+    @Test
+    public void appendCharSequence() throws Exception {
+        try (StringBuilderWriter writer = new StringBuilderWriter();
+                final ProxyWriter proxy = new ProxyWriter(writer)) {
+            proxy.append("ABC");
+            assertEquals("ABC", writer.toString());
+        }
+    }
+
+    @Test
+    public void appendCharSequence_with_offset() throws Exception {
+        try (StringBuilderWriter writer = new StringBuilderWriter();
+                final ProxyWriter proxy = new ProxyWriter(writer)) {
+            proxy.append("ABC", 1, 3);
+            proxy.flush();
+            assertEquals("BC", writer.toString());
+        }
+    }
+
+    @Test
+    public void exceptions_in_append_char() throws IOException {
+        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
+                final OutputStreamWriter osw = new OutputStreamWriter(baos) {
+                    @Override
+                    public void write(final int c) throws IOException {
+                        throw new UnsupportedEncodingException("Bah");
+                    }
+                }) {
+            try (ProxyWriter proxy = new ProxyWriter(osw)) {
+                assertThrows(UnsupportedEncodingException.class, () -> proxy.append('c'));
+            }
+        }
+    }
+
+    @Test
+    public void exceptions_in_append_charSequence() throws IOException {
+        try (OutputStreamWriter osw = new OutputStreamWriter(new ByteArrayOutputStream()) {
+            @Override
+            public Writer append(final CharSequence csq) throws IOException {
+                throw new UnsupportedEncodingException("Bah");
+            }
+        }) {
+            try (ProxyWriter proxy = new ProxyWriter(osw)) {
+                assertThrows(UnsupportedEncodingException.class, () -> proxy.append("ABCE"));
+            }
+        }
+    }
+
+    @Test
+    public void exceptions_in_append_charSequence_offset() throws IOException {
+        try (OutputStreamWriter osw = new OutputStreamWriter(new ByteArrayOutputStream()) {
+            @Override
+            public Writer append(final CharSequence csq, final int start, final int end) throws IOException {
+                throw new UnsupportedEncodingException("Bah");
+            }
+        }) {
+            try (ProxyWriter proxy = new ProxyWriter(osw)) {
+                assertThrows(UnsupportedEncodingException.class, () -> proxy.append("ABCE", 1, 2));
+            }
+        }
+    }
+
+    @Test
+    public void exceptions_in_close() {
+        assertThrows(UnsupportedEncodingException.class, () -> {
+            try (OutputStreamWriter osw = new OutputStreamWriter(new ByteArrayOutputStream()) {
+                @Override
+                public void close() throws IOException {
+                    throw new UnsupportedEncodingException("Bah");
+                }
+            }) {
+                try (ProxyWriter proxy = new ProxyWriter(osw)) {
+                    // noop
+                }
+            }
+        });
+    }
+
+    @Test
+    public void exceptions_in_flush() throws IOException {
+        try (OutputStreamWriter osw = new OutputStreamWriter(new ByteArrayOutputStream()) {
+            @Override
+            public void flush() throws IOException {
+                throw new UnsupportedEncodingException("Bah");
+            }
+        }) {
+            try (ProxyWriter proxy = new ProxyWriter(osw)) {
+                assertThrows(UnsupportedEncodingException.class, proxy::flush);
+            }
+        }
+    }
+
+    @Test
+    public void exceptions_in_write_char_array() throws IOException {
+        try (OutputStreamWriter osw = new OutputStreamWriter(new ByteArrayOutputStream()) {
+            @Override
+            public void write(final char[] cbuf) throws IOException {
+                throw new UnsupportedEncodingException("Bah");
+            }
+        }) {
+            try (ProxyWriter proxy = new ProxyWriter(osw)) {
+                assertThrows(UnsupportedEncodingException.class, () -> proxy.write("ABCE".toCharArray()));
+            }
+        }
+    }
+
+    @Test
+    public void exceptions_in_write_int() throws IOException {
+        try (OutputStreamWriter osw = new OutputStreamWriter(new ByteArrayOutputStream()) {
+            @Override
+            public void write(final int c) throws IOException {
+                throw new UnsupportedEncodingException("Bah");
+            }
+        }) {
+            try (ProxyWriter proxy = new ProxyWriter(osw)) {
+                assertThrows(UnsupportedEncodingException.class, () -> proxy.write('a'));
+            }
+        }
+    }
+
+    @Test
+    public void exceptions_in_write_offset_char_array() throws IOException {
+        try (OutputStreamWriter osw = new OutputStreamWriter(new ByteArrayOutputStream()) {
+            @Override
+            public void write(final char[] cbuf, final int off, final int len) throws IOException {
+                throw new UnsupportedEncodingException("Bah");
+            }
+        }) {
+            try (ProxyWriter proxy = new ProxyWriter(osw)) {
+                assertThrows(UnsupportedEncodingException.class, () -> proxy.write("ABCE".toCharArray(), 2, 3));
+            }
+        }
+    }
+
+    @Test
+    public void exceptions_in_write_string() throws IOException {
+        try (OutputStreamWriter osw = new OutputStreamWriter(new ByteArrayOutputStream()) {
+            @Override
+            public void write(final String str) throws IOException {
+                throw new UnsupportedEncodingException("Bah");
+            }
+        }) {
+            try (ProxyWriter proxy = new ProxyWriter(osw)) {
+                assertThrows(UnsupportedEncodingException.class, () -> proxy.write("ABCE"));
+            }
+        }
+    }
+
+    @Test
+    public void exceptions_in_write_string_offset() throws IOException {
+        try (OutputStreamWriter osw = new OutputStreamWriter(new ByteArrayOutputStream()) {
+            @Override
+            public void write(final String str, final int off, final int len) throws IOException {
+                throw new UnsupportedEncodingException("Bah");
+            }
+        }) {
+            try (ProxyWriter proxy = new ProxyWriter(osw)) {
+                assertThrows(UnsupportedEncodingException.class, () -> proxy.write("ABCE", 1, 3));
+            }
+        }
+    }
+
+    @Test
+    public void nullCharArray() throws Exception {
+        try (ProxyWriter proxy = new ProxyWriter(NullWriter.INSTANCE)) {
+            proxy.write((char[]) null);
+            proxy.write((char[]) null, 0, 0);
+        }
+    }
+
+    @Test
+    public void nullCharSequence() throws Exception {
+        try (ProxyWriter proxy = new ProxyWriter(NullWriter.INSTANCE)) {
+            proxy.append(null);
+        }
+    }
+
+    @Test
+    public void nullString() throws Exception {
+        try (ProxyWriter proxy = new ProxyWriter(NullWriter.INSTANCE)) {
+            proxy.write((String) null);
+            proxy.write((String) null, 0, 0);
+        }
+    }
+
+    @Test
+    public void writeCharArray() throws Exception {
+        try (StringBuilderWriter writer = new StringBuilderWriter();
+                final ProxyWriter proxy = new ProxyWriter(writer)) {
+            proxy.write(new char[] { 'A', 'B', 'C' });
+            assertEquals("ABC", writer.toString());
+        }
+    }
+
+    @Test
+    public void writeCharArrayPartial() throws Exception {
+        try (StringBuilderWriter writer = new StringBuilderWriter();
+                final ProxyWriter proxy = new ProxyWriter(writer)) {
+            proxy.write(new char[] { 'A', 'B', 'C' }, 1, 2);
+            assertEquals("BC", writer.toString());
+        }
+    }
+
+    @Test
+    public void writeInt() throws Exception {
+        try (StringBuilderWriter writer = new StringBuilderWriter();
+                final ProxyWriter proxy = new ProxyWriter(writer)) {
+            proxy.write(65);
+            assertEquals("A", writer.toString());
+        }
+    }
+
+    @Test
+    public void writeString() throws Exception {
+        try (StringBuilderWriter writer = new StringBuilderWriter();
+                final ProxyWriter proxy = new ProxyWriter(writer)) {
+            proxy.write("ABC");
+            assertEquals("ABC", writer.toString());
+        }
+    }
+
+    @Test
+    public void writeStringPartial() throws Exception {
+        try (StringBuilderWriter writer = new StringBuilderWriter();
+                final ProxyWriter proxy = new ProxyWriter(writer)) {
+            proxy.write("ABC", 1, 2);
+            assertEquals("BC", writer.toString());
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/output/QueueOutputStreamTest.java b/src/test/java/org/apache/commons/io/output/QueueOutputStreamTest.java
new file mode 100644
index 0000000..8502e05
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/QueueOutputStreamTest.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.InterruptedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Exchanger;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.LinkedBlockingQueue;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.input.QueueInputStream;
+import org.apache.commons.io.input.QueueInputStreamTest;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test {@link QueueOutputStream} and {@link QueueInputStream}
+ *
+ * @see QueueInputStreamTest
+ */
+public class QueueOutputStreamTest {
+
+    private static final ExecutorService executorService = Executors.newFixedThreadPool(5);
+
+    @AfterAll
+    public static void afterAll() {
+        executorService.shutdown();
+    }
+
+    private static <T> T callInThrowAwayThread(final Callable<T> callable) throws Exception {
+        final Exchanger<T> exchanger = new Exchanger<>();
+        executorService.submit(() -> {
+            final T value = callable.call();
+            exchanger.exchange(value);
+            return null;
+        });
+        return exchanger.exchange(null);
+    }
+
+    @Test
+    public void testNullArgument() {
+        assertThrows(NullPointerException.class, () -> new QueueOutputStream(null), "queue is required");
+    }
+
+    @Test
+    public void writeInterrupted() throws Exception {
+        try (QueueOutputStream outputStream = new QueueOutputStream(new LinkedBlockingQueue<>(1));
+                final QueueInputStream inputStream = outputStream.newQueueInputStream()) {
+
+            final int timeout = 1;
+            final Exchanger<Thread> writerThreadExchanger = new Exchanger<>();
+            final Exchanger<Exception> exceptionExchanger = new Exchanger<>();
+            executorService.submit(() -> {
+                final Thread writerThread = writerThreadExchanger.exchange(null, timeout, SECONDS);
+                writerThread.interrupt();
+                return null;
+            });
+
+            executorService.submit(() -> {
+                try {
+                    writerThreadExchanger.exchange(Thread.currentThread(), timeout, SECONDS);
+                    outputStream.write("ABC".getBytes(StandardCharsets.UTF_8));
+                } catch (final Exception e) {
+                    Thread.interrupted(); //clear interrupt
+                    exceptionExchanger.exchange(e, timeout, SECONDS);
+                }
+                return null;
+            });
+
+            final Exception exception = exceptionExchanger.exchange(null, timeout, SECONDS);
+            assertNotNull(exception);
+            assertEquals(exception.getClass(), InterruptedIOException.class);
+        }
+    }
+
+    @Test
+    public void writeString() throws Exception {
+        try (QueueOutputStream outputStream = new QueueOutputStream();
+                final QueueInputStream inputStream = outputStream.newQueueInputStream()) {
+            outputStream.write("ABC".getBytes(UTF_8));
+            final String value = IOUtils.toString(inputStream, UTF_8);
+            assertEquals("ABC", value);
+        }
+    }
+
+    @Test
+    public void writeStringMultiThread() throws Exception {
+        try (QueueOutputStream outputStream = callInThrowAwayThread(QueueOutputStream::new);
+                final QueueInputStream inputStream = callInThrowAwayThread(outputStream::newQueueInputStream)) {
+            callInThrowAwayThread(() -> {
+                outputStream.write("ABC".getBytes(UTF_8));
+                return null;
+            });
+
+            final String value = callInThrowAwayThread(() -> IOUtils.toString(inputStream, UTF_8));
+            assertEquals("ABC", value);
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/output/StringBuilderWriterTest.java b/src/test/java/org/apache/commons/io/output/StringBuilderWriterTest.java
new file mode 100644
index 0000000..f9ea879
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/StringBuilderWriterTest.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import java.io.IOException;
+import java.io.Writer;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test case for {@link StringBuilderWriter}.
+ *
+ */
+public class StringBuilderWriterTest {
+    private static final char[] FOOBAR_CHARS = {'F', 'o', 'o', 'B', 'a', 'r'};
+
+
+    @Test
+    public void testAppendChar() throws IOException {
+        try (Writer writer = new StringBuilderWriter()) {
+            writer.append('F').append('o').append('o');
+            assertEquals("Foo", writer.toString());
+        }
+    }
+
+    @Test
+    public void testAppendCharSequence() throws IOException {
+        try (Writer writer = new StringBuilderWriter()) {
+            writer.append("Foo").append("Bar");
+            assertEquals("FooBar", writer.toString());
+        }
+    }
+
+    @Test
+    public void testAppendCharSequencePortion() throws IOException {
+        try (Writer writer = new StringBuilderWriter()) {
+            writer.append("FooBar", 3, 6).append(new StringBuffer("FooBar"), 0, 3);
+            assertEquals("BarFoo", writer.toString());
+        }
+    }
+
+    @Test
+    public void testAppendConstructCapacity() throws IOException {
+        try (Writer writer = new StringBuilderWriter(100)) {
+            writer.append("Foo");
+            assertEquals("Foo", writer.toString());
+        }
+    }
+
+    @Test
+    public void testAppendConstructNull() throws IOException {
+        try (Writer writer = new StringBuilderWriter(null)) {
+            writer.append("Foo");
+            assertEquals("Foo", writer.toString());
+        }
+    }
+
+    @Test
+    public void testAppendConstructStringBuilder() {
+        final StringBuilder builder = new StringBuilder("Foo");
+        try (StringBuilderWriter writer = new StringBuilderWriter(builder)) {
+            writer.append("Bar");
+            assertEquals("FooBar", writer.toString());
+            assertSame(builder, writer.getBuilder());
+        }
+    }
+
+    @Test
+    public void testClose() throws IOException {
+        try (Writer writer = new StringBuilderWriter()) {
+            writer.append("Foo");
+            writer.close();
+            writer.append("Bar");
+            assertEquals("FooBar", writer.toString());
+        }
+    }
+
+    @Test
+    public void testWriteChar() throws IOException {
+        try (Writer writer = new StringBuilderWriter()) {
+            writer.write('F');
+            assertEquals("F", writer.toString());
+            writer.write('o');
+            assertEquals("Fo", writer.toString());
+            writer.write('o');
+            assertEquals("Foo", writer.toString());
+        }
+    }
+
+    @Test
+    public void testWriteCharArray() throws IOException {
+        try (Writer writer = new StringBuilderWriter()) {
+            writer.write(new char[] { 'F', 'o', 'o' });
+            assertEquals("Foo", writer.toString());
+            writer.write(new char[] { 'B', 'a', 'r' });
+            assertEquals("FooBar", writer.toString());
+        }
+    }
+
+    @Test
+    public void testWriteCharArrayPortion() throws IOException {
+        try (Writer writer = new StringBuilderWriter()) {
+            writer.write(FOOBAR_CHARS, 3, 3);
+            assertEquals("Bar", writer.toString());
+            writer.write(FOOBAR_CHARS, 0, 3);
+            assertEquals("BarFoo", writer.toString());
+        }
+    }
+
+    @Test
+    public void testWriteString() throws IOException {
+        try (Writer writer = new StringBuilderWriter()) {
+            writer.write("Foo");
+            assertEquals("Foo", writer.toString());
+            writer.write("Bar");
+            assertEquals("FooBar", writer.toString());
+        }
+    }
+
+    @Test
+    public void testWriteStringPortion() throws IOException {
+        try (Writer writer = new StringBuilderWriter()) {
+            writer.write("FooBar", 3, 3);
+            assertEquals("Bar", writer.toString());
+            writer.write("FooBar", 0, 3);
+            assertEquals("BarFoo", writer.toString());
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/TaggedOutputStreamTest.java b/src/test/java/org/apache/commons/io/output/TaggedOutputStreamTest.java
new file mode 100644
index 0000000..e9a5690
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/TaggedOutputStreamTest.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.UUID;
+
+import org.apache.commons.io.TaggedIOException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link TaggedOutputStream}.
+ */
+public class TaggedOutputStreamTest  {
+
+    @Test
+    public void testBrokenStream() {
+        final IOException exception = new IOException("test exception");
+        final TaggedOutputStream stream = new TaggedOutputStream(new BrokenOutputStream(exception));
+
+        // Test the write() method
+        try {
+            stream.write('x');
+            fail("Expected exception not thrown.");
+        } catch (final IOException e) {
+            assertTrue(stream.isCauseOf(e));
+            try {
+                stream.throwIfCauseOf(e);
+                fail("Expected exception not thrown.");
+            } catch (final IOException e2) {
+                assertEquals(exception, e2);
+            }
+        }
+
+        // Test the flush() method
+        try {
+            stream.flush();
+            fail("Expected exception not thrown.");
+        } catch (final IOException e) {
+            assertTrue(stream.isCauseOf(e));
+            try {
+                stream.throwIfCauseOf(e);
+                fail("Expected exception not thrown.");
+            } catch (final IOException e2) {
+                assertEquals(exception, e2);
+            }
+        }
+
+        // Test the close() method
+        try {
+            stream.close();
+            fail("Expected exception not thrown.");
+        } catch (final IOException e) {
+            assertTrue(stream.isCauseOf(e));
+            try {
+                stream.throwIfCauseOf(e);
+                fail("Expected exception not thrown.");
+            } catch (final IOException e2) {
+                assertEquals(exception, e2);
+            }
+        }
+    }
+
+    @Test
+    public void testNormalStream() {
+        try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
+            try (OutputStream stream = new TaggedOutputStream(buffer)) {
+                stream.write('a');
+                stream.write(new byte[] { 'b' });
+                stream.write(new byte[] { 'c' }, 0, 1);
+                stream.flush();
+            }
+            assertEquals(3, buffer.size());
+            assertEquals('a', buffer.toByteArray()[0]);
+            assertEquals('b', buffer.toByteArray()[1]);
+            assertEquals('c', buffer.toByteArray()[2]);
+        } catch (final IOException e) {
+            fail("Unexpected exception thrown");
+        }
+    }
+
+    @Test
+    public void testOtherException() throws Exception {
+        final IOException exception = new IOException("test exception");
+        try (TaggedOutputStream stream = new TaggedOutputStream(ClosedOutputStream.INSTANCE)) {
+
+            assertFalse(stream.isCauseOf(exception));
+            assertFalse(stream.isCauseOf(new TaggedIOException(exception, UUID.randomUUID())));
+
+            try {
+                stream.throwIfCauseOf(exception);
+            } catch (final IOException e) {
+                fail("Unexpected exception thrown");
+            }
+
+            try {
+                stream.throwIfCauseOf(new TaggedIOException(exception, UUID.randomUUID()));
+            } catch (final IOException e) {
+                fail("Unexpected exception thrown");
+            }
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/TaggedWriterTest.java b/src/test/java/org/apache/commons/io/output/TaggedWriterTest.java
new file mode 100644
index 0000000..7423c95
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/TaggedWriterTest.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.util.UUID;
+
+import org.apache.commons.io.TaggedIOException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link TaggedWriter}.
+ */
+public class TaggedWriterTest  {
+
+    @Test
+    public void testBrokenWriter() {
+        final IOException exception = new IOException("test exception");
+        final TaggedWriter writer = new TaggedWriter(new BrokenWriter(exception));
+
+        // Test the write() method
+        try {
+            writer.write(new char[] { 'x' }, 0, 1);
+            fail("Expected exception not thrown.");
+        } catch (final IOException e) {
+            assertTrue(writer.isCauseOf(e));
+            try {
+                writer.throwIfCauseOf(e);
+                fail("Expected exception not thrown.");
+            } catch (final IOException e2) {
+                assertEquals(exception, e2);
+            }
+        }
+
+        // Test the flush() method
+        try {
+            writer.flush();
+            fail("Expected exception not thrown.");
+        } catch (final IOException e) {
+            assertTrue(writer.isCauseOf(e));
+            try {
+                writer.throwIfCauseOf(e);
+                fail("Expected exception not thrown.");
+            } catch (final IOException e2) {
+                assertEquals(exception, e2);
+            }
+        }
+
+        // Test the close() method
+        try {
+            writer.close();
+            fail("Expected exception not thrown.");
+        } catch (final IOException e) {
+            assertTrue(writer.isCauseOf(e));
+            try {
+                writer.throwIfCauseOf(e);
+                fail("Expected exception not thrown.");
+            } catch (final IOException e2) {
+                assertEquals(exception, e2);
+            }
+        }
+    }
+
+    @Test
+    public void testNormalWriter() throws IOException {
+        try (StringBuilderWriter buffer = new StringBuilderWriter()) {
+            try (Writer writer = new TaggedWriter(buffer)) {
+                writer.write('a');
+                writer.write(new char[] { 'b' });
+                writer.write(new char[] { 'c' }, 0, 1);
+                writer.flush();
+            }
+            assertEquals(3, buffer.getBuilder().length());
+            assertEquals('a', buffer.getBuilder().charAt(0));
+            assertEquals('b', buffer.getBuilder().charAt(1));
+            assertEquals('c', buffer.getBuilder().charAt(2));
+        }
+    }
+
+    @Test
+    public void testOtherException() throws Exception {
+        final IOException exception = new IOException("test exception");
+        try (TaggedWriter writer = new TaggedWriter(ClosedWriter.INSTANCE)) {
+            assertFalse(writer.isCauseOf(exception));
+            assertFalse(writer.isCauseOf(new TaggedIOException(exception, UUID.randomUUID())));
+            writer.throwIfCauseOf(exception);
+            writer.throwIfCauseOf(new TaggedIOException(exception, UUID.randomUUID()));
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/TeeOutputStreamTest.java b/src/test/java/org/apache/commons/io/output/TeeOutputStreamTest.java
new file mode 100644
index 0000000..a417a00
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/TeeOutputStreamTest.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.apache.commons.io.test.ThrowOnCloseOutputStream;
+import org.junit.jupiter.api.Test;
+
+/**On
+ * JUnit Test Case for {@link TeeOutputStream}.
+ */
+public class TeeOutputStreamTest {
+
+    private void assertByteArrayEquals(final String msg, final byte[] array1, final byte[] array2) {
+        assertEquals(array1.length, array2.length, msg + ": array size mismatch");
+        for (int i = 0; i < array1.length; i++) {
+            assertEquals(array1[i], array2[i], msg + ": array[ " + i + "] mismatch");
+        }
+    }
+
+    /**
+     * Tests that the main {@code OutputStream} is closed when closing the branch {@code OutputStream} throws an
+     * exception on {@link TeeOutputStream#close()}.
+     */
+    @Test
+    public void testIOExceptionOnClose() throws IOException {
+        final OutputStream badOs = new ThrowOnCloseOutputStream();
+        final ByteArrayOutputStream goodOs = mock(ByteArrayOutputStream.class);
+        final TeeOutputStream tos = new TeeOutputStream(badOs, goodOs);
+        try {
+            tos.close();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOException e) {
+            verify(goodOs).close();
+        }
+    }
+
+    /**
+     * Tests that the branch {@code OutputStream} is closed when closing the main {@code OutputStream} throws an
+     * exception on {@link TeeOutputStream#close()}.
+     */
+    @Test
+    public void testIOExceptionOnCloseBranch() throws IOException {
+        final OutputStream badOs = new ThrowOnCloseOutputStream();
+        final ByteArrayOutputStream goodOs = mock(ByteArrayOutputStream.class);
+        final TeeOutputStream tos = new TeeOutputStream(goodOs, badOs);
+        try {
+            tos.close();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOException e) {
+            verify(goodOs).close();
+        }
+    }
+
+    @Test
+    public void testTee() throws IOException {
+        final ByteArrayOutputStream baos1 = new ByteArrayOutputStream();
+        final ByteArrayOutputStream baos2 = new ByteArrayOutputStream();
+        final ByteArrayOutputStream expected = new ByteArrayOutputStream();
+
+        try (TeeOutputStream tos = new TeeOutputStream(baos1, baos2)) {
+            for (int i = 0; i < 20; i++) {
+                tos.write(i);
+                expected.write(i);
+            }
+            assertByteArrayEquals("TeeOutputStream.write(int)", expected.toByteArray(), baos1.toByteArray());
+            assertByteArrayEquals("TeeOutputStream.write(int)", expected.toByteArray(), baos2.toByteArray());
+
+            final byte[] array = new byte[10];
+            for (int i = 20; i < 30; i++) {
+                array[i - 20] = (byte) i;
+            }
+            tos.write(array);
+            expected.write(array);
+            assertByteArrayEquals("TeeOutputStream.write(byte[])", expected.toByteArray(), baos1.toByteArray());
+            assertByteArrayEquals("TeeOutputStream.write(byte[])", expected.toByteArray(), baos2.toByteArray());
+
+            for (int i = 25; i < 35; i++) {
+                array[i - 25] = (byte) i;
+            }
+            tos.write(array, 5, 5);
+            expected.write(array, 5, 5);
+            assertByteArrayEquals("TeeOutputStream.write(byte[], int, int)", expected.toByteArray(),
+                    baos1.toByteArray());
+            assertByteArrayEquals("TeeOutputStream.write(byte[], int, int)", expected.toByteArray(),
+                    baos2.toByteArray());
+
+            expected.flush();
+            expected.close();
+
+            tos.flush();
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/TeeWriterTest.java b/src/test/java/org/apache/commons/io/output/TeeWriterTest.java
new file mode 100644
index 0000000..c3e3a80
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/TeeWriterTest.java
@@ -0,0 +1,451 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.util.Arrays;
+import java.util.Collection;
+
+import org.apache.commons.io.IOExceptionList;
+import org.apache.commons.io.IOIndexedException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link TeeWriter}.
+ */
+@SuppressWarnings("resource") // not necessary to close these resources
+public class TeeWriterTest {
+
+    @Test
+    public void testArrayIOExceptionOnAppendChar1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(badW, goodW, null);
+        final char data = 'A';
+        try {
+            tw.append(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).append(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnAppendChar2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final ProxyCollectionWriter tw = new ProxyCollectionWriter(goodW, badW, null);
+        final char data = 'A';
+        try {
+            tw.append(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).append(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnAppendCharSequence1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(badW, goodW, null);
+        final String data = "A";
+        try {
+            tw.append(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).append(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnAppendCharSequence2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(goodW, badW, null);
+        final String data = "A";
+        try {
+            tw.append(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).append(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnAppendCharSequenceIntInt1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(badW, goodW, null);
+        final String data = "A";
+        try {
+            tw.append(data, 0, 0);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).append(data, 0, 0);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnAppendCharSequenceIntInt2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(goodW, badW, null);
+        final String data = "A";
+        try {
+            tw.append(data, 0, 0);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).append(data, 0, 0);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnClose1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(badW, goodW, null);
+        try {
+            tw.close();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).close();
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnClose2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(goodW, badW, null);
+        try {
+            tw.close();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).close();
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnFlush1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(badW, goodW, null);
+        try {
+            tw.flush();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).flush();
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnFlush2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(goodW, badW, null);
+        try {
+            tw.flush();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).flush();
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteCharArray1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(badW, goodW, null);
+        final char[] data = { 'a' };
+        try {
+            tw.write(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteCharArray2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(goodW, badW, null);
+        final char[] data = { 'a' };
+        try {
+            tw.write(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteCharArrayIntInt1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(badW, goodW, null);
+        final char[] data = { 'a' };
+        try {
+            tw.write(data, 0, 0);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data, 0, 0);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteCharArrayIntInt2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(goodW, badW, null);
+        final char[] data = { 'a' };
+        try {
+            tw.write(data, 0, 0);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data, 0, 0);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteInt1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(badW, goodW, null);
+        final int data = 32;
+        try {
+            tw.write(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteInt2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(goodW, badW, null);
+        try {
+            tw.write(32);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(32);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteString1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(badW, goodW, null);
+        final String data = "A";
+        try {
+            tw.write(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteString2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(goodW, badW, null);
+        final String data = "A";
+        try {
+            tw.write(data);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteStringIntInt1() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(badW, goodW, null);
+        final String data = "A";
+        try {
+            tw.write(data, 0, 0);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data, 0, 0);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(0, e.getCause(0, IOIndexedException.class).getIndex());
+        }
+    }
+
+    @Test
+    public void testArrayIOExceptionOnWriteStringIntInt2() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(goodW, badW, null);
+        final String data = "A";
+        try {
+            tw.write(data, 0, 0);
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).write(data, 0, 0);
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+
+        }
+    }
+
+    @Test
+    public void testCollectionCloseBranchIOException() throws IOException {
+        final Writer badW = BrokenWriter.INSTANCE;
+        final StringWriter goodW = mock(StringWriter.class);
+        final TeeWriter tw = new TeeWriter(Arrays.asList(goodW, badW, null));
+        try {
+            tw.close();
+            fail("Expected " + IOException.class.getName());
+        } catch (final IOExceptionList e) {
+            verify(goodW).close();
+            assertEquals(1, e.getCauseList().size());
+            assertEquals(1, e.getCause(0, IOIndexedException.class).getIndex());
+
+        }
+    }
+
+    @Test
+    public void testConstructorsNull() throws IOException {
+        try (TeeWriter teeWriter = new TeeWriter((Writer[]) null)) {
+            // Call any method, should not throw
+            teeWriter.append('a');
+            teeWriter.flush();
+        }
+        try (TeeWriter teeWriter = new TeeWriter((Collection<Writer>) null)) {
+            // Call any method, should not throw
+            teeWriter.append('a');
+            teeWriter.flush();
+        }
+        assertTrue(true, "Dummy to show test completed OK");
+    }
+
+    @Test
+    public void testTee() throws IOException {
+        final StringBuilderWriter sbw1 = new StringBuilderWriter();
+        final StringBuilderWriter sbw2 = new StringBuilderWriter();
+        final StringBuilderWriter expected = new StringBuilderWriter();
+
+        try (TeeWriter tw = new TeeWriter(sbw1, sbw2, null)) {
+            for (int i = 0; i < 20; i++) {
+                tw.write(i);
+                expected.write(i);
+            }
+            assertEquals(expected.toString(), sbw1.toString(), "TeeWriter.write(int)");
+            assertEquals(expected.toString(), sbw2.toString(), "TeeWriter.write(int)");
+
+            final char[] array = new char[10];
+            for (int i = 20; i < 30; i++) {
+                array[i - 20] = (char) i;
+            }
+            tw.write(array);
+            expected.write(array);
+            assertEquals(expected.toString(), sbw1.toString(), "TeeWriter.write(char[])");
+            assertEquals(expected.toString(), sbw2.toString(), "TeeWriter.write(char[])");
+
+            for (int i = 25; i < 35; i++) {
+                array[i - 25] = (char) i;
+            }
+            tw.write(array, 5, 5);
+            expected.write(array, 5, 5);
+            assertEquals(expected.toString(), sbw1.toString(), "TeeOutputStream.write(byte[], int, int)");
+            assertEquals(expected.toString(), sbw2.toString(), "TeeOutputStream.write(byte[], int, int)");
+
+            for (int i = 0; i < 20; i++) {
+                tw.append((char) i);
+                expected.append((char) i);
+            }
+            assertEquals(expected.toString(), sbw1.toString(), "TeeWriter.append(char)");
+            assertEquals(expected.toString(), sbw2.toString(), "TeeWriter.append(char)");
+
+            for (int i = 20; i < 30; i++) {
+                array[i - 20] = (char) i;
+            }
+            tw.append(new String(array));
+            expected.append(new String(array));
+            assertEquals(expected.toString(), sbw1.toString(), "TeeWriter.append(CharSequence)");
+            assertEquals(expected.toString(), sbw2.toString(), "TeeWriter.append(CharSequence)");
+
+            for (int i = 25; i < 35; i++) {
+                array[i - 25] = (char) i;
+            }
+            tw.append(new String(array), 5, 5);
+            expected.append(new String(array), 5, 5);
+            assertEquals(expected.toString(), sbw1.toString(), "TeeWriter.append(CharSequence, int, int)");
+            assertEquals(expected.toString(), sbw2.toString(), "TeeWriter.append(CharSequence, int, int)");
+
+            expected.flush();
+            expected.close();
+
+            tw.flush();
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/ThresholdingOutputStreamTest.java b/src/test/java/org/apache/commons/io/output/ThresholdingOutputStreamTest.java
new file mode 100644
index 0000000..8d278df
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/ThresholdingOutputStreamTest.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.junit.jupiter.api.Test;
+
+public class ThresholdingOutputStreamTest {
+
+    @Test
+    public void testSetByteCount() throws Exception {
+        final AtomicBoolean reached = new AtomicBoolean(false);
+        try (ThresholdingOutputStream tos = new ThresholdingOutputStream(3) {
+            {
+                setByteCount(2);
+            }
+
+            @Override
+            protected OutputStream getStream() throws IOException {
+                return new ByteArrayOutputStream(4);
+            }
+
+            @Override
+            protected void thresholdReached() throws IOException {
+                reached.set(true);
+            }
+        }) {
+            tos.write('a');
+            assertFalse(reached.get());
+            tos.write('a');
+            assertTrue(reached.get());
+        }
+    }
+
+    @Test
+    public void testThresholdIOConsumer() throws Exception {
+        final AtomicBoolean reached = new AtomicBoolean();
+        // Null threshold consumer
+        reached.set(false);
+        try (ThresholdingOutputStream tos = new ThresholdingOutputStream(1, null,
+            os -> new ByteArrayOutputStream(4))) {
+            tos.write('a');
+            assertFalse(reached.get());
+            tos.write('a');
+            assertFalse(reached.get());
+        }
+        // Null output stream function
+        reached.set(false);
+        try (ThresholdingOutputStream tos = new ThresholdingOutputStream(1, os -> reached.set(true), null)) {
+            tos.write('a');
+            assertFalse(reached.get());
+            tos.write('a');
+            assertTrue(reached.get());
+        }
+        // non-null inputs.
+        reached.set(false);
+        try (ThresholdingOutputStream tos = new ThresholdingOutputStream(1, os -> reached.set(true),
+            os -> new ByteArrayOutputStream(4))) {
+            tos.write('a');
+            assertFalse(reached.get());
+            tos.write('a');
+            assertTrue(reached.get());
+        }
+    }
+
+    @Test
+    public void testThresholdIOConsumerIOException() throws Exception {
+        try (ThresholdingOutputStream tos = new ThresholdingOutputStream(1, os -> {
+            throw new IOException("Threshold reached.");
+        }, os -> new ByteArrayOutputStream(4))) {
+            tos.write('a');
+            assertThrows(IOException.class, () -> tos.write('a'));
+        }
+    }
+
+    @Test
+    public void testThresholdIOConsumerUncheckedException() throws Exception {
+        try (ThresholdingOutputStream tos = new ThresholdingOutputStream(1, os -> {
+            throw new IllegalStateException("Threshold reached.");
+        }, os -> new ByteArrayOutputStream(4))) {
+            tos.write('a');
+            assertThrows(IllegalStateException.class, () -> tos.write('a'));
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/commons/io/output/UncheckedAppendableTest.java b/src/test/java/org/apache/commons/io/output/UncheckedAppendableTest.java
new file mode 100644
index 0000000..fb3a713
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/UncheckedAppendableTest.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.UncheckedIOException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link UncheckedAppendable}.
+ */
+public class UncheckedAppendableTest {
+
+    private IOException exception;
+
+    private UncheckedAppendable appendableBroken;
+    private UncheckedAppendable appendableString;
+
+    @SuppressWarnings("resource")
+    @BeforeEach
+    public void setUp() {
+        exception = new IOException("test exception");
+        appendableBroken = UncheckedAppendable.on(new BrokenWriter(exception));
+        appendableString = UncheckedAppendable.on(new StringWriter());
+    }
+
+    @Test
+    public void testAppendChar() {
+        appendableString.append('a').append('b');
+        assertEquals("ab", appendableString.toString());
+    }
+
+    @Test
+    public void testAppendCharSequence() {
+        appendableString.append("a").append("b");
+        assertEquals("ab", appendableString.toString());
+    }
+
+    @Test
+    public void testAppendCharSequenceIndexed() {
+        appendableString.append("a", 0, 1).append("b", 0, 1);
+        assertEquals("ab", appendableString.toString());
+    }
+
+    @Test
+    public void testAppendCharSequenceIndexedThrows() {
+        try {
+            appendableBroken.append("a", 0, 1);
+            fail("Expected exception not thrown.");
+        } catch (final UncheckedIOException e) {
+            assertEquals(exception, e.getCause());
+        }
+    }
+
+    @Test
+    public void testAppendCharSequenceThrows() {
+        try {
+            appendableBroken.append("a");
+            fail("Expected exception not thrown.");
+        } catch (final UncheckedIOException e) {
+            assertEquals(exception, e.getCause());
+        }
+    }
+
+    @Test
+    public void testAppendCharThrows() {
+        try {
+            appendableBroken.append('a');
+            fail("Expected exception not thrown.");
+        } catch (final UncheckedIOException e) {
+            assertEquals(exception, e.getCause());
+        }
+    }
+
+    @Test
+    public void testToString() {
+        assertEquals("ab", UncheckedAppendable.on(new StringWriter(2).append("ab")).toString());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/UncheckedFilterOutputStreamTest.java b/src/test/java/org/apache/commons/io/output/UncheckedFilterOutputStreamTest.java
new file mode 100644
index 0000000..8f86db7
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/UncheckedFilterOutputStreamTest.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.UncheckedIOException;
+import java.nio.charset.Charset;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link BrokenWriter}.
+ */
+public class UncheckedFilterOutputStreamTest {
+
+    private IOException exception;
+
+    private UncheckedFilterOutputStream brokenWriter;
+    private UncheckedFilterOutputStream stringWriter;
+
+    @SuppressWarnings("resource")
+    @BeforeEach
+    public void setUp() {
+        exception = new IOException("test exception");
+        brokenWriter = UncheckedFilterOutputStream.on(new BrokenOutputStream(exception));
+        stringWriter = UncheckedFilterOutputStream.on(new WriterOutputStream(new StringWriter(), Charset.defaultCharset()));
+    }
+
+    @Test
+    public void testClose() {
+        stringWriter.close();
+    }
+
+    @Test
+    public void testCloseThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.close()).getCause());
+    }
+
+    @Test
+    public void testEquals() {
+        stringWriter.equals(null);
+    }
+
+    @Test
+    @Disabled("What should happen here?")
+    public void testEqualsThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.equals(null)).getCause());
+    }
+
+    @Test
+    public void testFlush() {
+        stringWriter.flush();
+    }
+
+    @Test
+    public void testFlushThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.flush()).getCause());
+    }
+
+    @Test
+    public void testHashCode() {
+        stringWriter.hashCode();
+    }
+
+    @Test
+    @Disabled("What should happen here?")
+    public void testHashCodeThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.hashCode()).getCause());
+    }
+
+    @Test
+    public void testToString() {
+        stringWriter.toString();
+    }
+
+    @Test
+    @Disabled("What should happen here?")
+    public void testToStringThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.toString()).getCause());
+    }
+
+    @Test
+    public void testWriteInt() {
+        stringWriter.write(1);
+    }
+
+    @Test
+    public void testWriteIntThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.write(1)).getCause());
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/output/UncheckedFilterWriterTest.java b/src/test/java/org/apache/commons/io/output/UncheckedFilterWriterTest.java
new file mode 100644
index 0000000..10b0ee3
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/UncheckedFilterWriterTest.java
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.UncheckedIOException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit Test Case for {@link BrokenWriter}.
+ */
+public class UncheckedFilterWriterTest {
+
+    private IOException exception;
+
+    private UncheckedFilterWriter brokenWriter;
+    private UncheckedFilterWriter stringWriter;
+
+    @SuppressWarnings("resource")
+    @BeforeEach
+    public void setUp() {
+        exception = new IOException("test exception");
+        brokenWriter = UncheckedFilterWriter.on(new BrokenWriter(exception));
+        stringWriter = UncheckedFilterWriter.on(new StringWriter());
+    }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void testAppendChar() {
+        stringWriter.append('1');
+    }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void testAppendCharSequence() {
+        stringWriter.append("01");
+    }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void testAppendCharSequenceIndexed() {
+        stringWriter.append("01", 0, 1);
+    }
+
+    @Test
+    public void testAppendCharSequenceIndexedThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.append("01", 0, 1)).getCause());
+    }
+
+    @Test
+    public void testAppendCharSequenceThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.append("01")).getCause());
+    }
+
+    @Test
+    public void testAppendCharThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.append('1')).getCause());
+    }
+
+    @Test
+    public void testClose() {
+        stringWriter.close();
+    }
+
+    @Test
+    public void testCloseThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.close()).getCause());
+    }
+
+    @Test
+    public void testEquals() {
+        stringWriter.equals(null);
+    }
+
+    @Test
+    @Disabled("What should happen here?")
+    public void testEqualsThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.equals(null)).getCause());
+    }
+
+    @Test
+    public void testFlush() {
+        stringWriter.flush();
+    }
+
+    @Test
+    public void testFlushThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.flush()).getCause());
+    }
+
+    @Test
+    public void testHashCode() {
+        stringWriter.hashCode();
+    }
+
+    @Test
+    @Disabled("What should happen here?")
+    public void testHashCodeThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.hashCode()).getCause());
+    }
+
+    @Test
+    public void testToString() {
+        stringWriter.toString();
+    }
+
+    @Test
+    @Disabled("What should happen here?")
+    public void testToStringThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.toString()).getCause());
+    }
+
+    @Test
+    public void testWriteCharArray() {
+        stringWriter.write(new char[1]);
+    }
+
+    @Test
+    public void testWriteCharArrayIndexed() {
+        stringWriter.write(new char[1], 0, 1);
+    }
+
+    @Test
+    public void testWriteCharArrayIndexedThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.write(new char[1], 0, 1)).getCause());
+    }
+
+    @Test
+    public void testWriteCharArrayThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.write(new char[1])).getCause());
+    }
+
+    @Test
+    public void testWriteInt() {
+        stringWriter.write(1);
+    }
+
+    @Test
+    public void testWriteIntThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.write(1)).getCause());
+    }
+
+    @Test
+    public void testWriteString() {
+        stringWriter.write("01");
+    }
+
+    @Test
+    public void testWriteStringIndexed() {
+        stringWriter.write("01", 0, 1);
+    }
+
+    @Test
+    public void testWriteStringIndexedThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.write("01", 0, 1)).getCause());
+    }
+
+    @Test
+    public void testWriteStringThrows() {
+        assertEquals(exception, assertThrows(UncheckedIOException.class, () -> brokenWriter.write("01")).getCause());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/output/WriterOutputStreamTest.java b/src/test/java/org/apache/commons/io/output/WriterOutputStreamTest.java
new file mode 100644
index 0000000..497334f
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/WriterOutputStreamTest.java
@@ -0,0 +1,205 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Random;
+
+import org.apache.commons.io.Charsets;
+import org.apache.commons.io.charset.CharsetDecoders;
+import org.junit.jupiter.api.Test;
+
+public class WriterOutputStreamTest {
+    private static final String UTF_16LE = StandardCharsets.UTF_16LE.name();
+    private static final String UTF_16BE = StandardCharsets.UTF_16BE.name();
+    private static final String UTF_16 = StandardCharsets.UTF_16.name();
+    private static final String UTF_8 = StandardCharsets.UTF_8.name();
+    private static final String TEST_STRING = "\u00e0 peine arriv\u00e9s nous entr\u00e2mes dans sa chambre";
+    private static final String LARGE_TEST_STRING;
+
+    static {
+        final StringBuilder buffer = new StringBuilder();
+        for (int i=0; i<100; i++) {
+            buffer.append(TEST_STRING);
+        }
+        LARGE_TEST_STRING = buffer.toString();
+    }
+
+    private final Random random = new Random();
+
+    @Test
+    public void testFlush() throws IOException {
+        final StringWriter writer = new StringWriter();
+        try (WriterOutputStream out = new WriterOutputStream(writer, "us-ascii", 1024, false)) {
+            out.write("abc".getBytes(StandardCharsets.US_ASCII));
+            assertEquals(0, writer.getBuffer().length());
+            out.flush();
+            assertEquals("abc", writer.toString());
+        }
+    }
+
+    @Test
+    public void testLargeUTF8CharsetWithBufferedWrite() throws IOException {
+        testWithBufferedWrite(LARGE_TEST_STRING, UTF_8);
+    }
+
+    @Test
+    public void testLargeUTF8CharsetWithSingleByteWrite() throws IOException {
+        testWithSingleByteWrite(LARGE_TEST_STRING, StandardCharsets.UTF_8);
+    }
+
+    @Test
+    public void testLargeUTF8WithBufferedWrite() throws IOException {
+        testWithBufferedWrite(LARGE_TEST_STRING, UTF_8);
+    }
+
+    @Test
+    public void testLargeUTF8WithSingleByteWrite() throws IOException {
+        testWithSingleByteWrite(LARGE_TEST_STRING, UTF_8);
+    }
+
+    @Test
+    public void testNullCharsetDecoderWithSingleByteWrite() throws IOException {
+        testWithSingleByteWrite(TEST_STRING, (CharsetDecoder) null);
+    }
+
+    @Test
+    public void testNullCharsetNameWithSingleByteWrite() throws IOException {
+        testWithSingleByteWrite(TEST_STRING, (String) null);
+    }
+
+    @Test
+    public void testNullCharsetWithSingleByteWrite() throws IOException {
+        testWithSingleByteWrite(TEST_STRING, (Charset) null);
+    }
+
+    @Test
+    public void testUTF16BEWithBufferedWrite() throws IOException {
+        testWithBufferedWrite(TEST_STRING, UTF_16BE);
+    }
+
+    @Test
+    public void testUTF16BEWithSingleByteWrite() throws IOException {
+        testWithSingleByteWrite(TEST_STRING, UTF_16BE);
+    }
+
+    @Test
+    public void testUTF16LEWithBufferedWrite() throws IOException {
+        testWithBufferedWrite(TEST_STRING, UTF_16LE);
+    }
+
+    @Test
+    public void testUTF16LEWithSingleByteWrite() throws IOException {
+        testWithSingleByteWrite(TEST_STRING, UTF_16LE);
+    }
+
+    @Test
+    public void testUTF16WithBufferedWrite() throws IOException {
+        try {
+            testWithBufferedWrite(TEST_STRING, UTF_16);
+        } catch (final UnsupportedOperationException e) {
+            if (!System.getProperty("java.vendor").contains("IBM")) {
+                fail("This test should only throw UOE on IBM JDKs with broken UTF-16");
+            }
+        }
+    }
+
+    @Test
+    public void testUTF16WithSingleByteWrite() throws IOException {
+        try {
+            testWithSingleByteWrite(TEST_STRING, UTF_16);
+        } catch (final UnsupportedOperationException e){
+            if (!System.getProperty("java.vendor").contains("IBM")){
+                fail("This test should only throw UOE on IBM JDKs with broken UTF-16");
+            }
+        }
+    }
+
+    @Test
+    public void testUTF8WithBufferedWrite() throws IOException {
+        testWithBufferedWrite(TEST_STRING, UTF_8);
+    }
+
+    @Test
+    public void testUTF8WithSingleByteWrite() throws IOException {
+        testWithSingleByteWrite(TEST_STRING, UTF_8);
+    }
+
+    private void testWithBufferedWrite(final String testString, final String charsetName) throws IOException {
+        final byte[] expected = testString.getBytes(charsetName);
+        final StringWriter writer = new StringWriter();
+        try (WriterOutputStream out = new WriterOutputStream(writer, charsetName)) {
+            int offset = 0;
+            while (offset < expected.length) {
+                final int length = Math.min(random.nextInt(128), expected.length - offset);
+                out.write(expected, offset, length);
+                offset += length;
+            }
+        }
+        assertEquals(testString, writer.toString());
+    }
+
+
+    private void testWithSingleByteWrite(final String testString, final Charset charset) throws IOException {
+        final byte[] bytes = testString.getBytes(Charsets.toCharset(charset));
+        final StringWriter writer = new StringWriter();
+        try (WriterOutputStream out = new WriterOutputStream(writer, charset)) {
+            for (final byte b : bytes) {
+                out.write(b);
+            }
+        }
+        assertEquals(testString, writer.toString());
+    }
+
+    private void testWithSingleByteWrite(final String testString, final CharsetDecoder charsetDecoder) throws IOException {
+        final byte[] bytes = testString.getBytes(CharsetDecoders.toCharsetDecoder(charsetDecoder).charset());
+        final StringWriter writer = new StringWriter();
+        try (WriterOutputStream out = new WriterOutputStream(writer, charsetDecoder)) {
+            for (final byte b : bytes) {
+                out.write(b);
+            }
+        }
+        assertEquals(testString, writer.toString());
+    }
+
+    private void testWithSingleByteWrite(final String testString, final String charsetName) throws IOException {
+        final byte[] bytes = testString.getBytes(Charsets.toCharset(charsetName));
+        final StringWriter writer = new StringWriter();
+        try (WriterOutputStream out = new WriterOutputStream(writer, charsetName)) {
+            for (final byte b : bytes) {
+                out.write(b);
+            }
+        }
+        assertEquals(testString, writer.toString());
+    }
+
+    @Test
+    public void testWriteImmediately() throws IOException {
+        final StringWriter writer = new StringWriter();
+        try (WriterOutputStream out = new WriterOutputStream(writer, "us-ascii", 1024, true)) {
+            out.write("abc".getBytes(StandardCharsets.US_ASCII));
+            assertEquals("abc", writer.toString());
+        }
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/output/XmlStreamWriterTest.java b/src/test/java/org/apache/commons/io/output/XmlStreamWriterTest.java
new file mode 100644
index 0000000..2a005be
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/output/XmlStreamWriterTest.java
@@ -0,0 +1,167 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.output;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+import org.junitpioneer.jupiter.DefaultLocale;
+
+/**
+ */
+public class XmlStreamWriterTest {
+
+    /** French */
+    private static final String TEXT_LATIN1 = "eacute: \u00E9";
+
+    /** Greek */
+    private static final String TEXT_LATIN7 = "alpha: \u03B1";
+
+    /** Euro support */
+    private static final String TEXT_LATIN15 = "euro: \u20AC";
+
+    /** Japanese */
+    private static final String TEXT_EUC_JP = "hiragana A: \u3042";
+
+    /** Unicode: support everything */
+    private static final String TEXT_UNICODE = TEXT_LATIN1 + ", " + TEXT_LATIN7
+            + ", " + TEXT_LATIN15 + ", " + TEXT_EUC_JP;
+
+    private static void checkXmlContent(final String xml, final String encodingName, final String defaultEncodingName)
+        throws IOException {
+        final ByteArrayOutputStream out = new ByteArrayOutputStream();
+        final XmlStreamWriter writer = new XmlStreamWriter(out, defaultEncodingName);
+        writer.write(xml);
+        writer.close();
+        final byte[] xmlContent = out.toByteArray();
+        final Charset charset = Charset.forName(encodingName);
+        final Charset writerCharset = Charset.forName(writer.getEncoding());
+        assertEquals(charset, writerCharset);
+        assertTrue(writerCharset.contains(charset), writerCharset.name());
+        assertArrayEquals(xml.getBytes(encodingName), xmlContent);
+    }
+
+    private static void checkXmlWriter(final String text, final String encoding)
+            throws IOException {
+        checkXmlWriter(text, encoding, null);
+    }
+
+    private static void checkXmlWriter(final String text, final String encoding, final String defaultEncoding)
+            throws IOException {
+        final String xml = createXmlContent(text, encoding);
+        String effectiveEncoding = encoding;
+        if (effectiveEncoding == null) {
+            effectiveEncoding = defaultEncoding == null ? StandardCharsets.UTF_8.name() : defaultEncoding;
+        }
+        checkXmlContent(xml, effectiveEncoding, defaultEncoding);
+    }
+
+    private static String createXmlContent(final String text, final String encoding) {
+        String xmlDecl = "<?xml version=\"1.0\"?>";
+        if (encoding != null) {
+            xmlDecl = "<?xml version=\"1.0\" encoding=\"" + encoding + "\"?>";
+        }
+        return xmlDecl + "\n<text>" + text + "</text>";
+    }
+
+    @Test
+    public void testDefaultEncoding() throws IOException {
+        checkXmlWriter(TEXT_UNICODE, null, null);
+        checkXmlWriter(TEXT_UNICODE, null, StandardCharsets.UTF_8.name());
+        checkXmlWriter(TEXT_UNICODE, null, StandardCharsets.UTF_16.name());
+        checkXmlWriter(TEXT_UNICODE, null, StandardCharsets.UTF_16BE.name());
+        checkXmlWriter(TEXT_UNICODE, null, StandardCharsets.ISO_8859_1.name());
+    }
+
+    @Test
+    public void testEBCDICEncoding() throws IOException {
+        checkXmlWriter("simple text in EBCDIC", "CP1047");
+    }
+
+    @Test
+    public void testEmpty() throws IOException {
+        final ByteArrayOutputStream out = new ByteArrayOutputStream();
+        try (XmlStreamWriter writer = new XmlStreamWriter(out)) {
+            writer.flush();
+            writer.write("");
+            writer.flush();
+            writer.write(".");
+            writer.flush();
+        }
+    }
+
+    @Test
+    public void testEUC_JPEncoding() throws IOException {
+        checkXmlWriter(TEXT_EUC_JP, "EUC-JP");
+    }
+
+    @Test
+    public void testLatin15Encoding() throws IOException {
+        checkXmlWriter(TEXT_LATIN15, "ISO-8859-15");
+    }
+
+    @Test
+    public void testLatin1Encoding() throws IOException {
+        checkXmlWriter(TEXT_LATIN1, StandardCharsets.ISO_8859_1.name());
+    }
+
+    @Test
+    public void testLatin7Encoding() throws IOException {
+        checkXmlWriter(TEXT_LATIN7, "ISO-8859-7");
+    }
+
+    /** Turkish language has specific rules to convert dotted and dotless i character. */
+    @Test
+    @DefaultLocale(language = "tr")
+    public void testLowerCaseEncodingWithTurkishLocale_IO_557() throws IOException {
+        checkXmlWriter(TEXT_UNICODE, "utf-8");
+        checkXmlWriter(TEXT_LATIN1, "iso-8859-1");
+        checkXmlWriter(TEXT_LATIN7, "iso-8859-7");
+    }
+
+    @Test
+    public void testNoXmlHeader() throws IOException {
+        checkXmlContent("<text>text with no XML header</text>", StandardCharsets.UTF_8.name(), null);
+    }
+
+    @Test
+    public void testUTF16BEEncoding() throws IOException {
+        checkXmlWriter(TEXT_UNICODE, StandardCharsets.UTF_16BE.name());
+    }
+
+    @Test
+    public void testUTF16Encoding() throws IOException {
+        checkXmlWriter(TEXT_UNICODE, StandardCharsets.UTF_16.name());
+    }
+
+    @Test
+    public void testUTF16LEEncoding() throws IOException {
+        checkXmlWriter(TEXT_UNICODE, StandardCharsets.UTF_16LE.name());
+    }
+
+    @Test
+    public void testUTF8Encoding() throws IOException {
+        checkXmlWriter(TEXT_UNICODE, StandardCharsets.UTF_8.name());
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/serialization/AbstractCloseableListTest.java b/src/test/java/org/apache/commons/io/serialization/AbstractCloseableListTest.java
new file mode 100644
index 0000000..71bfbd2
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/serialization/AbstractCloseableListTest.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.commons.io.serialization;
+
+import java.io.Closeable;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+
+/**
+ * Test base class that keeps track of Closeable objects and cleans them up.
+ */
+public abstract class AbstractCloseableListTest {
+    private final List<Closeable> closeableList = new ArrayList<>();
+
+    @AfterEach
+    public void cleanup() {
+        IOUtils.closeQuietly(closeableList);
+    }
+
+    /**
+     * Adds a Closeable to close after each test.
+     *
+     * @param <T> The Closeable type
+     * @param t The Closeable.
+     * @return The Closeable.
+     */
+    protected <T extends Closeable> T closeAfterEachTest(final T t) {
+        closeableList.add(t);
+        return t;
+    }
+
+    @BeforeEach
+    public void setup() {
+        closeableList.clear();
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/commons/io/serialization/FullClassNameMatcherTest.java b/src/test/java/org/apache/commons/io/serialization/FullClassNameMatcherTest.java
new file mode 100644
index 0000000..fce1be1
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/serialization/FullClassNameMatcherTest.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.commons.io.serialization;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+public class FullClassNameMatcherTest {
+
+    private static final String [] NAMES_ARRAY = { Integer.class.getName(), Long.class.getName() };
+
+    @Test
+    public void noNames() {
+        final FullClassNameMatcher m = new FullClassNameMatcher();
+        assertFalse(m.matches(Integer.class.getName()));
+    }
+
+    @Test
+    public void withNames() {
+        final FullClassNameMatcher m = new FullClassNameMatcher(NAMES_ARRAY);
+        assertTrue(m.matches(Integer.class.getName()));
+        assertFalse(m.matches(String.class.getName()));
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/commons/io/serialization/MockSerializedClass.java b/src/test/java/org/apache/commons/io/serialization/MockSerializedClass.java
new file mode 100644
index 0000000..726dc70
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/serialization/MockSerializedClass.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.commons.io.serialization;
+
+import java.io.Serializable;
+
+public class MockSerializedClass implements Serializable {
+    private static final long serialVersionUID = 2139985988735372175L;
+
+    private final String str;
+
+    MockSerializedClass(final String str) {
+        this.str = str;
+    }
+
+    @Override
+    public boolean equals(final Object obj) {
+        if(!(obj instanceof MockSerializedClass)) {
+            return false;
+        }
+        return str.equals(((MockSerializedClass)obj).str);
+    }
+
+    @Override
+    public int hashCode() {
+        return str.hashCode();
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/commons/io/serialization/MoreComplexObject.java b/src/test/java/org/apache/commons/io/serialization/MoreComplexObject.java
new file mode 100644
index 0000000..201b52d
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/serialization/MoreComplexObject.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.commons.io.serialization;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+import java.util.UUID;
+
+/** A test class that uses various java.* member objects,
+ *  to show which settings are necessary to deserialize
+ *  those.
+ */
+public class MoreComplexObject implements Serializable {
+    private static final long serialVersionUID = -5187124661539240729L;
+
+    private final Random random = new Random(System.currentTimeMillis());
+    private final String string = UUID.randomUUID().toString();
+    private final Integer integer = random.nextInt();
+    private final int pInt = random.nextInt();
+    private final long pLong = random.nextLong();
+    private final Integer [] intArray = { random.nextInt(), random.nextInt() };
+    private final List<Boolean> boolList = new ArrayList<>();
+
+    MoreComplexObject() {
+        for(int i=0 ; i < 5; i++) {
+            boolList.add(random.nextBoolean());
+        }
+    }
+
+    @Override
+    public String toString() {
+        return string + integer + pInt + pLong + Arrays.asList(intArray) + boolList;
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/commons/io/serialization/MoreComplexObjectTest.java b/src/test/java/org/apache/commons/io/serialization/MoreComplexObjectTest.java
new file mode 100644
index 0000000..a57a465
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/serialization/MoreComplexObjectTest.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.commons.io.serialization;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.ArrayList;
+import java.util.Random;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/** This is more an example than a test - deserialize our {@link MoreComplexObject}
+ *  to verify which settings it requires, as the object uses a number of primitive
+ *  and java.* member objects.
+ */
+public class MoreComplexObjectTest extends AbstractCloseableListTest {
+
+    private InputStream inputStream;
+    private MoreComplexObject original;
+
+    private void assertSerialization(final ObjectInputStream ois) throws ClassNotFoundException, IOException {
+        final MoreComplexObject copy = (MoreComplexObject) ois.readObject();
+        assertEquals(original.toString(), copy.toString(), "Expecting same data after deserializing");
+    }
+
+    @BeforeEach
+    public void setupMoreComplexObject() throws IOException {
+        original = new MoreComplexObject();
+        final ByteArrayOutputStream bos = closeAfterEachTest(new ByteArrayOutputStream());
+        final ObjectOutputStream oos = closeAfterEachTest(new ObjectOutputStream(bos));
+        oos.writeObject(original);
+        inputStream = closeAfterEachTest(new ByteArrayInputStream(bos.toByteArray()));
+    }
+
+    /** Trusting java.* is probably reasonable and avoids having to be too
+     *  detailed in the accepts.
+     */
+    @Test
+    public void trustJavaIncludingArrays() throws IOException, ClassNotFoundException {
+        assertSerialization(closeAfterEachTest(
+                new ValidatingObjectInputStream(inputStream)
+                .accept(MoreComplexObject.class)
+                .accept("java.*","[Ljava.*")
+        ));
+    }
+
+    /** Trusting java.lang.* and the array variants of that means we have
+     *  to define a number of accept classes explicitly. Quite safe but
+     *  might become a bit verbose.
+     */
+    @Test
+    public void trustJavaLang() throws IOException, ClassNotFoundException {
+        assertSerialization(closeAfterEachTest(
+                new ValidatingObjectInputStream(inputStream)
+                .accept(MoreComplexObject.class, ArrayList.class, Random.class)
+                .accept("java.lang.*","[Ljava.lang.*")
+        ));
+    }
+
+    /** Here we accept everything but reject specific classes, using a pure
+     *  blacklist mode.
+     *
+     *  That's not as safe as it's hard to get an exhaustive blacklist, but
+     *  might be ok in controlled environments.
+     */
+    @Test
+    public void useBlacklist() throws IOException, ClassNotFoundException {
+        final String [] blacklist = {
+                "org.apache.commons.collections.functors.InvokerTransformer",
+                "org.codehaus.groovy.runtime.ConvertedClosure",
+                "org.codehaus.groovy.runtime.MethodClosure",
+                "org.springframework.beans.factory.ObjectFactory"
+        };
+        assertSerialization(closeAfterEachTest(
+                new ValidatingObjectInputStream(inputStream)
+                .accept("*")
+                .reject(blacklist)
+        ));
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/commons/io/serialization/RegexpClassNameMatcherTest.java b/src/test/java/org/apache/commons/io/serialization/RegexpClassNameMatcherTest.java
new file mode 100644
index 0000000..c90c92d
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/serialization/RegexpClassNameMatcherTest.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.commons.io.serialization;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.regex.Pattern;
+
+import org.junit.jupiter.api.Test;
+
+public class RegexpClassNameMatcherTest {
+
+    @Test
+    public void testNullPatternPattern() {
+        assertThrows(NullPointerException.class, () -> new RegexpClassNameMatcher((Pattern) null));
+    }
+
+    @Test
+    public void testNullStringPattern() {
+        assertThrows(NullPointerException.class, () -> new RegexpClassNameMatcher((String) null));
+    }
+
+    @Test
+    public void testOrPattern() {
+        final ClassNameMatcher ca = new RegexpClassNameMatcher("foo.*|bar.*");
+        assertTrue(ca.matches("foo.should.match"));
+        assertTrue(ca.matches("bar.should.match"));
+        assertFalse(ca.matches("zoo.should.not.match"));
+    }
+
+    @Test
+    public void testSimplePatternFromPattern() {
+        final ClassNameMatcher ca = new RegexpClassNameMatcher(Pattern.compile("foo.*"));
+        assertTrue(ca.matches("foo.should.match"));
+        assertFalse(ca.matches("bar.should.not.match"));
+    }
+
+    @Test
+    public void testSimplePatternFromString() {
+        final ClassNameMatcher ca = new RegexpClassNameMatcher("foo.*");
+        assertTrue(ca.matches("foo.should.match"));
+        assertFalse(ca.matches("bar.should.not.match"));
+    }
+}
diff --git a/src/test/java/org/apache/commons/io/serialization/ValidatingObjectInputStreamTest.java b/src/test/java/org/apache/commons/io/serialization/ValidatingObjectInputStreamTest.java
new file mode 100644
index 0000000..fa58f67
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/serialization/ValidatingObjectInputStreamTest.java
@@ -0,0 +1,228 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.commons.io.serialization;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InvalidClassException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.UUID;
+import java.util.regex.Pattern;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class ValidatingObjectInputStreamTest extends AbstractCloseableListTest {
+    private static final ClassNameMatcher ALWAYS_TRUE = className -> true;
+    private MockSerializedClass testObject;
+
+    private InputStream testStream;
+
+    @Test
+    public void acceptCustomMatcher() throws Exception {
+        assertSerialization(
+                closeAfterEachTest(new ValidatingObjectInputStream(testStream))
+                .accept(ALWAYS_TRUE)
+        );
+    }
+
+    @Test
+    public void acceptPattern() throws Exception {
+        assertSerialization(
+                closeAfterEachTest(new ValidatingObjectInputStream(testStream))
+                .accept(Pattern.compile(".*MockSerializedClass.*"))
+        );
+    }
+
+    @Test
+    public void acceptWildcard() throws Exception {
+        assertSerialization(
+                closeAfterEachTest(new ValidatingObjectInputStream(testStream))
+                .accept("org.apache.commons.io.*")
+        );
+    }
+
+    private void assertSerialization(final ObjectInputStream ois) throws ClassNotFoundException, IOException {
+        final MockSerializedClass result = (MockSerializedClass) ois.readObject();
+        assertEquals(testObject, result);
+    }
+
+    @Test
+    public void customInvalidMethod() {
+        class CustomVOIS extends ValidatingObjectInputStream {
+            CustomVOIS(final InputStream is) throws IOException {
+                super(is);
+            }
+
+            @Override
+            protected void invalidClassNameFound(final String className) throws InvalidClassException {
+                throw new RuntimeException("Custom exception");
+            }
+        }
+
+        assertThrows(RuntimeException.class,
+                () -> assertSerialization(
+                closeAfterEachTest(new CustomVOIS(testStream))
+                .reject(Integer.class)
+        ));
+    }
+
+    @Test
+    public void exceptionIncludesClassName() throws Exception {
+        try {
+            assertSerialization(
+                    closeAfterEachTest(new ValidatingObjectInputStream(testStream)));
+            fail("Expected an InvalidClassException");
+        } catch(final InvalidClassException ice) {
+            final String name = MockSerializedClass.class.getName();
+            assertTrue(ice.getMessage().contains(name), "Expecting message to contain " + name);
+        }
+    }
+
+    @Test
+    public void noAccept() {
+        assertThrows(InvalidClassException.class, () -> assertSerialization(
+                closeAfterEachTest(new ValidatingObjectInputStream(testStream))));
+    }
+
+    @Test
+    public void ourTestClassAcceptedFirst() throws Exception {
+        assertSerialization(
+                closeAfterEachTest(new ValidatingObjectInputStream(testStream))
+                .accept(MockSerializedClass.class, Integer.class)
+        );
+    }
+
+    @Test
+    public void ourTestClassAcceptedFirstWildcard() throws Exception {
+        assertSerialization(
+                closeAfterEachTest(new ValidatingObjectInputStream(testStream))
+                .accept("*MockSerializedClass","*Integer")
+        );
+    }
+
+    @Test
+    public void ourTestClassAcceptedSecond() throws Exception {
+        assertSerialization(
+                closeAfterEachTest(new ValidatingObjectInputStream(testStream))
+                .accept(Integer.class, MockSerializedClass.class)
+        );
+    }
+
+    @Test
+    public void ourTestClassAcceptedSecondWildcard() throws Exception {
+        assertSerialization(
+                closeAfterEachTest(new ValidatingObjectInputStream(testStream))
+                .accept("*Integer","*MockSerializedClass")
+        );
+    }
+
+    @Test
+    public void ourTestClassNotAccepted() {
+        assertThrows(InvalidClassException.class,
+                () -> assertSerialization(
+                closeAfterEachTest(new ValidatingObjectInputStream(testStream))
+                .accept(Integer.class)
+        ));
+    }
+
+    @Test
+    public void ourTestClassOnlyAccepted() throws Exception {
+        assertSerialization(
+                closeAfterEachTest(new ValidatingObjectInputStream(testStream))
+                .accept(MockSerializedClass.class)
+        );
+    }
+
+    @Test
+    public void reject() {
+        assertThrows(InvalidClassException.class,
+                () -> assertSerialization(
+                closeAfterEachTest(new ValidatingObjectInputStream(testStream))
+                .accept(Long.class)
+                .reject(MockSerializedClass.class, Integer.class)
+        ));
+    }
+
+    @Test
+    public void rejectCustomMatcher() {
+        assertThrows(InvalidClassException.class,
+                () -> assertSerialization(
+                closeAfterEachTest(new ValidatingObjectInputStream(testStream))
+                .accept(MockSerializedClass.class)
+                .reject(ALWAYS_TRUE)
+        ));
+    }
+
+    @Test
+    public void rejectOnly() {
+        assertThrows(InvalidClassException.class,
+                () -> assertSerialization(
+                closeAfterEachTest(new ValidatingObjectInputStream(testStream))
+                .reject(Integer.class)
+        ));
+    }
+
+    @Test
+    public void rejectPattern() {
+        assertThrows(InvalidClassException.class,
+                () -> assertSerialization(
+                closeAfterEachTest(new ValidatingObjectInputStream(testStream))
+                .accept(MockSerializedClass.class)
+                .reject(Pattern.compile("org.*"))
+        ));
+    }
+
+    @Test
+    public void rejectPrecedence() {
+        assertThrows(InvalidClassException.class,
+                () -> assertSerialization(
+                closeAfterEachTest(new ValidatingObjectInputStream(testStream))
+                .accept(MockSerializedClass.class)
+                .reject(MockSerializedClass.class, Integer.class)
+        ));
+    }
+
+    @Test
+    public void rejectWildcard() {
+        assertThrows(InvalidClassException.class,
+                () -> assertSerialization(
+                closeAfterEachTest(new ValidatingObjectInputStream(testStream))
+                .accept(MockSerializedClass.class)
+                .reject("org.*")
+        ));
+    }
+
+    @BeforeEach
+    public void setupMockSerializedClass() throws IOException {
+        testObject = new MockSerializedClass(UUID.randomUUID().toString());
+        final ByteArrayOutputStream bos = closeAfterEachTest(new ByteArrayOutputStream());
+        final ObjectOutputStream oos = closeAfterEachTest(new ObjectOutputStream(bos));
+        oos.writeObject(testObject);
+        testStream = closeAfterEachTest(new ByteArrayInputStream(bos.toByteArray()));
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/commons/io/serialization/WildcardClassNameMatcherTest.java b/src/test/java/org/apache/commons/io/serialization/WildcardClassNameMatcherTest.java
new file mode 100644
index 0000000..e76424a
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/serialization/WildcardClassNameMatcherTest.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.commons.io.serialization;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+public class WildcardClassNameMatcherTest {
+
+    @Test
+    public void noPattern() {
+        final ClassNameMatcher ca = new WildcardClassNameMatcher("org.foo");
+        assertTrue(ca.matches("org.foo"));
+        assertFalse(ca.matches("org.foo.and.more"));
+        assertFalse(ca.matches("org_foo"));
+    }
+
+    @Test
+    public void star() {
+        final ClassNameMatcher ca = new WildcardClassNameMatcher("org*");
+        assertTrue(ca.matches("org.foo.should.match"));
+        assertFalse(ca.matches("bar.should.not.match"));
+    }
+
+    @Test
+    public void starAndQuestionMark() {
+        final ClassNameMatcher ca = new WildcardClassNameMatcher("org?apache?something*");
+        assertTrue(ca.matches("org.apache_something.more"));
+        assertFalse(ca.matches("org..apache_something.more"));
+    }
+
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/commons/io/test/TestUtils.java b/src/test/java/org/apache/commons/io/test/TestUtils.java
new file mode 100644
index 0000000..8ace275
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/test/TestUtils.java
@@ -0,0 +1,263 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.io.Reader;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.output.ByteArrayOutputStream;
+import org.apache.commons.lang3.ThreadUtils;
+
+/**
+ * Base class for tests doing tests with files.
+ */
+public abstract class TestUtils {
+
+    /**
+     * Assert that the content of a file is equal to that in a byte[].
+     *
+     * @param b0   the expected contents
+     * @param file the file to check
+     * @throws IOException If an I/O error occurs while reading the file contents
+     */
+    public static void assertEqualContent(final byte[] b0, final File file) throws IOException {
+        assertEqualContent(b0, file.toPath());
+    }
+
+    /**
+     * Assert that the content of a file is equal to that in a byte[].
+     *
+     * @param b0   the expected contents
+     * @param file the file to check
+     * @throws IOException If an I/O error occurs while reading the file contents
+     */
+    public static void assertEqualContent(final byte[] b0, final Path file) throws IOException {
+        int count = 0, numRead = 0;
+        final byte[] b1 = new byte[b0.length];
+        try (InputStream is = Files.newInputStream(file)) {
+            while (count < b0.length && numRead >= 0) {
+                numRead = is.read(b1, count, b0.length);
+                count += numRead;
+            }
+            assertEquals(b0.length, count, "Different number of bytes: ");
+            for (int i = 0; i < count; i++) {
+                assertEquals(b0[i], b1[i], "byte " + i + " differs");
+            }
+        }
+    }
+
+    /**
+     * Assert that the content of a file is equal to that in a char[].
+     *
+     * @param c0   the expected contents
+     * @param file the file to check
+     * @throws IOException If an I/O error occurs while reading the file contents
+     */
+    public static void assertEqualContent(final char[] c0, final File file) throws IOException {
+        assertEqualContent(c0, file.toPath());
+    }
+
+    /**
+     * Assert that the content of a file is equal to that in a char[].
+     *
+     * @param c0   the expected contents
+     * @param file the file to check
+     * @throws IOException If an I/O error occurs while reading the file contents
+     */
+    public static void assertEqualContent(final char[] c0, final Path file) throws IOException {
+        int count = 0, numRead = 0;
+        final char[] c1 = new char[c0.length];
+        try (Reader ir = Files.newBufferedReader(file)) {
+            while (count < c0.length && numRead >= 0) {
+                numRead = ir.read(c1, count, c0.length);
+                count += numRead;
+            }
+            assertEquals(c0.length, count, "Different number of chars: ");
+            for (int i = 0; i < count; i++) {
+                assertEquals(c0[i], c1[i], "char " + i + " differs");
+            }
+        }
+    }
+
+    /**
+     * Assert that the content of two files is the same.
+     */
+    private static void assertEqualContent(final File f0, final File f1)
+            throws IOException {
+        /* This doesn't work because the filesize isn't updated until the file
+         * is closed.
+        assertTrue( "The files " + f0 + " and " + f1 +
+                    " have differing file sizes (" + f0.length() +
+                    " vs " + f1.length() + ")", ( f0.length() == f1.length() ) );
+        */
+        try (InputStream is0 = Files.newInputStream(f0.toPath())) {
+            try (InputStream is1 = Files.newInputStream(f1.toPath())) {
+                final byte[] buf0 = new byte[1024];
+                final byte[] buf1 = new byte[1024];
+                int n0 = 0;
+                int n1;
+
+                while (-1 != n0) {
+                    n0 = is0.read(buf0);
+                    n1 = is1.read(buf1);
+                    assertTrue(n0 == n1,
+                            "The files " + f0 + " and " + f1 +
+                            " have differing number of bytes available (" + n0 + " vs " + n1 + ")");
+
+                    assertArrayEquals(buf0, buf1, "The files " + f0 + " and " + f1 + " have different content");
+                }
+            }
+        }
+    }
+
+    public static void checkFile(final File file, final File referenceFile)
+            throws Exception {
+        assertTrue(file.exists(), "Check existence of output file");
+        assertEqualContent(referenceFile, file);
+    }
+
+    public static void checkWrite(final OutputStream output) {
+        try {
+            new java.io.PrintStream(output).write(0);
+        } catch (final Throwable t) {
+            fail("The copy() method closed the stream when it shouldn't have. " + t.getMessage());
+        }
+    }
+
+    public static void checkWrite(final Writer output) {
+        try {
+            new java.io.PrintWriter(output).write('a');
+        } catch (final Throwable t) {
+            fail("The copy() method closed the stream when it shouldn't have. " + t.getMessage());
+        }
+    }
+
+    public static void createFile(final File file, final long size)
+            throws IOException {
+        if (!file.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + file
+                    + " as the parent directory does not exist");
+        }
+        try (BufferedOutputStream output =
+                new BufferedOutputStream(Files.newOutputStream(file.toPath()))) {
+            generateTestData(output, size);
+        }
+    }
+
+    public static void createLineBasedFile(final File file, final String[] data) throws IOException {
+        if (file.getParentFile() != null && !file.getParentFile().exists()) {
+            throw new IOException("Cannot create file " + file + " as the parent directory does not exist");
+        }
+        try (PrintWriter output = new PrintWriter(new OutputStreamWriter(Files.newOutputStream(file.toPath()), StandardCharsets.UTF_8))) {
+            for (final String element : data) {
+                output.println(element);
+            }
+        }
+    }
+
+    public static void deleteFile(final File file) {
+        if (file.exists()) {
+            assertTrue(file.delete(), "Couldn't delete file: " + file);
+        }
+    }
+
+    public static void generateTestData(final File file, final long size) throws IOException, FileNotFoundException {
+        try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(file.toPath()))) {
+            generateTestData(output, size);
+        }
+    }
+
+    public static byte[] generateTestData(final long size) {
+        try {
+            try (ByteArrayOutputStream baout = new ByteArrayOutputStream()) {
+                generateTestData(baout, size);
+                return baout.toByteArray();
+            }
+        } catch (final IOException ioe) {
+            throw new IllegalStateException("This should never happen: " + ioe.getMessage(), ioe);
+        }
+    }
+
+    public static void generateTestData(final OutputStream out, final long size) throws IOException {
+        for (int i = 0; i < size; i++) {
+            // output.write((byte)'X');
+            // nice varied byte pattern compatible with Readers and Writers
+            out.write((byte) (i % 127 + 1));
+        }
+    }
+
+    public static File newFile(final File testDirectory, final String filename) throws IOException {
+        final File destination = new File(testDirectory, filename);
+        /*
+        assertTrue( filename + "Test output data file shouldn't previously exist",
+                    !destination.exists() );
+        */
+        if (destination.exists()) {
+            FileUtils.forceDelete(destination);
+        }
+        return destination;
+    }
+
+    /**
+     * Sleeps for a guaranteed number of milliseconds unless interrupted.
+     *
+     * This method exists because Thread.sleep(100) can sleep for 0, 70, 100 or 200ms or anything else
+     * it deems appropriate. Read the docs on Thread.sleep for further details.
+     *
+     * @param millis the number of milliseconds to sleep.
+     * @throws InterruptedException if interrupted.
+     */
+    public static void sleep(final long millis) throws InterruptedException {
+        ThreadUtils.sleep(Duration.ofMillis(millis));
+    }
+
+    /**
+     * Sleeps and swallows InterruptedException.
+     *
+     * @param millis the number of milliseconds to sleep.
+     */
+    public static void sleepQuietly(final long millis) {
+        try {
+            sleep(millis);
+        } catch (final InterruptedException ignored){
+            // ignore InterruptedException.
+        }
+    }
+
+    private TestUtils() {
+
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/test/ThrowOnCloseInputStream.java b/src/test/java/org/apache/commons/io/test/ThrowOnCloseInputStream.java
new file mode 100644
index 0000000..5a12379
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/test/ThrowOnCloseInputStream.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.test;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.apache.commons.io.input.NullInputStream;
+import org.apache.commons.io.input.ProxyInputStream;
+
+/**
+ * Helper class for checking behavior of IO classes.
+ */
+public class ThrowOnCloseInputStream extends ProxyInputStream {
+
+    /**
+     * Default constructor.
+     */
+    public ThrowOnCloseInputStream() {
+        super(NullInputStream.INSTANCE);
+    }
+
+    /**
+     * @param proxy InputStream to delegate to.
+     */
+    public ThrowOnCloseInputStream(final InputStream proxy) {
+        super(proxy);
+    }
+
+    /** @see java.io.InputStream#close() */
+    @Override
+    public void close() throws IOException {
+        throw new IOException(getClass().getSimpleName() + ".close() called.");
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/test/ThrowOnCloseOutputStream.java b/src/test/java/org/apache/commons/io/test/ThrowOnCloseOutputStream.java
new file mode 100644
index 0000000..d85a2c3
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/test/ThrowOnCloseOutputStream.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.test;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.apache.commons.io.output.NullOutputStream;
+import org.apache.commons.io.output.ProxyOutputStream;
+
+/**
+ * Helper class for checking behavior of IO classes.
+ */
+public class ThrowOnCloseOutputStream extends ProxyOutputStream {
+
+    /**
+     * Default constructor.
+     */
+    public ThrowOnCloseOutputStream() {
+        super(NullOutputStream.INSTANCE);
+    }
+
+    /**
+     * @param proxy OutputStream to delegate to.
+     */
+    public ThrowOnCloseOutputStream(final OutputStream proxy) {
+        super(proxy);
+    }
+
+    /** @see java.io.OutputStream#close() */
+    @Override
+    public void close() throws IOException {
+        throw new IOException(getClass().getSimpleName() + ".close() called.");
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/test/ThrowOnCloseReader.java b/src/test/java/org/apache/commons/io/test/ThrowOnCloseReader.java
new file mode 100644
index 0000000..855d3da
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/test/ThrowOnCloseReader.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.test;
+
+import java.io.IOException;
+import java.io.Reader;
+
+import org.apache.commons.io.input.NullReader;
+import org.apache.commons.io.input.ProxyReader;
+
+/**
+ * Helper class for checking behavior of IO classes.
+ */
+public class ThrowOnCloseReader extends ProxyReader {
+
+    /**
+     * Default constructor.
+     */
+    public ThrowOnCloseReader() {
+        super(NullReader.INSTANCE);
+    }
+
+    /**
+     * @param proxy Reader to delegate to.
+     */
+    public ThrowOnCloseReader(final Reader proxy) {
+        super(proxy);
+    }
+
+    /** @see java.io.Reader#close() */
+    @Override
+    public void close() throws IOException {
+        throw new IOException(getClass().getSimpleName() + ".close() called.");
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/test/ThrowOnCloseWriter.java b/src/test/java/org/apache/commons/io/test/ThrowOnCloseWriter.java
new file mode 100644
index 0000000..307f1b0
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/test/ThrowOnCloseWriter.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.test;
+
+import java.io.IOException;
+import java.io.Writer;
+
+import org.apache.commons.io.output.NullWriter;
+import org.apache.commons.io.output.ProxyWriter;
+
+/**
+ * Helper class for checking behavior of IO classes.
+ */
+public class ThrowOnCloseWriter extends ProxyWriter {
+
+    /**
+     * Default constructor.
+     */
+    public ThrowOnCloseWriter() {
+        super(NullWriter.INSTANCE);
+    }
+
+    /**
+     * @param proxy Writer to delegate to.
+     */
+    public ThrowOnCloseWriter(final Writer proxy) {
+        super(proxy);
+    }
+
+    /** @see java.io.Writer#close() */
+    @Override
+    public void close() throws IOException {
+        throw new IOException(getClass().getSimpleName() + ".close() called.");
+    }
+
+}
diff --git a/src/test/java/org/apache/commons/io/test/ThrowOnFlushAndCloseOutputStream.java b/src/test/java/org/apache/commons/io/test/ThrowOnFlushAndCloseOutputStream.java
new file mode 100644
index 0000000..f63ee5d
--- /dev/null
+++ b/src/test/java/org/apache/commons/io/test/ThrowOnFlushAndCloseOutputStream.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.test;
+
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.apache.commons.io.output.ProxyOutputStream;
+
+/**
+ * Helper class for checking behavior of IO classes.
+ */
+public class ThrowOnFlushAndCloseOutputStream extends ProxyOutputStream {
+
+    private boolean throwOnFlush;
+    private boolean throwOnClose;
+
+    /**
+     * @param proxy OutputStream to delegate to.
+     * @param throwOnFlush True if flush() is forbidden
+     * @param throwOnClose True if close() is forbidden
+     */
+    public ThrowOnFlushAndCloseOutputStream(final OutputStream proxy, final boolean throwOnFlush,
+        final boolean throwOnClose) {
+        super(proxy);
+        this.throwOnFlush = throwOnFlush;
+        this.throwOnClose = throwOnClose;
+    }
+
+    /** @see java.io.OutputStream#close() */
+    @Override
+    public void close() throws IOException {
+        if (throwOnClose) {
+            fail(getClass().getSimpleName() + ".close() called.");
+        }
+        super.close();
+    }
+
+    /** @see java.io.OutputStream#flush() */
+    @Override
+    public void flush() throws IOException {
+        if (throwOnFlush) {
+            fail(getClass().getSimpleName() + ".flush() called.");
+        }
+        super.flush();
+    }
+
+    public void off() {
+        throwOnFlush = false;
+        throwOnClose = false;
+    }
+
+}
diff --git a/src/test/resources/.gitattributes b/src/test/resources/.gitattributes
new file mode 100644
index 0000000..32c7972
--- /dev/null
+++ b/src/test/resources/.gitattributes
@@ -0,0 +1,17 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+*        -text
diff --git a/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1/directory-files-only1/file1.txt b/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1/directory-files-only1/file1.txt
new file mode 100644
index 0000000..56a6051
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1/directory-files-only1/file1.txt
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1/directory-files-only1/file2.txt b/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1/directory-files-only1/file2.txt
new file mode 100644
index 0000000..d8263ee
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1/directory-files-only1/file2.txt
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1/file1.txt b/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1/file1.txt
new file mode 100644
index 0000000..56a6051
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1/file1.txt
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1/file2.txt b/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1/file2.txt
new file mode 100644
index 0000000..d8263ee
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files1/file2.txt
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files2/directory-files-only2/file1.txt b/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files2/directory-files-only2/file1.txt
new file mode 100644
index 0000000..56a6051
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files2/directory-files-only2/file1.txt
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files2/directory-files-only2/file2.txt b/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files2/directory-files-only2/file2.txt
new file mode 100644
index 0000000..d8263ee
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files2/directory-files-only2/file2.txt
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files2/file1.txt b/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files2/file1.txt
new file mode 100644
index 0000000..56a6051
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files2/file1.txt
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files2/file2.txt b/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files2/file2.txt
new file mode 100644
index 0000000..d8263ee
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-dirs-and-files/dirs-and-files2/file2.txt
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/dir1/directory-files-only1/file1.txt b/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/dir1/directory-files-only1/file1.txt
new file mode 100644
index 0000000..56a6051
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/dir1/directory-files-only1/file1.txt
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/dir1/directory-files-only1/file2.txt b/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/dir1/directory-files-only1/file2.txt
new file mode 100644
index 0000000..d8263ee
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/dir1/directory-files-only1/file2.txt
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/dir2/directory-files-only1/file1.txt b/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/dir2/directory-files-only1/file1.txt
new file mode 100644
index 0000000..56a6051
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/dir2/directory-files-only1/file1.txt
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/dir2/directory-files-only1/file2.txt b/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/dir2/directory-files-only1/file2.txt
new file mode 100644
index 0000000..d8263ee
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/dir2/directory-files-only1/file2.txt
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/directory-files-only1/file1.txt b/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/directory-files-only1/file1.txt
new file mode 100644
index 0000000..56a6051
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/directory-files-only1/file1.txt
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/directory-files-only1/file2.txt b/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/directory-files-only1/file2.txt
new file mode 100644
index 0000000..d8263ee
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/directory-files-only1/file2.txt
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/directory-files-only2/file1.txt b/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/directory-files-only2/file1.txt
new file mode 100644
index 0000000..56a6051
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/directory-files-only2/file1.txt
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/directory-files-only2/file2.txt b/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/directory-files-only2/file2.txt
new file mode 100644
index 0000000..d8263ee
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-dirs-then-files/directory-files-only2/file2.txt
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-files-only/directory-files-only1/file1.txt b/src/test/resources/dir-equals-tests/dir-equals-files-only/directory-files-only1/file1.txt
new file mode 100644
index 0000000..56a6051
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-files-only/directory-files-only1/file1.txt
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-files-only/directory-files-only1/file2.txt b/src/test/resources/dir-equals-tests/dir-equals-files-only/directory-files-only1/file2.txt
new file mode 100644
index 0000000..d8263ee
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-files-only/directory-files-only1/file2.txt
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-files-only/directory-files-only2/file1.txt b/src/test/resources/dir-equals-tests/dir-equals-files-only/directory-files-only2/file1.txt
new file mode 100644
index 0000000..56a6051
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-files-only/directory-files-only2/file1.txt
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/src/test/resources/dir-equals-tests/dir-equals-files-only/directory-files-only2/file2.txt b/src/test/resources/dir-equals-tests/dir-equals-files-only/directory-files-only2/file2.txt
new file mode 100644
index 0000000..d8263ee
--- /dev/null
+++ b/src/test/resources/dir-equals-tests/dir-equals-files-only/directory-files-only2/file2.txt
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git a/src/test/resources/org/apache/commons/io/.gitattributes b/src/test/resources/org/apache/commons/io/.gitattributes
new file mode 100644
index 0000000..06d4160
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/.gitattributes
@@ -0,0 +1,17 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more

+# contributor license agreements.  See the NOTICE file distributed with

+# this work for additional information regarding copyright ownership.

+# The ASF licenses this file to You under the Apache License, Version 2.0

+# (the "License"); you may not use this file except in compliance with

+# the License.  You may obtain a copy of the License at

+#

+#      http://www.apache.org/licenses/LICENSE-2.0

+#

+# Unless required by applicable law or agreed to in writing, software

+# distributed under the License is distributed on an "AS IS" BASIS,

+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.

+# See the License for the specific language governing permissions and

+# limitations under the License.

+#

+

+*.dat -diff

diff --git a/src/test/resources/org/apache/commons/io/CharSequenceReader.bin b/src/test/resources/org/apache/commons/io/CharSequenceReader.bin
new file mode 100644
index 0000000..de6c460
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/CharSequenceReader.bin
Binary files differ
diff --git a/src/test/resources/org/apache/commons/io/FileUtilsTestDataCR.dat b/src/test/resources/org/apache/commons/io/FileUtilsTestDataCR.dat
new file mode 100644
index 0000000..beff0d6
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/FileUtilsTestDataCR.dat
Binary files differ
diff --git a/src/test/resources/org/apache/commons/io/FileUtilsTestDataCRLF.dat b/src/test/resources/org/apache/commons/io/FileUtilsTestDataCRLF.dat
new file mode 100644
index 0000000..294d779
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/FileUtilsTestDataCRLF.dat
@@ -0,0 +1,3 @@
+1

+2

+3

diff --git a/src/test/resources/org/apache/commons/io/FileUtilsTestDataLF.dat b/src/test/resources/org/apache/commons/io/FileUtilsTestDataLF.dat
new file mode 100644
index 0000000..01e79c3
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/FileUtilsTestDataLF.dat
@@ -0,0 +1,3 @@
+1
+2
+3
diff --git a/src/test/resources/org/apache/commons/io/abitmorethan16k.txt b/src/test/resources/org/apache/commons/io/abitmorethan16k.txt
new file mode 100644
index 0000000..57f6249
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/abitmorethan16k.txt
@@ -0,0 +1,248 @@
+## Licensed to the Apache Software Foundation (ASF) under one

+## or more contributor license agreements.  See the NOTICE file

+## distributed with this work for additional information

+## regarding copyright ownership.  The ASF licenses this file

+## to you under the Apache License, Version 2.0 (the

+## "License"); you may not use this file except in compliance

+## with the License.  You may obtain a copy of the License at

+##

+##  http://www.apache.org/licenses/LICENSE-2.0

+##

+## Unless required by applicable law or agreed to in writing,

+## software distributed under the License is distributed on an

+## "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY

+## KIND, either express or implied.  See the License for the

+## specific language governing permissions and limitations

+## under the License.

+

+a bit more than 16 K

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

diff --git a/src/test/resources/org/apache/commons/io/abitmorethan16kcopy.txt b/src/test/resources/org/apache/commons/io/abitmorethan16kcopy.txt
new file mode 100644
index 0000000..57f6249
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/abitmorethan16kcopy.txt
@@ -0,0 +1,248 @@
+## Licensed to the Apache Software Foundation (ASF) under one

+## or more contributor license agreements.  See the NOTICE file

+## distributed with this work for additional information

+## regarding copyright ownership.  The ASF licenses this file

+## to you under the Apache License, Version 2.0 (the

+## "License"); you may not use this file except in compliance

+## with the License.  You may obtain a copy of the License at

+##

+##  http://www.apache.org/licenses/LICENSE-2.0

+##

+## Unless required by applicable law or agreed to in writing,

+## software distributed under the License is distributed on an

+## "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY

+## KIND, either express or implied.  See the License for the

+## specific language governing permissions and limitations

+## under the License.

+

+a bit more than 16 K

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

+012345679 012345679 012345679 012345679 012345679 012345679 012345679 

diff --git a/src/test/resources/org/apache/commons/io/dirs-1-file-size-0/file-size-0.bin b/src/test/resources/org/apache/commons/io/dirs-1-file-size-0/file-size-0.bin
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/dirs-1-file-size-0/file-size-0.bin
diff --git a/src/test/resources/org/apache/commons/io/dirs-1-file-size-1/file-size-1.bin b/src/test/resources/org/apache/commons/io/dirs-1-file-size-1/file-size-1.bin
new file mode 100644
index 0000000..2e65efe
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/dirs-1-file-size-1/file-size-1.bin
@@ -0,0 +1 @@
+a
\ No newline at end of file
diff --git a/src/test/resources/org/apache/commons/io/dirs-2-file-size-2/dirs-a-file-size-1/file-size-1.bin b/src/test/resources/org/apache/commons/io/dirs-2-file-size-2/dirs-a-file-size-1/file-size-1.bin
new file mode 100644
index 0000000..2e65efe
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/dirs-2-file-size-2/dirs-a-file-size-1/file-size-1.bin
@@ -0,0 +1 @@
+a
\ No newline at end of file
diff --git a/src/test/resources/org/apache/commons/io/dirs-2-file-size-2/dirs-b-file-size-1/file-size-1.bin b/src/test/resources/org/apache/commons/io/dirs-2-file-size-2/dirs-b-file-size-1/file-size-1.bin
new file mode 100644
index 0000000..2e65efe
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/dirs-2-file-size-2/dirs-b-file-size-1/file-size-1.bin
@@ -0,0 +1 @@
+a
\ No newline at end of file
diff --git a/src/test/resources/org/apache/commons/io/dirs-2-file-size-4/dirs-a-file-size-1/file-size-1.bin b/src/test/resources/org/apache/commons/io/dirs-2-file-size-4/dirs-a-file-size-1/file-size-1.bin
new file mode 100644
index 0000000..9ae9e86
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/dirs-2-file-size-4/dirs-a-file-size-1/file-size-1.bin
@@ -0,0 +1 @@
+ab
\ No newline at end of file
diff --git a/src/test/resources/org/apache/commons/io/dirs-2-file-size-4/dirs-a-file-size-1/file-size-2.bin b/src/test/resources/org/apache/commons/io/dirs-2-file-size-4/dirs-a-file-size-1/file-size-2.bin
new file mode 100644
index 0000000..eb49652
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/dirs-2-file-size-4/dirs-a-file-size-1/file-size-2.bin
@@ -0,0 +1 @@
+ac
\ No newline at end of file
diff --git a/src/test/resources/org/apache/commons/io/dirs-2-file-size-4/dirs-b-file-size-1/file-size-1.bin b/src/test/resources/org/apache/commons/io/dirs-2-file-size-4/dirs-b-file-size-1/file-size-1.bin
new file mode 100644
index 0000000..9ae9e86
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/dirs-2-file-size-4/dirs-b-file-size-1/file-size-1.bin
@@ -0,0 +1 @@
+ab
\ No newline at end of file
diff --git a/src/test/resources/org/apache/commons/io/dirs-2-file-size-4/dirs-b-file-size-1/file-size-2.bin b/src/test/resources/org/apache/commons/io/dirs-2-file-size-4/dirs-b-file-size-1/file-size-2.bin
new file mode 100644
index 0000000..eb49652
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/dirs-2-file-size-4/dirs-b-file-size-1/file-size-2.bin
@@ -0,0 +1 @@
+ac
\ No newline at end of file
diff --git a/src/test/resources/org/apache/commons/io/io639-1.bin b/src/test/resources/org/apache/commons/io/io639-1.bin
new file mode 100644
index 0000000..4277a52
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/io639-1.bin
@@ -0,0 +1,2 @@
+the
+test
diff --git a/src/test/resources/org/apache/commons/io/io639-2.bin b/src/test/resources/org/apache/commons/io/io639-2.bin
new file mode 100644
index 0000000..8405060
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/io639-2.bin
@@ -0,0 +1,2 @@
+
+test
diff --git a/src/test/resources/org/apache/commons/io/io639-3.bin b/src/test/resources/org/apache/commons/io/io639-3.bin
new file mode 100644
index 0000000..f6b17a1
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/io639-3.bin
@@ -0,0 +1,3 @@
+
+
+test
diff --git a/src/test/resources/org/apache/commons/io/io639-4.bin b/src/test/resources/org/apache/commons/io/io639-4.bin
new file mode 100644
index 0000000..139597f
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/io639-4.bin
@@ -0,0 +1,2 @@
+
+
diff --git a/src/test/resources/org/apache/commons/io/io639-5.bin b/src/test/resources/org/apache/commons/io/io639-5.bin
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/io639-5.bin
@@ -0,0 +1 @@
+
diff --git a/src/test/resources/org/apache/commons/io/test-file-20byteslength.bin b/src/test/resources/org/apache/commons/io/test-file-20byteslength.bin
new file mode 100644
index 0000000..9ae2ae9
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/test-file-20byteslength.bin
@@ -0,0 +1,2 @@
+123456789
+987654321
diff --git a/src/test/resources/org/apache/commons/io/test-file-empty.bin b/src/test/resources/org/apache/commons/io/test-file-empty.bin
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/test-file-empty.bin
diff --git a/src/test/resources/org/apache/commons/io/test-file-gbk.bin b/src/test/resources/org/apache/commons/io/test-file-gbk.bin
new file mode 100644
index 0000000..5c1efeb
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/test-file-gbk.bin
@@ -0,0 +1,2 @@
+Ã÷ݔ×Ó¾©

+¼òÌåÖÐÎÄ

diff --git a/src/test/resources/org/apache/commons/io/test-file-iso8859-1-shortlines-win-linebr.bin b/src/test/resources/org/apache/commons/io/test-file-iso8859-1-shortlines-win-linebr.bin
new file mode 100644
index 0000000..6291acb
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/test-file-iso8859-1-shortlines-win-linebr.bin
@@ -0,0 +1,12 @@
+1

+

+

+

+2

+

+

+

+3

+

+

+

diff --git a/src/test/resources/org/apache/commons/io/test-file-iso8859-1.bin b/src/test/resources/org/apache/commons/io/test-file-iso8859-1.bin
new file mode 100644
index 0000000..db93dd9
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/test-file-iso8859-1.bin
@@ -0,0 +1,52 @@
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ¥£±²®
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ¥£±²
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ¥£±
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ¥£
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ¥
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ 
+A Test Line. Special chars: ÄäÜüÖöß ÃáéíïçñÂ
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïç
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíï
+A Test Line. Special chars: ÄäÜüÖöß Ãáéí
+A Test Line. Special chars: ÄäÜüÖöß Ãáé
+A Test Line. Special chars: ÄäÜüÖöß Ãá
+A Test Line. Special chars: ÄäÜüÖöß Ã
+A Test Line. Special chars: ÄäÜüÖöß 
+A Test Line. Special chars: ÄäÜüÖöß
+A Test Line. Special chars: ÄäÜüÖö
+A Test Line. Special chars: ÄäÜüÖ
+A Test Line. Special chars: ÄäÜü
+A Test Line. Special chars: ÄäÜ
+A Test Line. Special chars: Ää
+A Test Line. Special chars: Ä
+A Test Line. Special chars: 
+A Test Line. Special chars:
+A Test Line. Special chars
+A Test Line. Special char
+A Test Line. Special cha
+A Test Line. Special ch
+A Test Line. Special c
+A Test Line. Special 
+A Test Line. Special
+A Test Line. Specia
+A Test Line. Speci
+A Test Line. Spec
+A Test Line. Spe
+A Test Line. Sp
+A Test Line. S
+A Test Line. 
+A Test Line.
+A Test Line
+A Test Lin
+A Test Li
+A Test L
+A Test 
+A Test
+A Tes
+A Te
+A T
+A 
+A
diff --git a/src/test/resources/org/apache/commons/io/test-file-shiftjis.bin b/src/test/resources/org/apache/commons/io/test-file-shiftjis.bin
new file mode 100644
index 0000000..74c6d05
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/test-file-shiftjis.bin
@@ -0,0 +1,2 @@
+Hiragana letters: ‚Ÿ‚ ‚¡‚¢‚£
+Kanji letters: –¾—AŽq‹ž
diff --git a/src/test/resources/org/apache/commons/io/test-file-simple-utf8.bin b/src/test/resources/org/apache/commons/io/test-file-simple-utf8.bin
new file mode 100644
index 0000000..83871a5
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/test-file-simple-utf8.bin
@@ -0,0 +1 @@
+ABC

diff --git a/src/test/resources/org/apache/commons/io/test-file-utf16be.bin b/src/test/resources/org/apache/commons/io/test-file-utf16be.bin
new file mode 100644
index 0000000..31bef00
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/test-file-utf16be.bin
Binary files differ
diff --git a/src/test/resources/org/apache/commons/io/test-file-utf16le.bin b/src/test/resources/org/apache/commons/io/test-file-utf16le.bin
new file mode 100644
index 0000000..023d31c
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/test-file-utf16le.bin
Binary files differ
diff --git a/src/test/resources/org/apache/commons/io/test-file-utf8-cr-only.bin b/src/test/resources/org/apache/commons/io/test-file-utf8-cr-only.bin
new file mode 100644
index 0000000..42748a8
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/test-file-utf8-cr-only.bin
Binary files differ
diff --git a/src/test/resources/org/apache/commons/io/test-file-utf8-win-linebr.bin b/src/test/resources/org/apache/commons/io/test-file-utf8-win-linebr.bin
new file mode 100644
index 0000000..91b0a06
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/test-file-utf8-win-linebr.bin
@@ -0,0 +1,52 @@
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ¥£±²®

+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ¥£±²

+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ¥£±

+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ¥£

+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ¥

+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ

+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©

+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ 

+A Test Line. Special chars: ÄäÜüÖöß ÃáéíïçñÂ

+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ

+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïç

+A Test Line. Special chars: ÄäÜüÖöß Ãáéíï

+A Test Line. Special chars: ÄäÜüÖöß Ãáéí

+A Test Line. Special chars: ÄäÜüÖöß Ãáé

+A Test Line. Special chars: ÄäÜüÖöß Ãá

+A Test Line. Special chars: ÄäÜüÖöß Ã

+A Test Line. Special chars: ÄäÜüÖöß 

+A Test Line. Special chars: ÄäÜüÖöß

+A Test Line. Special chars: ÄäÜüÖö

+A Test Line. Special chars: ÄäÜüÖ

+A Test Line. Special chars: ÄäÜü

+A Test Line. Special chars: ÄäÜ

+A Test Line. Special chars: Ää

+A Test Line. Special chars: Ä

+A Test Line. Special chars: 

+A Test Line. Special chars:

+A Test Line. Special chars

+A Test Line. Special char

+A Test Line. Special cha

+A Test Line. Special ch

+A Test Line. Special c

+A Test Line. Special 

+A Test Line. Special

+A Test Line. Specia

+A Test Line. Speci

+A Test Line. Spec

+A Test Line. Spe

+A Test Line. Sp

+A Test Line. S

+A Test Line. 

+A Test Line.

+A Test Line

+A Test Lin

+A Test Li

+A Test L

+A Test 

+A Test

+A Tes

+A Te

+A T

+A 

+A

diff --git a/src/test/resources/org/apache/commons/io/test-file-utf8.bin b/src/test/resources/org/apache/commons/io/test-file-utf8.bin
new file mode 100644
index 0000000..616b091
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/test-file-utf8.bin
@@ -0,0 +1,52 @@
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ¥£±²®
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ¥£±²
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ¥£±
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ¥£
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ¥
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©µ
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ ©
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ 
+A Test Line. Special chars: ÄäÜüÖöß ÃáéíïçñÂ
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïçñ
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíïç
+A Test Line. Special chars: ÄäÜüÖöß Ãáéíï
+A Test Line. Special chars: ÄäÜüÖöß Ãáéí
+A Test Line. Special chars: ÄäÜüÖöß Ãáé
+A Test Line. Special chars: ÄäÜüÖöß Ãá
+A Test Line. Special chars: ÄäÜüÖöß Ã
+A Test Line. Special chars: ÄäÜüÖöß 
+A Test Line. Special chars: ÄäÜüÖöß
+A Test Line. Special chars: ÄäÜüÖö
+A Test Line. Special chars: ÄäÜüÖ
+A Test Line. Special chars: ÄäÜü
+A Test Line. Special chars: ÄäÜ
+A Test Line. Special chars: Ää
+A Test Line. Special chars: Ä
+A Test Line. Special chars: 
+A Test Line. Special chars:
+A Test Line. Special chars
+A Test Line. Special char
+A Test Line. Special cha
+A Test Line. Special ch
+A Test Line. Special c
+A Test Line. Special 
+A Test Line. Special
+A Test Line. Specia
+A Test Line. Speci
+A Test Line. Spec
+A Test Line. Spe
+A Test Line. Sp
+A Test Line. S
+A Test Line. 
+A Test Line.
+A Test Line
+A Test Lin
+A Test Li
+A Test L
+A Test 
+A Test
+A Tes
+A Te
+A T
+A 
+A
diff --git a/src/test/resources/org/apache/commons/io/test-file-windows-31j.bin b/src/test/resources/org/apache/commons/io/test-file-windows-31j.bin
new file mode 100644
index 0000000..eff55df
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/test-file-windows-31j.bin
@@ -0,0 +1,2 @@
+‚Ÿ‚ ‚¡‚¢‚£

+–¾—AŽq‹ž

diff --git a/src/test/resources/org/apache/commons/io/test-file-x-windows-949.bin b/src/test/resources/org/apache/commons/io/test-file-x-windows-949.bin
new file mode 100644
index 0000000..60d203e
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/test-file-x-windows-949.bin
@@ -0,0 +1,2 @@
+Çѱ¹¾î

+´ëÇѹα¹

diff --git a/src/test/resources/org/apache/commons/io/test-file-x-windows-950.bin b/src/test/resources/org/apache/commons/io/test-file-x-windows-950.bin
new file mode 100644
index 0000000..7c9cd59
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/test-file-x-windows-950.bin
@@ -0,0 +1,2 @@
+©ú¿é¤l¨Ê

+ÁcÅ餤¤å

diff --git a/src/test/resources/org/apache/commons/io/test.jar b/src/test/resources/org/apache/commons/io/test.jar
new file mode 100644
index 0000000..05b6d3d
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/test.jar
Binary files differ
diff --git a/src/test/resources/org/apache/commons/io/testfileBOM.xml b/src/test/resources/org/apache/commons/io/testfileBOM.xml
new file mode 100644
index 0000000..9701aaa
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/testfileBOM.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<element>
+Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.   
+
+Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.   
+
+Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.   
+
+Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.   
+
+Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis.   
+
+At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, At accusam aliquyam diam diam dolore dolores duo eirmod eos erat, et nonumy sed tempor et et invidunt justo labore Stet clita ea et gubergren, kasd magna no rebum. sanctus sea sed takimata ut vero voluptua. est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.   
+
+Consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus.   
+
+Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.   
+
+Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.   
+
+Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.   
+
+Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo
+</element>
diff --git a/src/test/resources/org/apache/commons/io/testfileNoBOM.xml b/src/test/resources/org/apache/commons/io/testfileNoBOM.xml
new file mode 100644
index 0000000..0c9c951
--- /dev/null
+++ b/src/test/resources/org/apache/commons/io/testfileNoBOM.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<element>
+Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.   
+
+Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.   
+
+Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.   
+
+Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.   
+
+Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis.   
+
+At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, At accusam aliquyam diam diam dolore dolores duo eirmod eos erat, et nonumy sed tempor et et invidunt justo labore Stet clita ea et gubergren, kasd magna no rebum. sanctus sea sed takimata ut vero voluptua. est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.   
+
+Consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus.   
+
+Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.   
+
+Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.   
+
+Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.   
+
+Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo
+</element>