Repository: pkolaczk/fclones (language: rust)

Issue:
add crypto hash and by byte content checking - enhancement suggestion
Overall I think that MetroHash you use is fine but some of us are more paranoid than others, also sometimes there are regulatory requirements we have to folllow.

What about to make it optional to use e.g. SHA512 (not sha256 as sha512 is faster on 64 bit CPUs) and for most paranoid simple byte by byte content compare?

ATM fclones defaults are perfectly reasonable but some of us would be happy to have even bigger choice.


Discussion / hints:
I have been thinking and reading a bit about hashing and I would like to argue here that fclones should allow other hash options.

1. metro hash

It is working and it is fast, but its biggest weakness is that it is one person creation without easily available peer reviews. You google it and there is very little out there to support author's claims. It does not matter much for suffix, prefix matching phase (even CRC would do) - it is perfect place to use very fast hash. But not the final full file hash. To be honest this the biggest single objection I have with using fclones. I am not questioning metro hash quality as I am not qualified. But looks like not many qualified people investigated it.

I have also noticed on various forums chatter that this one thing many people question re fclones. People want to trust proven and tested technology.


2. cryptographic hashes speed in context of deduplication

I was curious myself what is real life speed of various established hashes and run tests on my computer (2019 Intel CPU laptop)

single thread:

```
sha1		- 770 MB/sec
md5		- 630 MB/sec
sha512-256	- 625 MB/sec
sha512		- 625 MB/sec
blake2s256 	- 450 MB/sec
sha256		- 420 MB/sec
sha3-256	- 360 MB/sec	
```

indeed, they are much slower than non-crypto fast hashes. I have tried e.g., xxh128 and it was like 5000 MB/sec. I do not have metro implementation, but I guess it is the same range.

Among them I think the most suitable candidate would be sha512-256. sha1 and md5 are broken and collisions can be created. Others are slower. sha512 output would increase dramatically memory usage with mathematically insignificant benefits.

Good explanation in this paper:

https://eprint.iacr.org/2010/548.pdf

Key message from it:

"From our performance analysis, and experimentation
on 64 bit Intel Architecture, we have shown that the cost
of implementing a SHA-512 algorithm delivers a 50%
performance improvement over similar implementations
of SHA-256. We also showed that the storage costs for
implementing SHA-512 can be reduced by adding a small
amount of one-off computation to compute the SHA-512
constants - which we believe will be useful for
constrained implementation environment."

And truncated SHA512 is implemented in rust already:

https://docs.rs/sha2/0.4.1/sha2/struct.Sha512Trunc256.html


My computer has 8 cores CPU so I also tested multithreading:

`8 threads sha512-256		- 4000 MB/sec`

To put these numbers into context of deduplication - single thread is faster than any HDD can deliver and fast enough to crunch data from NAS over 10g network, multithread is faster than my NVME SSD.  256 bits hash would make the hashing index table size grow and lead to increased time both for hash generation and matching stages, but it would not be big penalty. Slower than metro? yes. But I think not so much on any modern computer for files deduplication.

I am only thinking here about last deduplication step - earlier stages can use whatever.

3. by byte content checking

this is not really needed (unless for paranoid people who do not get math) when good hash is used


4. competition

Other de-duplicators offer options e.g.:
rmlint - uses blake2b by default but allows highway256, metro256, metro or by byte.
yadf - default AHash but also offers Highway, MetroHash, SeaHash, XxHash
rdfind - default sha1 but also offers md5, sha256


5. summary

optional final stage files checking hash option is in my opinion most desirable missing fclones feature. Thanks to robust multithreading implementation it is clearly the fastest de-duplicator out there. This will also make much slower hashes impact on deduplication process speed minimal as with modern hardware still the bottleneck is mostly with disk I/O. Alternative hashes option in my opinion would convince many doubters and make fclones much more popular.

IMHO this is the low hanging fruit for significant functionality improvement. People will have option to balance their requirement.
Tested fclones vs others - 100GB folder but with many duplicates - this is actually 2x 50GB the same dataset so put stress on grouping by contents phase as all data has to be checked by reading full file content.

Only finding duplicates without any action (`fclones group` style):

```
fclones (metro, multi thread)- 42s
rdfind (sha1, single thread) - 4m25s 
jdupes (byte by byte compare, single thread) - 1m2s
```

My thoughts:

disk I/O is bottleneck for this deduplication check phase- all programs have to read all files and compare them either using hash or directly - interestingly byte by byte comparison is not much slower than fast hash. But in this case it is only because all duplicates are doubles. Any bigger groups would make hash comaprison faster, e.g., when I quadruple duplicates hash comparison advantage becomes more apparent:

```
fclones (metro, multi thread)- 1m32s
rdfind (sha1, single thread) - 9m2s 
jdupes (byte by byte compare, single thread) - 3m2s
```
 
jdupes gets slower when fclones and rdfind scale nicely.


calculating crypto hashes has its cost but it should not make much difference if multithreading is utilised -  fclones was 6x faster than rdfind but on my machine sha512-256 multithreding is 6x faster than single thread - means end result would be similar with much stronger hash function

fclones metro hash does not provide advantage when multithreading is used - and raises many questions. Fclones strenght is its multithreading. It makes looking for duplicates in massive dataset much faster (this test daset is small so advantage is not highlighted). You can see it in your NVME SSD benchmark where 1.5m files is used.
surprise surprise yadf levaes everybody behind - for quadruple duplicates it finishes in 43s - 2 times faster than fclones (for doubles the same). But it is only proof of concept and they advice fclones for real job.
and in addition SHA512-256 test on the latest MacBook Air (M2 ARM CPU)

single thread 1400 MB/sec

It is only to highlight that hash processing speed vs disk I/O is becoming more irrelevant.



Yes, I agree adding more hashing algorithms is a good idea, in particular adding support for well known cryptographic hashes. Metrohash was used not because it was the fastest, but actually because it was fast enough and I could easily compile on most platforms without too much hassle with conditional compilation, including ARM. Earlier I used one of hashes from `fasthash` crate, but it turned out it had some issues with portability (see: https://github.com/pkolaczk/fclones/issues/3). 

As far as performance is considered, hashing performance does not matter much for the wall clock time, and I agree with your conclusions that I/O is what affects the run time the most. However, there is a bit more to efficiency than just the amount of time you need to wait. Actually I don't want to load CPU too much while searching for dupes (particularly on fast SSD), because then it would render my machine useless for the time dupe search is running. This is actually what Ubuntu dejadup is doing whenever I'm backing up the system, and I literally hate it (yup, somehow it consumes all my CPU, and I/O not so much, fans go crazy, etc). 

To summarize, there are two things here, and both would be really good to implement:
* reconsidering some other more mature hash to be used as the default
* adding more hash choices, to allow the user to explore the tradeoffs by themselves

BTW: I'm curious how you got yadf so much faster. Can you share your repro steps? This is a surprise because in my testing yadf was actually quite much slower than fclones, so maybe there is some edge case I missed. Did you evict page cache before each run to make comparison fair? 




>
>BTW: I'm curious how you got yadf so much faster. Can you share your repro steps? This is a surprise because in my >testing yadf was actually quite much slower than fclones, so maybe there is some edge case I missed. Did you evict page >cache before each run to make comparison fair?
> 

I use 50GB folder with 24k files - mix of small and big. As I was interested about the last deduplication step speed I make two or four copies of this folder creating 24k duplicates groups forcing programs to read all content.

on your tests https://github.com/pkolaczk/fclones#ssd-benchmark yadf is actually second fastest on SSD and much slower on HDD

on yadf own tests https://github.com/jRimbault/yadf#benchmarks yadf is faster with warm cache and minimally second fastest with cold cache - they only test on SSD and explicitely say "I recommend fclones as it has more hardware heuristics. and in general more features. yadf on HDDs is terrible."

in my tests yadf is significantly faster than fclones both for warm and cold cache (I tested both, `sudo purge` in macOS to clean cache)

Why - no idea. Maybe it scales better with number of processes? You used 4 core CPU, yadf tests used 6 core and I used 8 core. Also I use macOS when two other tests were run in Ubuntu. We all most likely used different SSD. Too many factors to say for sure why:)

Also I tested the latest versions. In your tests yadf was v0.13 when now it is v1.0



>As far as performance is considered, hashing performance does not matter much for the wall clock time, and I agree with your conclusions that I/O is what affects the run time the most. However, there is a bit more to efficiency than just the amount of time you need to wait. Actually I don't want to load CPU too much while searching for dupes (particularly on fast SSD), because then it would render my machine useless for the time dupe search is running. This is actually what Ubuntu dejadup is doing whenever I'm backing up the system, and I literally hate it (yup, somehow it consumes all my CPU, and I/O not so much, fans go crazy, etc).

Agree regarding application impact on system performance. But clearly dejadup is not written well backup process to consume all resources when for such task it is abcolutely not needed.  Now in general it is something what should be optional - run program by default with full speed but give user option to start it with lower priority. I do this with my own backup -  runs during a day at very low priority but late night run uses all resources available and does extra tasks like verification.
>To summarize, there are two things here, and both would be really good to implement:
>
>   reconsidering some other more mature hash to be used as the default
>    adding more hash choices, to allow the user to explore the tradeoffs by themselves

I would not change default - definitely not for prefix and suffix steps. metro seems to be good tradeoff - especially in light of your comment on overall system performance.


EDIT: unless BLAKE3 provides similarly good tradeoffs


For second point as soon as option is available e.g. for 256 bits output hashes it should  be super easy to add some reasonable choice:

SHA512-256 - the fastest on 64bit architecture
SHA-256 - the fastest on 32 bits architecture
BLAKE2s-256, SHA3-256 - for people who believe that NSA is spying on them

Also there is "new" kid on the block - BLAKE3 - this could be maybe alternative to metro for default full content hashing? It is of course slower as it is designed as crypto hash. But quick test and read tell me that more than fast enough for deduplication.

https://github.com/BLAKE3-team/BLAKE3
https://crates.io/crates/blake3

It has been added to OpenZFS last year for flies' hashes and they are very conservative in adding something which is not proven - only this is good reccomendation. 

on my machine it is more than 2x faster compared to SHA512-256 on single thread:
`b3sum --num-threads 1 10GBtestFile` - 1400 MB/sec

using built in multithreding:
`b3sum 10GBtestFile` - 10,800 MB/sec

and hashing 8 files using 8 cores (b3 single threaded) - 10,000 MB/sec
```
b3sum --num-threads 1 1GBtestFile &
b3sum --num-threads 1 1GBtestFile2 &
b3sum --num-threads 1 1GBtestFile3 &
b3sum --num-threads 1 1GBtestFile4 &
b3sum --num-threads 1 1GBtestFile5 &
b3sum --num-threads 1 1GBtestFile6 &
b3sum --num-threads 1 1GBtestFile7 &
b3sum --num-threads 1 1GBtestFile8 &
wait
```

I tested multiple times to make sure files are in cache - as I was interested in hash speed - not my disk speed.

These numbers mean that it would never really need 100% CPU to do its job (no fans spinning) - simply it is faster than even fast NVME SSD I/O can provide.

All of them are probably already implemented in rust.
EDIT: they are already implemented.

I think any performance and efficiency questions can be only answered by testing. If you add configurable hash option I am happy to test it throughly.
> Why - no idea. Maybe it scales better with number of processes? You used 4 core CPU, yadf tests used 6 core and I used 8 core. Also I use macOS when two other tests were run in Ubuntu. We all most likely used different SSD. Too many factors to say for sure why:)
> 
> Also I tested the latest versions. In your tests yadf was v0.13 when now it is v1.0

Actually I have idea. I looked at htop because yadf is faster in any test I tried... yadf is using all my 8 cores vs fclones only using 4. Which explains why it is about x2 faster

It makes use of multicore much better:

<img width="709" alt="yadf" src="https://user-images.githubusercontent.com/15635386/188926441-71269b4a-5d5a-417c-b844-ab514dd2d97d.png">


<img width="709" alt="fclones" src="https://user-images.githubusercontent.com/15635386/188926455-dea3c8f8-df67-450e-bfc3-1d77ecd5ec53.png">


With fclones I can see thast all 8 cores are used during initial stages - but then only 4 when contents hashes are calculated.

What happens if you force number of threads to 8 with `-t 8` ?
Anyway I think this is worth opening a separate issue.
Good thing is that with proper parallelism and proper hashes fclones can become the best
for new hash algs I can also test on ARM - RPi and Apple M2 - so so surprises when released
Hey, got an hour today and added more hashes :)
Tested it only very quickly - blake3 is just as fast as metro on my laptop, but SHA family is visually slower, and you were right sha256 is slower than sha512.
SHA256 will be faster if somebody runs 32b CPU - blake3 crate is really well done with Intel and ARM optimization.

BTW - this is really great news!!!! I am going to test it later today
could not wait - blake3 is a bit slower than metro  for me - memory usage is up (about 2.5x) but it is 256b hash so no issue

I test it on 220 GB of duplicates:

[2022-09-10 18:24:59.815] fclones:  info: Found 75449 (220.6 GB) redundant files

metro - 75s
blake3 - 80s
sha512 - 100s
sha256 - 190s

is some way it is amazing that 220 GB can be read and processed in little over 1 min...  computers are crazy fast nowadays
It is great milestone! Now we have a choice.

Thank you again for this great project.
One thing:

you are using Sha512_256 but:

```
# fclones group --help

        --hash-fn <hash-fn>
            Hash function [default: metro128]  [possible values: metro128, blake3, sha256,
            sha512]
```

calls it sha512 (which might be missleading)

sha512 and sha512-256 are not the same


And with proper crypto hashes still much faster than jdupes:)

`jdupes --summarize -r -Q .`

takes 190s for my test data

BTW - competition noticed fclones:):

https://www.jodybruchon.com/2020/07/11/a-challenger-appears-fclones-fastest-duplicate-scanner-ever-its-complicated/
> One thing:
> 
> you are using Sha512_256 but:
> 
> ```
> # fclones group --help
> 
>         --hash-fn <hash-fn>
>             Hash function [default: metro128]  [possible values: metro128, blake3, sha256,
>             sha512]
> ```
> 
> calls it sha512 (which might be missleading)
> 
> sha512 and sha512-256 are not the same

sha512 has 512 bits output
sha512-256 has output truncated to 256 bits - but it is not as simple as taking half of sha512 output. To maintain required mathematical properties like randomness it is calculated in special way.

Both are the same family of hashes - sha2. Actually very close relatives but not the same. Their speed is the same - difference is memory requirement to juggle 512 vs 256 bits hashes. As per paper I sent link to - sha512-256 as for today is very good practical balance. 256 bits for files dedup is super safe.

It is very frustrating to see many software using sha256 becasue they assume that sha512 is always slower. And 32 bits hardware is a niche.
Also have tested on 32 bits ARM and Debian - this is usually where problems are with many modern software:) compiles ok and all hashes work

another observation - at least for my computer blake3 is super fast but when run on 8 cores makes my computer very slow.

good thing is that thx to options available in fclones when I run it as ssd:8,6 then all is fine... and it is marginally slower deduplication

Maybe in the future default threads strategy could be better - as I said new mac computers now have up to 20 cores. In such cases I/O bottlneck makes it pointless to use all of them even when they have insane fast SSDs - 7GB/sec transfers

Anyway it is good problem to have.




might be worth to mention in readme that some hashes are computationaly more intensive and  user should plan.

again thx to fclones options easy. Even if plaughing through TBs of data

I can use --cache and lower number of thread during a day - then ctrl-C bafore going to bed and change strategy

> Also have tested on 32 bits ARM and Debian - this is usually where problems are with many modern software:) compiles ok and all hashes work

Some speed results:

metro128 - 32s
blake3 - 41s
sha512-256 - 75s
sha256 - 54s

as expected on 32 bits CPU sha256 is faster than sha512

blake3 doing very well for such small hardware



Last comment is that for sake of making available set of hashes complete I would also include  sha3-256

https://en.wikipedia.org/wiki/SHA-3
https://docs.rs/sha3/0.7.3/sha3/struct.Sha3_256.html

Speed is very hardware depndent - probably slower today on our laptops  but things are changing quick. 

It is standard some people migh have to adhere to.

Fix patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index f0f78cd..9517e55 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,9 +1,27 @@
 version: 2
 
 jobs:
-  build-and-test:
+  format:
+    docker:
+      - image: cimg/rust:1.63.0
+    steps:
+      - checkout
+      - run:
+          name: Version information
+          command: |
+            rustc --version; cargo --version; rustup --version
+      - run:
+          name: Install formatter
+          command: |
+            rustup component add rustfmt
+      - run:
+          name: Check format
+          command: |
+            cargo fmt --all -- --check
+
+  build:
     docker:
-      - image: circleci/rust:latest
+      - image: cimg/rust:1.63.0
     environment:
       RUSTFLAGS: '-D warnings'
     steps:
@@ -27,14 +45,10 @@ jobs:
             - target/debug/.fingerprint
             - target/debug/build
             - target/debug/deps
-      - run:
-          name: Test
-          command: |
-            export RUST_BACKTRACE=1
-            cargo test
-  format:
+
+  clippy:
     docker:
-      - image: circleci/rust:latest
+      - image: cimg/rust:1.63.0
     steps:
       - checkout
       - run:
@@ -42,35 +56,51 @@ jobs:
           command: |
             rustc --version; cargo --version; rustup --version
       - run:
-          name: Install formatter
+          name: Install Clippy
           command: |
-            rustup component add rustfmt
+            rustup component add clippy
       - run:
-          name: Check format
+          name: Run Clippy
           command: |
-            cargo fmt --all -- --check
-  lint:
+            cargo clippy --all -- -D warnings -D rust-2018-idioms
+
+  test:
     docker:
-      - image: circleci/rust:latest
+      - image: cimg/rust:1.63.0
+    environment:
+      RUSTFLAGS: '-D warnings'
     steps:
       - checkout
       - run:
-         name: Version information
-         command: |
-           rustc --version; cargo --version; rustup --version
+          name: Calculate dependencies
+          command: |
+            rustc --version >rust-version
+            test -e Cargo.lock || cargo generate-lockfile
+      - restore_cache:
+          keys:
+            - cargo-cache-v1-{{ arch }}-{{checksum "rust-version"}}-{{ checksum "Cargo.lock" }}
       - run:
-         name: Install Clippy
-         command: |
-           rustup component add clippy
+          name: Build
+          command: |
+            cargo build --all
+      - save_cache:
+          key: cargo-cache-v1-{{ arch }}-{{checksum "rust-version"}}-{{ checksum "Cargo.lock" }}
+          paths:
+            - /usr/local/cargo/registry
+            - target/debug/.fingerprint
+            - target/debug/build
+            - target/debug/deps
       - run:
-          name: Run Clippy
+          name: Test
           command: |
-            cargo clippy --all -- -D warnings -D rust-2018-idioms
+            export RUST_BACKTRACE=1
+            cargo test
 
 workflows:
   version: 2
-  build-and-test:
+  all-checks:
     jobs:
-      - build-and-test
       - format
-      - lint
+      - build
+      - clippy
+      - test
diff --git a/Cargo.lock b/Cargo.lock
index aef27b1..3cb9806 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -20,12 +20,30 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "arrayref"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544"
+
 [[package]]
 name = "arrayvec"
 version = "0.5.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
 
+[[package]]
+name = "arrayvec"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8da52d66c7071e2e3fa2a1e5c6d088fec47b593032b254f5e980de8ea54454d6"
+
+[[package]]
+name = "assert_matches"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9"
+
 [[package]]
 name = "atomic-counter"
 version = "1.0.1"
@@ -64,6 +82,29 @@ version = "1.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
 
+[[package]]
+name = "blake3"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a08e53fc5a564bb15bfe6fae56bd71522205f1f91893f9c0116edad6496c183f"
+dependencies = [
+ "arrayref",
+ "arrayvec 0.7.2",
+ "cc",
+ "cfg-if",
+ "constant_time_eq",
+ "digest",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cce20737498f97b993470a6e536b8523f0af7892a4f928cceb1ac5e52ebe7e"
+dependencies = [
+ "generic-array",
+]
+
 [[package]]
 name = "bstr"
 version = "0.2.17"
@@ -163,12 +204,27 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "constant_time_eq"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
+
 [[package]]
 name = "core-foundation-sys"
 version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc"
 
+[[package]]
+name = "cpufeatures"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d997bd5e24a5928dd43e46dc529867e207907fe0b239c3477d924f7f2ca320"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "crc32fast"
 version = "1.3.2"
@@ -223,6 +279,16 @@ dependencies = [
  "lazy_static",
 ]
 
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
 [[package]]
 name = "csv"
 version = "1.1.6"
@@ -256,6 +322,17 @@ dependencies = [
  "parking_lot 0.12.0",
 ]
 
+[[package]]
+name = "digest"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+ "subtle",
+]
+
 [[package]]
 name = "dirs"
 version = "4.0.0"
@@ -324,11 +401,14 @@ dependencies = [
 
 [[package]]
 name = "fclones"
-version = "0.27.3"
+version = "0.28.0"
 dependencies = [
+ "assert_matches",
  "atomic-counter",
  "bincode",
+ "blake3",
  "byte-unit",
+ "byteorder",
  "bytesize",
  "chrono",
  "clap",
@@ -343,6 +423,7 @@ dependencies = [
  "fiemap",
  "file-owner",
  "filetime",
+ "hex",
  "ignore",
  "indexmap",
  "indicatif",
@@ -364,6 +445,8 @@ dependencies = [
  "serde",
  "serde_json",
  "serde_test",
+ "sha2",
+ "sha3",
  "sled",
  "smallvec",
  "stfu8",
@@ -376,6 +459,7 @@ dependencies = [
  "winapi",
  "winapi-util",
  "xattr",
+ "xxhash-rust",
 ]
 
 [[package]]
@@ -433,6 +517,16 @@ dependencies = [
  "byteorder",
 ]
 
+[[package]]
+name = "generic-array"
+version = "0.14.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bff49e947297f3312447abdca79f45f4738097cc82b06e72054d2223f601f1b9"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
 [[package]]
 name = "getrandom"
 version = "0.2.6"
@@ -481,6 +575,12 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
 [[package]]
 name = "ignore"
 version = "0.4.18"
@@ -561,6 +661,12 @@ version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35"
 
+[[package]]
+name = "keccak"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9b7d56ba4a8344d6be9729995e6b06f928af29998cdf79fe390cbf6b1fee838"
+
 [[package]]
 name = "lazy-init"
 version = "0.5.0"
@@ -579,7 +685,7 @@ version = "0.7.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe"
 dependencies = [
- "arrayvec",
+ "arrayvec 0.5.2",
  "bitflags",
  "cfg-if",
  "ryu",
@@ -1096,6 +1202,27 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "sha2"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf9db03534dff993187064c4e0c05a5708d2a9728ace9a8959b77bedf415dac5"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha3"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eaedf34ed289ea47c2b741bb72e5357a209512d67bcd4bda44359e5bf0470f56"
+dependencies = [
+ "digest",
+ "keccak",
+]
+
 [[package]]
 name = "sled"
 version = "0.34.7"
@@ -1164,6 +1291,12 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "subtle"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
+
 [[package]]
 name = "syn"
 version = "1.0.90"
@@ -1275,6 +1408,12 @@ dependencies = [
  "thiserror",
 ]
 
+[[package]]
+name = "typenum"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987"
+
 [[package]]
 name = "unicode-segmentation"
 version = "1.9.0"
@@ -1425,3 +1564,9 @@ checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc"
 dependencies = [
  "libc",
 ]
+
+[[package]]
+name = "xxhash-rust"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "074914ea4eec286eb8d1fd745768504f420a1f7b7919185682a4a267bed7d2e7"
diff --git a/Cargo.toml b/Cargo.toml
index 821473e..d7ae164 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "fclones"
-version = "0.27.3"
+version = "0.28.0"
 description = "Finds duplicate, unique, under- or over-replicated files"
 authors = ["Piotr Kołaczkowski <pkolaczk@gmail.com>"]
 homepage = "https://github.com/pkolaczk/fclones"
@@ -20,6 +20,8 @@ exclude = [
 [dependencies]
 atomic-counter = "1.0"
 bincode = "1.3"
+blake3 = { version = "1.3", optional = true }
+byteorder = "1.4"
 bytesize = "1.1"
 byte-unit = "4.0"
 chrono = { version = "0.4", features = ["serde", "clock"] }
@@ -33,6 +35,7 @@ dtparse = "1.2"
 dunce = "1.0"
 fallible-iterator = "0.2"
 filetime = "0.2"
+hex = "0.4"
 ignore = "0.4.18"
 indicatif = { version = "0.14", features = ["with_rayon"] }
 indexmap = "1.8"
@@ -50,6 +53,8 @@ rayon = "1.5"
 regex = "1.5"
 serde_json = "1.0"
 serde = { version = "1.0", features = ["derive"] }
+sha2 = { version = "0.10", optional = true }
+sha3 = { version = "0.10", optional = true }
 sled = "0.34"
 smallvec = "1.8"
 stfu8 = "0.2"
@@ -58,6 +63,11 @@ sysinfo = "0.23.10"
 thread_local = "1.1"
 typed-sled = "0.2.0"
 uuid = { version = "0.8", features = ["v4"] }
+xxhash-rust = { version = "0.8", features = ["xxh3"], optional = true }
+
+[features]
+default = ["blake3", "xxhash", "sha2", "sha3"]
+xxhash = ["xxhash-rust"]
 
 [target.'cfg(target_os = "linux")'.dependencies]
 fiemap = "0.1"
@@ -76,6 +86,7 @@ winapi-util = "0.1"
 reflink = "0.1"
 
 [dev-dependencies]
+assert_matches = "1.5"
 reflink = "0.1"
 serde_test = "1.0"
 tempfile = "3.3"
diff --git a/README.md b/README.md
index b6e6671..3eb3f13 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,7 @@ on either SSD or HDD storage.
   - parallel processing capability in all I/O and CPU heavy stages
   - automatic tuning of parallelism and access strategy based on device type (SSD vs HDD)
   - low memory footprint thanks to heavily optimized path representation
-  - fast, non-cryptographic 128-bit hashing function
+  - variety of fast non-cryptographic and cryptographic hash functions up to 512 bits wide
   - doesn't push data out of the page-cache (Linux-only)
   - optional persistent caching of file hashes
   - accurate progress reporting   
@@ -385,7 +385,7 @@ the previous stage must complete fully before the next one is started.
    (default 2). 
 3. In each group, remove duplicate files with the same inode id. The same file could be reached through different
    paths when hardlinks are present. This step can be optionally skipped.
-4. For each remaining file, compute a 128-bit hash of a tiny block of initial data. Put files with different hashes 
+4. For each remaining file, compute a hash of a tiny block of initial data. Put files with different hashes 
    into separate groups. Prune result groups if needed. 
 5. For each remaining file, compute a hash of a tiny block of data at the end of the file. 
    Put files with different hashes into separate groups. Prune small groups if needed.
@@ -394,10 +394,25 @@ the previous stage must complete fully before the next one is started.
    omitted. Same as in steps 4 and 5, split groups and remove the ones that are too small.
 7. Write report to the stdout.          
     
-Note that there is no byte-by-byte comparison of files anywhere. A fast and good 128-bit 
-[MetroHash](http://www.jandrewrogers.com/2015/05/27/metrohash/) hash function
-is used and you don't need to worry about hash collisions. At 10<sup>15</sup> files, the probability of collision is
-0.000000001, without taking into account the requirement for the files to also match by size.
+Note that there is no byte-by-byte comparison of files anywhere. All available hash functions are at least 
+128-bit wide, and you don't need to worry about hash collisions. At 10<sup>15</sup> files, 
+the probability of collision is 0.000000001 when using a 128-bit hash, 
+without taking into account the requirement for the files to also match by size.
+
+### Hashes
+You can select the hash function with `--hash-fn` (default: `metro`).
+Non-cryptographic hashes are much more efficient than cryptographic, 
+however you probably won't see much difference unless you're reading from a fast SSD or if file data is cached.
+
+| Hash function                                               | Hash width | Cryptographic | 
+|-------------------------------------------------------------|------------|---------------|
+| [metro](http://www.jandrewrogers.com/2015/05/27/metrohash/) | 128-bit    | No            |
+| [xxhash3](https://cyan4973.github.io/xxHash/)               | 128-bit    | No            |
+| [blake3](https://github.com/BLAKE3-team/BLAKE3)             | 256-bit    | Yes           |         
+| [sha256](https://en.wikipedia.org/wiki/SHA-2)               | 256-bit    | Yes           | 
+| [sha512](https://en.wikipedia.org/wiki/SHA-2)               | 512-bit    | Yes           | 
+| [sha3-256](https://en.wikipedia.org/wiki/SHA-3)             | 256-bit    | Yes           |
+| [sha3-512](https://en.wikipedia.org/wiki/SHA-3)             | 512-bit    | Yes           |
 
 ## Tuning
 This section provides hints on getting the best performance from `fclones`.
diff --git a/src/cache.rs b/src/cache.rs
index 8699845..78d5c4a 100644
--- a/src/cache.rs
+++ b/src/cache.rs
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
 
 use crate::error::Error;
 use crate::file::{FileChunk, FileHash, FileId, FileLen, FileMetadata, FilePos};
-use crate::hasher::HashAlgorithm;
+use crate::hasher::HashFn;
 use crate::path::Path;
 
 #[derive(Debug, Serialize, Deserialize)]
@@ -47,7 +47,7 @@ impl HashCache {
     pub fn open(
         database_path: &Path,
         transform: Option<&str>,
-        algorithm: HashAlgorithm,
+        algorithm: HashFn,
     ) -> Result<HashCache, Error> {
         create_dir_all(&database_path.to_path_buf()).map_err(|e| {
             format!(
@@ -71,10 +71,7 @@ impl HashCache {
 
     /// Opens the file hash database located in `fclones` subdir of user cache directory.
     /// If the database doesn't exist yet, creates a new one.
-    pub fn open_default(
-        transform: Option<&str>,
-        algorithm: HashAlgorithm,
-    ) -> Result<HashCache, Error> {
+    pub fn open_default(transform: Option<&str>, algorithm: HashFn) -> Result<HashCache, Error> {
         let cache_dir =
             dirs::cache_dir().ok_or("Could not obtain user cache directory from the system.")?;
         let hash_db_path = cache_dir.join("fclones");
@@ -160,7 +157,7 @@ mod test {
 
     use crate::cache::HashCache;
     use crate::file::{FileChunk, FileHash, FileLen, FileMetadata, FilePos};
-    use crate::hasher::HashAlgorithm;
+    use crate::hasher::HashFn;
     use crate::path::Path;
     use crate::util::test::{create_file, with_dir};
 
@@ -174,12 +171,14 @@ mod test {
             let chunk = FileChunk::new(&path, FilePos(0), FileLen(1000));
 
             let cache_path = Path::from(root.join("cache"));
-            let cache = HashCache::open(&cache_path, None, HashAlgorithm::MetroHash128).unwrap();
+            let cache = HashCache::open(&cache_path, None, HashFn::Metro).unwrap();
             let key = cache.key(&chunk, &metadata).unwrap();
-            let orig_hash = FileHash(12345);
+            let orig_hash = FileHash::from(12345);
 
             let data_len = FileLen(200);
-            cache.put(&key, &metadata, data_len, orig_hash).unwrap();
+            cache
+                .put(&key, &metadata, data_len, orig_hash.clone())
+                .unwrap();
             let cached_hash = cache.get(&key, &metadata).unwrap();
 
             assert_eq!(cached_hash, Some((data_len, orig_hash)))
@@ -196,10 +195,10 @@ mod test {
             let chunk = FileChunk::new(&path, FilePos(0), FileLen(1000));
 
             let cache_path = Path::from(root.join("cache"));
-            let cache = HashCache::open(&cache_path, None, HashAlgorithm::MetroHash128).unwrap();
+            let cache = HashCache::open(&cache_path, None, HashFn::Metro).unwrap();
             let key = cache.key(&chunk, &metadata).unwrap();
             cache
-                .put(&key, &metadata, chunk.len, FileHash(12345))
+                .put(&key, &metadata, chunk.len, FileHash::from(12345))
                 .unwrap();
 
             // modify the file
@@ -228,11 +227,11 @@ mod test {
             let chunk = FileChunk::new(&path, FilePos(0), FileLen(1000));
 
             let cache_path = Path::from(root.join("cache"));
-            let cache = HashCache::open(&cache_path, None, HashAlgorithm::MetroHash128).unwrap();
+            let cache = HashCache::open(&cache_path, None, HashFn::Metro).unwrap();
             let key = cache.key(&chunk, &metadata).unwrap();
 
             cache
-                .put(&key, &metadata, chunk.len, FileHash(12345))
+                .put(&key, &metadata, chunk.len, FileHash::from(12345))
                 .unwrap();
 
             let chunk = FileChunk::new(&path, FilePos(1000), FileLen(2000));
@@ -255,20 +254,19 @@ mod test {
                 let chunk = FileChunk::new(&path, FilePos(0), FileLen(1000));
 
                 let cache_path = Path::from(root.join("cache"));
-                let cache =
-                    HashCache::open(&cache_path, None, HashAlgorithm::MetroHash128).unwrap();
+                let cache = HashCache::open(&cache_path, None, HashFn::Metro).unwrap();
                 let key = cache.key(&chunk, &metadata).unwrap();
 
-                let orig_hash = FileHash(12345);
+                let orig_hash = FileHash::from(12345);
                 let data_len = FileLen(200);
-                cache.put(&key, &metadata, data_len, orig_hash).unwrap();
+                cache
+                    .put(&key, &metadata, data_len, orig_hash.clone())
+                    .unwrap();
                 let cached_hash = cache.get(&key, &metadata).unwrap();
                 assert_eq!(cached_hash, Some((data_len, orig_hash)));
                 drop(cache); // unlock the db so we can open another cache
 
-                let cache =
-                    HashCache::open(&cache_path, Some("transform"), HashAlgorithm::MetroHash128)
-                        .unwrap();
+                let cache = HashCache::open(&cache_path, Some("transform"), HashFn::Metro).unwrap();
                 let cached_hash = cache.get(&key, &metadata).unwrap();
                 assert_eq!(cached_hash, None);
             },
diff --git a/src/config.rs b/src/config.rs
index fd39889..45160c0 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -16,6 +16,7 @@ use structopt::StructOpt;
 use crate::file::FileLen;
 use crate::group::FileGroupFilter;
 use crate::group::Replication::{Overreplicated, Underreplicated};
+use crate::hasher::HashFn;
 use crate::path::Path;
 use crate::pattern::{Pattern, PatternError, PatternOpts};
 use crate::selector::PathSelector;
@@ -260,6 +261,10 @@ pub struct GroupConfig {
     #[structopt(short = "x", long)]
     pub regex: bool,
 
+    /// Hash function.
+    #[structopt(long, default_value = "metro", possible_values = &HashFn::variants())]
+    pub hash_fn: HashFn,
+
     /// Enables caching of file hashes.
     ///
     /// Caching can significantly speed up subsequent runs of `fclones group` by avoiding
@@ -718,3 +723,46 @@ pub struct Config {
     #[structopt(subcommand)]
     pub command: Command,
 }
+
+#[cfg(test)]
+mod test {
+    use crate::config::{Command, Config};
+    use crate::path::Path;
+    use assert_matches::assert_matches;
+    use std::path::PathBuf;
+    use structopt::StructOpt;
+
+    #[test]
+    fn test_group_command() {
+        let config: Config = Config::from_iter(vec!["fclones", "group", "dir1", "dir2"]);
+        assert_matches!(
+            config.command,
+            Command::Group(g) if g.paths == vec![Path::from("dir1"), Path::from("dir2")]);
+    }
+
+    #[test]
+    fn test_dedupe_command() {
+        let config: Config = Config::from_iter(vec!["fclones", "dedupe"]);
+        assert_matches!(config.command, Command::Dedupe { .. });
+    }
+
+    #[test]
+    fn test_remove_command() {
+        let config: Config = Config::from_iter(vec!["fclones", "remove"]);
+        assert_matches!(config.command, Command::Remove { .. });
+    }
+
+    #[test]
+    fn test_link_command() {
+        let config: Config = Config::from_iter(vec!["fclones", "link"]);
+        assert_matches!(config.command, Command::Link { .. });
+    }
+
+    #[test]
+    fn test_move_command() {
+        let config: Config = Config::from_iter(vec!["fclones", "move", "target"]);
+        assert_matches!(
+            config.command,
+            Command::Move { target, .. } if target == PathBuf::from("target"));
+    }
+}
diff --git a/src/dedupe.rs b/src/dedupe.rs
index c932607..7f2ce6f 100644
--- a/src/dedupe.rs
+++ b/src/dedupe.rs
@@ -763,7 +763,7 @@ where
     P: Into<Path>,
 {
     let file_len = group.file_len;
-    let file_hash = group.file_hash;
+    let file_hash = group.file_hash.clone();
     let error = |msg: &str| {
         Err(Error::from(format!(
             "Could not determine files to drop in group with hash {} and len {}: {}",
@@ -1035,6 +1035,7 @@ mod test {
     use std::collections::HashSet;
     use std::fs::{create_dir, create_dir_all};
     use std::path::PathBuf;
+    use std::str::FromStr;
     use std::{thread, time};
 
     use chrono::Duration;
@@ -1206,7 +1207,7 @@ mod test {
     #[test]
     fn test_partition_selects_files_for_removal() {
         with_dir("dedupe/partition/basic", |root| {
-            let group = make_group(root, FileHash(0));
+            let group = make_group(root, FileHash::from_str("00").unwrap());
             let config = DedupeConfig::default();
             let partitioned = partition(group, &config, &Log::new()).unwrap();
             assert_eq!(partitioned.to_keep.len(), 1);
@@ -1217,7 +1218,7 @@ mod test {
     #[test]
     fn test_partition_bails_out_if_file_modified_too_late() {
         with_dir("dedupe/partition/modification", |root| {
-            let group = make_group(root, FileHash(0));
+            let group = make_group(root, FileHash::from_str("00").unwrap());
             let mut config = DedupeConfig::default();
             config.modified_before = Some(DateTime::from(Local::now() - Duration::days(1)));
             let partitioned = partition(group, &config, &Log::new());
@@ -1228,7 +1229,7 @@ mod test {
     #[test]
     fn test_partition_skips_file_with_different_len() {
         with_dir("dedupe/partition/file_len", |root| {
-            let group = make_group(root, FileHash(0));
+            let group = make_group(root, FileHash::from_str("00").unwrap());
             let path = group.files[0].clone();
             write_file(&path.to_path_buf(), "foo");
 
@@ -1260,7 +1261,7 @@ mod test {
                 // file creation time
                 return;
             }
-            let group = make_group(root, FileHash(0));
+            let group = make_group(root, FileHash::from_str("00").unwrap());
             let mut config = DedupeConfig::default();
             config.priority = vec![Priority::Newest];
             let partitioned_1 = partition(group.clone(), &config, &Log::new()).unwrap();
@@ -1281,7 +1282,7 @@ mod test {
     #[test]
     fn test_partition_respects_modification_time_priority() {
         with_dir("dedupe/partition/mtime_priority", |root| {
-            let group = make_group(root, FileHash(0));
+            let group = make_group(root, FileHash::from_str("00").unwrap());
 
             thread::sleep(time::Duration::from_millis(10));
             let path = group.files[0].clone();
@@ -1307,7 +1308,7 @@ mod test {
     #[test]
     fn test_partition_respects_keep_patterns() {
         with_dir("dedupe/partition/keep", |root| {
-            let group = make_group(root, FileHash(0));
+            let group = make_group(root, FileHash::from_str("00").unwrap());
             let mut config = DedupeConfig::default();
             config.priority = vec![Priority::LeastRecentlyModified];
             config.keep_name_patterns = vec![Pattern::glob("*_1").unwrap()];
@@ -1326,7 +1327,7 @@ mod test {
     #[test]
     fn test_partition_respects_drop_patterns() {
         with_dir("dedupe/partition/drop", |root| {
-            let group = make_group(root, FileHash(0));
+            let group = make_group(root, FileHash::from_str("00").unwrap());
             let mut config = DedupeConfig::default();
             config.priority = vec![Priority::LeastRecentlyModified];
             config.name_patterns = vec![Pattern::glob("*_3").unwrap()];
@@ -1350,8 +1351,8 @@ mod test {
             create_dir(&root1).unwrap();
             create_dir(&root2).unwrap();
 
-            let group1 = make_group(&root1, FileHash(0));
-            let group2 = make_group(&root2, FileHash(0));
+            let group1 = make_group(&root1, FileHash::from_str("00").unwrap());
+            let group2 = make_group(&root2, FileHash::from_str("00").unwrap());
             let group = FileGroup {
                 file_len: group1.file_len,
                 file_hash: group1.file_hash,
@@ -1395,7 +1396,7 @@ mod test {
 
             let group = FileGroup {
                 file_len: FileLen(3),
-                file_hash: FileHash(0),
+                file_hash: FileHash::from_str("00").unwrap(),
                 files: vec![
                     Path::from(&file_b1),
                     Path::from(&file_a2),
@@ -1428,7 +1429,7 @@ mod test {
             log.no_progress = true;
             log.log_stderr_to_stdout = true;
 
-            let group = make_group(root, FileHash(0));
+            let group = make_group(root, FileHash::from_str("00").unwrap());
             let mut config = DedupeConfig::default();
             config.priority = vec![Priority::LeastRecentlyModified];
             let script = dedupe(vec![group], DedupeOp::Remove, &config, &log);
@@ -1447,9 +1448,9 @@ mod test {
             log.no_progress = true;
             log.log_stderr_to_stdout = true;
 
-            let group_1 = make_group(&root.join("group_1"), FileHash(0));
-            let group_2 = make_group(&root.join("group_2"), FileHash(1));
-            let group_3 = make_group(&root.join("group_3"), FileHash(2));
+            let group_1 = make_group(&root.join("group_1"), FileHash::from_str("00").unwrap());
+            let group_2 = make_group(&root.join("group_2"), FileHash::from_str("01").unwrap());
+            let group_3 = make_group(&root.join("group_3"), FileHash::from_str("02").unwrap());
             let groups = vec![group_1, group_2, group_3];
 
             let mut config = DedupeConfig::default();
diff --git a/src/file.rs b/src/file.rs
index c4e037d..b3cf3e8 100644
--- a/src/file.rs
+++ b/src/file.rs
@@ -10,7 +10,10 @@ use std::ops::{Add, AddAssign, BitXor, Deref, Mul, Sub};
 use std::{fs, io};
 
 use byte_unit::Byte;
+use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
 use bytesize::ByteSize;
+use hex::FromHexError;
+use itertools::{EitherOrBoth, Itertools};
 use rayon::iter::{IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator};
 use serde::*;
 use smallvec::alloc::fmt::Formatter;
@@ -424,8 +427,17 @@ pub(crate) fn get_physical_file_location(path: &Path) -> io::Result<Option<u64>>
     }
 }
 
-#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
-pub struct FileHash(pub u128);
+#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
+pub struct FileHash(Box<[u8]>);
+
+impl FileHash {
+    pub fn u128_prefix(&self) -> u128 {
+        self.0
+            .as_ref()
+            .read_u128::<LittleEndian>()
+            .expect("Hash must be at least 128-bit long")
+    }
+}
 
 pub trait AsFileHash {
     fn as_file_hash(&self) -> &FileHash;
@@ -445,7 +457,29 @@ impl<T> AsFileHash for (T, FileHash) {
 
 impl Display for FileHash {
     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
-        f.pad(format!("{:032x}", self.0).as_str())
+        f.pad(hex::encode(&self.0).as_str())
+    }
+}
+
+impl From<&[u8]> for FileHash {
+    fn from(bytes: &[u8]) -> Self {
+        FileHash(bytes.into())
+    }
+}
+
+impl From<u128> for FileHash {
+    fn from(hash: u128) -> Self {
+        let mut bytes: Vec<u8> = vec![];
+        bytes.write_u128::<LittleEndian>(hash).unwrap();
+        FileHash(bytes.into_boxed_slice())
+    }
+}
+
+impl FromStr for FileHash {
+    type Err = FromHexError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(FileHash(hex::decode(s)?.into_boxed_slice()))
     }
 }
 
@@ -453,7 +487,17 @@ impl BitXor for FileHash {
     type Output = Self;
 
     fn bitxor(self, rhs: Self) -> Self::Output {
-        FileHash(rhs.0 ^ self.0)
+        FileHash(
+            self.0
+                .iter()
+                .zip_longest(rhs.0.as_ref())
+                .map(|r| match r {
+                    EitherOrBoth::Both(a, b) => a ^ b,
+                    _ => 0,
+                })
+                .collect_vec()
+                .into_boxed_slice(),
+        )
     }
 }
 
@@ -462,7 +506,7 @@ impl Serialize for FileHash {
     where
         S: Serializer,
     {
-        serializer.collect_str(self)
+        serializer.collect_str(self.to_string().as_str())
     }
 }
 
@@ -472,8 +516,8 @@ impl<'de> Deserialize<'de> for FileHash {
         D: Deserializer<'de>,
     {
         let s = String::deserialize(deserializer)?;
-        let hash_value = u128::from_str_radix(s.as_str(), 16).map_err(serde::de::Error::custom)?;
-        Ok(FileHash(hash_value))
+        let h = FileHash::from_str(&s).map_err(serde::de::Error::custom)?;
+        Ok(h)
     }
 }
 
diff --git a/src/group.rs b/src/group.rs
index 8480b77..87875c7 100644
--- a/src/group.rs
+++ b/src/group.rs
@@ -140,9 +140,9 @@ impl<'a> GroupCtx<'a> {
             .path_selector(&base_dir)
             .map_err(|e| format!("Invalid pattern: {}", e))?;
         let hasher = if config.cache {
-            FileHasher::new_cached(transform, log)?
+            FileHasher::new_cached(config.hash_fn, transform, log)?
         } else {
-            FileHasher::new(transform, log)
+            FileHasher::new(config.hash_fn, transform, log)
         };
 
         Self::check_pool_config(thread_pool_sizes, &devices)?;
@@ -464,7 +464,7 @@ fn partition_by_devices(
         for f in g.files {
             let device = &devices[f.get_device_index()];
             result[device.index].push(HashedFileInfo {
-                file_hash: g.file_hash,
+                file_hash: g.file_hash.clone(),
                 file_info: f,
             });
         }
@@ -572,10 +572,10 @@ where
                     // when the pool has only one thread.
                     let hash_fn: &HashFn<'static> = unsafe { std::mem::transmute(hash_fn) };
                     thread_pool.spawn_fifo(move || {
-                        let old_hash = fg[0].file_hash;
+                        let old_hash = fg[0].file_hash.clone();
                         if let Some(hash) = hash_fn((&mut fg[0].file_info, old_hash)) {
                             for mut f in fg {
-                                f.file_hash = hash;
+                                f.file_hash = hash.clone();
                                 tx.send(f).unwrap();
                             }
                         }
@@ -725,7 +725,7 @@ fn group_by_size(ctx: &GroupCtx<'_>, files: Vec<Vec<FileInfo>>) -> Vec<FileGroup
         .into_iter()
         .map(|(l, files)| FileGroup {
             file_len: l,
-            file_hash: FileHash(0),
+            file_hash: FileHash::from(0),
             files: files.into_vec(),
         })
         .filter(|g| g.matches(&ctx.group_filter))
@@ -862,8 +862,8 @@ fn group_transformed(ctx: &GroupCtx<'_>, files: Vec<FileInfo>) -> Vec<FileGroup<
     files.par_sort_unstable_by_key(|f| FileId::of(f)); // need to sort so we know unique_file_count
 
     let groups = vec![FileGroup {
-        file_len: FileLen(0),   // doesn't matter, will be computed
-        file_hash: FileHash(0), // doesn't matter, will be computed
+        file_len: FileLen(0),         // doesn't matter, will be computed
+        file_hash: FileHash::from(0), // doesn't matter, will be computed
         files,
     }];
     let progress = ctx.log.progress_bar(
@@ -1147,7 +1147,7 @@ pub fn group_files(config: &GroupConfig, log: &Log) -> Result<Vec<FileGroup<File
             group_by_contents(&ctx, prefix_len, suffix_groups)
         }
     };
-    groups.par_sort_by_key(|g| Reverse((g.file_len, g.file_hash)));
+    groups.par_sort_by_key(|g| Reverse((g.file_len, g.file_hash.u128_prefix())));
     groups
         .par_iter_mut()
         .for_each(|g| g.sort_by_path(&ctx.group_filter.root_paths));
@@ -1260,7 +1260,7 @@ mod test {
         let devices = DiskDevices::default();
         let input = vec![FileGroup {
             file_len: FileLen(200),
-            file_hash: FileHash(0),
+            file_hash: FileHash::from(0),
             files: vec![
                 FileInfo {
                     id: FileId {
@@ -1289,7 +1289,7 @@ mod test {
             |_| true,
             &devices,
             FileAccess::Random,
-            |(fi, _)| Some(FileHash(fi.location as u128)),
+            |(fi, _)| Some(FileHash::from(fi.location as u128)),
         );
 
         assert_eq!(result.len(), 2);
@@ -1303,7 +1303,7 @@ mod test {
         let devices = DiskDevices::default();
         let input = vec![FileGroup {
             file_len: FileLen(200),
-            file_hash: FileHash(0),
+            file_hash: FileHash::from(0),
             files: vec![
                 FileInfo {
                     id: FileId {
@@ -1335,7 +1335,7 @@ mod test {
             FileAccess::Random,
             |(fi, _)| {
                 hash_call_count.fetch_add(1, Ordering::Relaxed);
-                Some(FileHash(fi.location as u128))
+                Some(FileHash::from(fi.location as u128))
             },
         );
 
@@ -1352,7 +1352,7 @@ mod test {
         let input = vec![
             FileGroup {
                 file_len: FileLen(200),
-                file_hash: FileHash(0),
+                file_hash: FileHash::from(0),
                 files: vec![FileInfo {
                     id: FileId {
                         device: 1,
@@ -1365,7 +1365,7 @@ mod test {
             },
             FileGroup {
                 file_len: FileLen(500),
-                file_hash: FileHash(0),
+                file_hash: FileHash::from(0),
                 files: vec![FileInfo {
                     id: FileId {
                         device: 1,
@@ -1384,7 +1384,7 @@ mod test {
             |_| true,
             &devices,
             FileAccess::Random,
-            |(_, _)| Some(FileHash(123456)),
+            |(_, _)| Some(FileHash::from(123456)),
         );
 
         assert_eq!(result.len(), 1);
@@ -1396,7 +1396,7 @@ mod test {
         let devices = DiskDevices::default();
         let input = vec![FileGroup {
             file_len: FileLen(200),
-            file_hash: FileHash(0),
+            file_hash: FileHash::from(0),
             files: vec![FileInfo {
                 id: FileId {
                     device: 1,
@@ -1417,7 +1417,7 @@ mod test {
             FileAccess::Random,
             |(fi, _)| {
                 called.store(true, Ordering::Release);
-                Some(FileHash(fi.location as u128))
+                Some(FileHash::from(fi.location as u128))
             },
         );
 
@@ -1430,7 +1430,7 @@ mod test {
         let devices = DiskDevices::default();
         let input = vec![FileGroup {
             file_len: FileLen(200),
-            file_hash: FileHash(0),
+            file_hash: FileHash::from(0),
             files: vec![
                 FileInfo {
                     id: FileId {
@@ -1459,7 +1459,7 @@ mod test {
             |g| g.files.len() >= 2,
             &devices,
             FileAccess::Random,
-            |(fi, _)| Some(FileHash(fi.location as u128)),
+            |(fi, _)| Some(FileHash::from(fi.location as u128)),
         );
 
         assert!(result.is_empty())
@@ -1474,7 +1474,7 @@ mod test {
         for i in 0..count {
             input.push(FileGroup {
                 file_len: FileLen(0),
-                file_hash: FileHash(0),
+                file_hash: FileHash::from(0),
                 files: vec![FileInfo {
                     id: FileId {
                         device: 1,
@@ -1497,7 +1497,7 @@ mod test {
             FileAccess::Random,
             |(fi, _)| {
                 processing_order.lock().unwrap().push(fi.location as i32);
-                Some(FileHash(fi.location as u128))
+                Some(FileHash::from(fi.location as u128))
             },
         );
         let processing_order = processing_order.into_inner().unwrap();
diff --git a/src/hasher.rs b/src/hasher.rs
index f03edf3..2c6126c 100644
--- a/src/hasher.rs
+++ b/src/hasher.rs
@@ -4,9 +4,16 @@ use std::fs::{File, OpenOptions};
 use std::hash::Hasher;
 use std::io;
 use std::io::{ErrorKind, Read, Seek};
+use std::str::FromStr;
 
 use metrohash::MetroHash128;
 use serde::{Deserialize, Serialize};
+#[cfg(feature = "sha2")]
+use sha2::{Sha256, Sha512};
+#[cfg(feature = "sha3")]
+use sha3::{Sha3_256, Sha3_512};
+#[cfg(feature = "xxhash")]
+use xxhash_rust::xxh3::Xxh3;
 
 use crate::cache::{HashCache, Key};
 use crate::file::{FileAccess, FileChunk, FileHash, FileLen, FileMetadata, FilePos};
@@ -15,14 +22,190 @@ use crate::path::Path;
 use crate::transform::Transform;
 use crate::Error;
 
-#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
-pub enum HashAlgorithm {
-    MetroHash128,
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
+pub enum HashFn {
+    #[default]
+    Metro,
+    #[cfg(feature = "xxhash")]
+    Xxh3,
+    #[cfg(feature = "blake3")]
+    Blake3,
+    #[cfg(feature = "sha2")]
+    Sha256,
+    #[cfg(feature = "sha2")]
+    Sha512,
+    #[cfg(feature = "sha3")]
+    Sha3_256,
+    #[cfg(feature = "sha3")]
+    Sha3_512,
+}
+
+impl HashFn {
+    pub fn variants() -> Vec<&'static str> {
+        vec![
+            "metro",
+            #[cfg(feature = "xxhash")]
+            "xxhash3",
+            #[cfg(feature = "blake3")]
+            "blake3",
+            #[cfg(feature = "sha2")]
+            "sha256",
+            #[cfg(feature = "sha2")]
+            "sha512",
+            #[cfg(feature = "sha3")]
+            "sha3-256",
+            #[cfg(feature = "sha3")]
+            "sha3-512",
+        ]
+    }
+}
+
+impl FromStr for HashFn {
+    type Err = String;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s.to_lowercase().as_str() {
+            "metro" => Ok(Self::Metro),
+            #[cfg(feature = "xxhash")]
+            "xxhash3" => Ok(Self::Xxh3),
+            #[cfg(feature = "blake3")]
+            "blake3" => Ok(Self::Blake3),
+            #[cfg(feature = "sha2")]
+            "sha256" => Ok(Self::Sha256),
+            #[cfg(feature = "sha2")]
+            "sha512" => Ok(Self::Sha512),
+            #[cfg(feature = "sha3")]
+            "sha3-256" => Ok(Self::Sha3_256),
+            #[cfg(feature = "sha3")]
+            "sha3-512" => Ok(Self::Sha3_512),
+            _ => Err(format!("Unknown hash algorithm: {}", s)),
+        }
+    }
+}
+
+/// Computes the hash of a data stream
+trait StreamHasher {
+    fn new() -> Self;
+    fn update(&mut self, bytes: &[u8]);
+    fn finish(self) -> FileHash;
+}
+
+impl StreamHasher for MetroHash128 {
+    fn new() -> Self {
+        MetroHash128::new()
+    }
+
+    fn update(&mut self, bytes: &[u8]) {
+        self.write(bytes)
+    }
+
+    fn finish(self) -> FileHash {
+        let (a, b) = self.finish128();
+        FileHash::from(((a as u128) << 64) | b as u128)
+    }
+}
+
+#[cfg(feature = "xxhash")]
+impl StreamHasher for Xxh3 {
+    fn new() -> Self {
+        Xxh3::new()
+    }
+
+    fn update(&mut self, bytes: &[u8]) {
+        self.update(bytes)
+    }
+
+    fn finish(self) -> FileHash {
+        FileHash::from(self.digest128())
+    }
+}
+
+#[cfg(feature = "blake3")]
+impl StreamHasher for blake3::Hasher {
+    fn new() -> Self {
+        blake3::Hasher::new()
+    }
+
+    fn update(&mut self, bytes: &[u8]) {
+        self.update(bytes);
+    }
+
+    fn finish(self) -> FileHash {
+        FileHash::from(self.finalize().as_bytes().as_slice())
+    }
+}
+
+#[cfg(feature = "sha2")]
+impl StreamHasher for Sha256 {
+    fn new() -> Self {
+        <Sha256 as sha2::Digest>::new()
+    }
+
+    fn update(&mut self, bytes: &[u8]) {
+        sha2::Digest::update(self, bytes);
+    }
+
+    fn finish(self) -> FileHash {
+        use sha2::Digest;
+        let result = self.finalize();
+        FileHash::from(result.as_slice())
+    }
+}
+
+#[cfg(feature = "sha2")]
+impl StreamHasher for Sha512 {
+    fn new() -> Self {
+        <Sha512 as sha2::Digest>::new()
+    }
+
+    fn update(&mut self, bytes: &[u8]) {
+        sha2::Digest::update(self, bytes);
+    }
+
+    fn finish(self) -> FileHash {
+        use sha2::Digest;
+        let result = self.finalize();
+        FileHash::from(result.as_slice())
+    }
+}
+
+#[cfg(feature = "sha3")]
+impl StreamHasher for Sha3_256 {
+    fn new() -> Self {
+        <Sha3_256 as sha3::Digest>::new()
+    }
+
+    fn update(&mut self, bytes: &[u8]) {
+        sha3::Digest::update(self, bytes);
+    }
+
+    fn finish(self) -> FileHash {
+        use sha3::Digest;
+        let result = self.finalize();
+        FileHash::from(result.as_slice())
+    }
+}
+
+#[cfg(feature = "sha3")]
+impl StreamHasher for Sha3_512 {
+    fn new() -> Self {
+        <Sha3_512 as sha3::Digest>::new()
+    }
+
+    fn update(&mut self, bytes: &[u8]) {
+        sha3::Digest::update(self, bytes);
+    }
+
+    fn finish(self) -> FileHash {
+        use sha3::Digest;
+        let result = self.finalize();
+        FileHash::from(result.as_slice())
+    }
 }
 
 /// Hashes file contents
 pub struct FileHasher<'a> {
-    pub(crate) _algorithm: HashAlgorithm, // to be used later
+    pub(crate) algorithm: HashFn,
     pub(crate) buf_len: usize,
     pub(crate) cache: Option<HashCache>,
     pub(crate) transform: Option<Transform>,
@@ -31,9 +214,9 @@ pub struct FileHasher<'a> {
 
 impl FileHasher<'_> {
     /// Creates a hasher with no caching
-    pub fn new(transform: Option<Transform>, log: &Log) -> FileHasher<'_> {
+    pub fn new(algorithm: HashFn, transform: Option<Transform>, log: &Log) -> FileHasher<'_> {
         FileHasher {
-            _algorithm: HashAlgorithm::MetroHash128,
+            algorithm,
             buf_len: 65536,
             cache: None,
             transform,
@@ -42,12 +225,15 @@ impl FileHasher<'_> {
     }
 
     /// Creates a default hasher with caching enabled
-    pub fn new_cached(transform: Option<Transform>, log: &Log) -> Result<FileHasher<'_>, Error> {
-        let algorithm = HashAlgorithm::MetroHash128;
+    pub fn new_cached(
+        algorithm: HashFn,
+        transform: Option<Transform>,
+        log: &Log,
+    ) -> Result<FileHasher<'_>, Error> {
         let transform_command_str = transform.as_ref().map(|t| t.command_str.as_str());
         let cache = HashCache::open_default(transform_command_str, algorithm)?;
         Ok(FileHasher {
-            _algorithm: algorithm,
+            algorithm,
             buf_len: 65536,
             cache: Some(cache),
             transform,
@@ -74,9 +260,22 @@ impl FileHasher<'_> {
             progress(chunk.len.0 as usize);
             return Ok(hash);
         }
-
-        let hash = file_hash(chunk, self.buf_len, progress)?;
-        self.store_hash(key, metadata, chunk.len, hash);
+        let hash = match self.algorithm {
+            HashFn::Metro => file_hash::<MetroHash128>(chunk, self.buf_len, progress),
+            #[cfg(feature = "xxhash")]
+            HashFn::Xxh3 => file_hash::<Xxh3>(chunk, self.buf_len, progress),
+            #[cfg(feature = "blake3")]
+            HashFn::Blake3 => file_hash::<blake3::Hasher>(chunk, self.buf_len, progress),
+            #[cfg(feature = "sha2")]
+            HashFn::Sha256 => file_hash::<Sha256>(chunk, self.buf_len, progress),
+            #[cfg(feature = "sha2")]
+            HashFn::Sha512 => file_hash::<Sha512>(chunk, self.buf_len, progress),
+            #[cfg(feature = "sha3")]
+            HashFn::Sha3_256 => file_hash::<Sha3_256>(chunk, self.buf_len, progress),
+            #[cfg(feature = "sha3")]
+            HashFn::Sha3_512 => file_hash::<Sha3_512>(chunk, self.buf_len, progress),
+        }?;
+        self.store_hash(key, metadata, chunk.len, hash.clone());
         Ok(hash)
     }
 
@@ -123,12 +322,27 @@ impl FileHasher<'_> {
         }
 
         let mut transform_output = transform.run(chunk.path)?;
-        let hash_input = &mut transform_output.out_stream;
+        let stream = &mut transform_output.out_stream;
+        let buf_len = self.buf_len;
 
         // Transformed file may have a different length, so we cannot use stream_hash progress
         // reporting, as it would report progress of the transformed stream. Instead we advance
         // progress after doing the full file.
-        let hash = stream_hash(hash_input, chunk.len, self.buf_len, |_| {});
+        let hash = match self.algorithm {
+            HashFn::Metro => stream_hash::<MetroHash128>(stream, chunk.len, buf_len, |_| {}),
+            #[cfg(feature = "xxhash")]
+            HashFn::Xxh3 => stream_hash::<Xxh3>(stream, chunk.len, buf_len, |_| {}),
+            #[cfg(feature = "blake3")]
+            HashFn::Blake3 => stream_hash::<blake3::Hasher>(stream, chunk.len, buf_len, |_| {}),
+            #[cfg(feature = "sha2")]
+            HashFn::Sha256 => stream_hash::<Sha256>(stream, chunk.len, buf_len, |_| {}),
+            #[cfg(feature = "sha2")]
+            HashFn::Sha512 => stream_hash::<Sha512>(stream, chunk.len, buf_len, |_| {}),
+            #[cfg(feature = "sha3")]
+            HashFn::Sha3_256 => stream_hash::<Sha3_256>(stream, chunk.len, buf_len, |_| {}),
+            #[cfg(feature = "sha3")]
+            HashFn::Sha3_512 => stream_hash::<Sha3_512>(stream, chunk.len, buf_len, |_| {}),
+        };
         progress(chunk.len.0 as usize);
 
         let hash = hash?;
@@ -156,7 +370,7 @@ impl FileHasher<'_> {
             };
         }
 
-        self.store_hash(key, metadata, hash.0, hash.1);
+        self.store_hash(key, metadata, hash.0, hash.1.clone());
         Ok(hash)
     }
 
@@ -381,27 +595,26 @@ fn scan<F: FnMut(&[u8])>(
 
 /// Computes the hash value over at most `len` bytes of the stream.
 /// Returns the number of the bytes read and a 128-bit hash value.
-fn stream_hash(
+fn stream_hash<H: StreamHasher>(
     stream: &mut impl Read,
     len: FileLen,
     buf_len: usize,
     progress: impl Fn(usize),
 ) -> io::Result<(FileLen, FileHash)> {
-    let mut hasher = MetroHash128::new();
+    let mut hasher = H::new();
     let mut read_len: FileLen = FileLen(0);
     scan(stream, len, buf_len, |buf| {
-        hasher.write(buf);
+        hasher.update(buf);
         read_len += FileLen(buf.len() as u64);
         (progress)(buf.len());
     })?;
-    let (a, b) = hasher.finish128();
-    Ok((read_len, FileHash(((a as u128) << 64) | b as u128)))
+    Ok((read_len, hasher.finish()))
 }
 
 /// Computes hash of initial `len` bytes of a file.
 /// If the file does not exist or is not readable, print the error to stderr and return `None`.
 /// The returned hash is not cryptograhically secure.
-fn file_hash(
+fn file_hash<H: StreamHasher>(
     chunk: &FileChunk<'_>,
     buf_len: usize,
     progress: impl Fn(usize),
@@ -412,23 +625,23 @@ fn file_hash(
         FileAccess::Sequential
     };
     let mut file = open(chunk.path, chunk.pos, chunk.len, access)?;
-    let hash = stream_hash(&mut file, chunk.len, buf_len, progress)?.1;
+    let hash = stream_hash::<H>(&mut file, chunk.len, buf_len, progress)?.1;
     evict_page_cache_if_low_mem(&mut file, chunk.len);
     Ok(hash)
 }
 
 #[cfg(test)]
 mod test {
+    use metrohash::MetroHash128;
     use std::fs::{create_dir_all, File};
     use std::io::Write;
     use std::path::PathBuf;
 
     use crate::file::{FileChunk, FileLen, FilePos};
-    use crate::hasher::file_hash;
+    use crate::hasher::{file_hash, StreamHasher};
     use crate::path::Path;
 
-    #[test]
-    fn test_file_hash() {
+    fn test_file_hash<H: StreamHasher>() {
         let test_root = PathBuf::from("target/test/file_hash/");
         create_dir_all(&test_root).unwrap();
 
@@ -450,11 +663,52 @@ mod test {
         let chunk2 = FileChunk::new(&file2, FilePos(0), FileLen::MAX);
         let chunk3 = FileChunk::new(&file2, FilePos(0), FileLen(8));
 
-        let hash1 = file_hash(&chunk1, 4096, |_| {}).unwrap();
-        let hash2 = file_hash(&chunk2, 4096, |_| {}).unwrap();
-        let hash3 = file_hash(&chunk3, 4096, |_| {}).unwrap();
+        let hash1 = file_hash::<H>(&chunk1, 4096, |_| {}).unwrap();
+        let hash2 = file_hash::<H>(&chunk2, 4096, |_| {}).unwrap();
+        let hash3 = file_hash::<H>(&chunk3, 4096, |_| {}).unwrap();
 
         assert_ne!(hash1, hash2);
         assert_ne!(hash2, hash3);
     }
+
+    #[test]
+    fn test_file_hash_metro_128() {
+        test_file_hash::<MetroHash128>()
+    }
+
+    #[test]
+    #[cfg(feature = "xxhash")]
+    fn test_file_hash_xxh3() {
+        test_file_hash::<xxhash_rust::xxh3::Xxh3>()
+    }
+
+    #[test]
+    #[cfg(feature = "blake3")]
+    fn test_file_hash_blake3() {
+        test_file_hash::<blake3::Hasher>()
+    }
+
+    #[test]
+    #[cfg(feature = "sha2")]
+    fn test_file_hash_sha256() {
+        test_file_hash::<sha2::Sha256>()
+    }
+
+    #[test]
+    #[cfg(feature = "sha2")]
+    fn test_file_hash_sha512() {
+        test_file_hash::<sha2::Sha512>()
+    }
+
+    #[test]
+    #[cfg(feature = "sha3")]
+    fn test_file_hash_sha3_256() {
+        test_file_hash::<sha3::Sha3_256>()
+    }
+
+    #[test]
+    #[cfg(feature = "sha3")]
+    fn test_file_hash_sha3_512() {
+        test_file_hash::<sha3::Sha3_512>()
+    }
 }
diff --git a/src/report.rs b/src/report.rs
index ed7f9d7..bd452c7 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -4,6 +4,7 @@ use std::cell::Cell;
 use std::cmp::min;
 use std::io;
 use std::io::{BufRead, BufReader, Error, ErrorKind, Read, Write};
+use std::str::FromStr;
 
 use chrono::{DateTime, FixedOffset};
 use console::style;
@@ -277,7 +278,7 @@ impl<W: Write> ReportWriter<W> {
     {
         let groups = groups.into_iter().map(|g| FileGroup {
             file_len: g.as_ref().file_len,
-            file_hash: g.as_ref().file_hash,
+            file_hash: g.as_ref().file_hash.clone(),
             files: g
                 .as_ref()
                 .files
@@ -380,7 +381,7 @@ where
 
         lazy_static! {
             static ref GROUP_HEADER_RE: Regex =
-                Regex::new(r"^([a-f0-9]{32}), ([0-9]+) B [^*]* \* ([0-9]+):").unwrap();
+                Regex::new(r"^([a-f0-9]+), ([0-9]+) B [^*]* \* ([0-9]+):").unwrap();
         }
 
         let captures = GROUP_HEADER_RE.captures(header_str).ok_or_else(|| {
@@ -391,9 +392,7 @@ where
         })?;
 
         Ok(Some(GroupHeader {
-            file_hash: FileHash(
-                u128::from_str_radix(captures.get(1).unwrap().as_str(), 16).unwrap(),
-            ),
+            file_hash: FileHash::from_str(captures.get(1).unwrap().as_str()).unwrap(),
             file_len: FileLen(captures.get(2).unwrap().as_str().parse::<u64>().unwrap()),
             count: captures.get(3).unwrap().as_str().parse::<usize>().unwrap(),
         }))
@@ -742,12 +741,12 @@ mod test {
         let groups = vec![
             FileGroup {
                 file_len: FileLen(100),
-                file_hash: FileHash(0x00112233445566778899aabbccddeeff),
+                file_hash: FileHash::from(0x00112233445566778899aabbccddeeff),
                 files: vec![Path::from("a"), Path::from("b")],
             },
             FileGroup {
                 file_len: FileLen(40),
-                file_hash: FileHash(0x0000000000000555555555ffffffffff),
+                file_hash: FileHash::from(0x0000000000000555555555ffffffffff),
                 files: vec![Path::from("c"), Path::from("d")],
             },
         ];
@@ -761,12 +760,12 @@ mod test {
         let groups = vec![
             FileGroup {
                 file_len: FileLen(100),
-                file_hash: FileHash(0x00112233445566778899aabbccddeeff),
+                file_hash: FileHash::from(0x00112233445566778899aabbccddeeff),
                 files: vec![Path::from("\t\r\n/foo"), Path::from("ąę/ść/żź/óń/")],
             },
             FileGroup {
                 file_len: FileLen(40),
-                file_hash: FileHash(0x0000000000000555555555ffffffffff),
+                file_hash: FileHash::from(0x0000000000000555555555ffffffffff),
                 files: vec![Path::from("c\u{7f}"), Path::from("😀/😋")],
             },
         ];
@@ -781,7 +780,7 @@ mod test {
         let header = dummy_report_header();
         let groups = vec![FileGroup {
             file_len: FileLen(100),
-            file_hash: FileHash(0x00112233445566778899aabbccddeeff),
+            file_hash: FileHash::from(0x00112233445566778899aabbccddeeff),
             files: vec![Path::from(OsString::from_vec(vec![
                 0xED, 0xA0, 0xBD, 0xED, 0xB8, 0x8D,
             ]))],
@@ -875,12 +874,12 @@ mod test {
         let groups = vec![
             FileGroup {
                 file_len: FileLen(100),
-                file_hash: FileHash(0x00112233445566778899aabbccddeeff),
+                file_hash: FileHash::from(0x00112233445566778899aabbccddeeff),
                 files: vec![Path::from("a"), Path::from("b")],
             },
             FileGroup {
                 file_len: FileLen(40),
-                file_hash: FileHash(0x0000000000000555555555ffffffffff),
+                file_hash: FileHash::from(0x0000000000000555555555ffffffffff),
                 files: vec![Path::from("c"), Path::from("d")],
             },
         ];
@@ -894,12 +893,12 @@ mod test {
         let groups = vec![
             FileGroup {
                 file_len: FileLen(100),
-                file_hash: FileHash(0x00112233445566778899aabbccddeeff),
+                file_hash: FileHash::from(0x00112233445566778899aabbccddeeff),
                 files: vec![Path::from("\t\r\n/foo"), Path::from("ąę/ść/żź/óń/")],
             },
             FileGroup {
                 file_len: FileLen(40),
-                file_hash: FileHash(0x0000000000000555555555ffffffffff),
+                file_hash: FileHash::from(0x0000000000000555555555ffffffffff),
                 files: vec![Path::from("c\u{7f}"), Path::from("😀/😋")],
             },
         ];
@@ -914,7 +913,7 @@ mod test {
         let header = dummy_report_header();
         let groups = vec![FileGroup {
             file_len: FileLen(100),
-            file_hash: FileHash(0x00112233445566778899aabbccddeeff),
+            file_hash: FileHash::from(0x00112233445566778899aabbccddeeff),
             files: vec![Path::from(OsString::from_vec(vec![
                 0xED, 0xA0, 0xBD, 0xED, 0xB8, 0x8D,
             ]))],
diff --git a/src/transform.rs b/src/transform.rs
index dfd174b..b7e7a6b 100644
--- a/src/transform.rs
+++ b/src/transform.rs
@@ -444,7 +444,7 @@ mod test {
     use std::io::Write;
 
     use crate::file::{FileChunk, FileLen, FilePos};
-    use crate::hasher::FileHasher;
+    use crate::hasher::{FileHasher, HashFn};
     use crate::log::Log;
     use crate::util::test::with_dir;
 
@@ -467,7 +467,7 @@ mod test {
             drop(input);
 
             let log = Log::default();
-            let hasher = FileHasher::new(Some(transform), &log);
+            let hasher = FileHasher::new(HashFn::default(), Some(transform), &log);
             let input_path = Path::from(input_path);
             let chunk = FileChunk::new(&input_path, FilePos(0), FileLen::MAX);
             let good_file_hash = hasher.hash_file(&chunk, |_| {}).unwrap();
@@ -489,7 +489,7 @@ mod test {
             drop(input);
 
             let log = Log::default();
-            let hasher = FileHasher::new(Some(transform), &log);
+            let hasher = FileHasher::new(HashFn::default(), Some(transform), &log);
             let input_path = Path::from(input_path);
 
             let chunk = FileChunk::new(&input_path, FilePos(0), FileLen::MAX);


Test patch: