Repository: rust-bitcoin/rust-bitcoin
Pull request #3961 (2025-01-26 23:59:28)

Invert dependency between `io` and `hashes`
Currently in order to release `hashes v1.0` we need to 1.0 `io` as well. For multiple reasons, many out of our control, the `io` crate may not stabalise any time soon.

Instead we can invert the dependency between the two crates.

 This is an ingenious idea, props to Kixunil for coming up with it.

Notes

- This work highlights that we cannot call `hash_reader` on a siphash.
- We cannot implement `Hmac::hash_reader` without silently ignoring the key - this would be a footgun
- This work also highlights that our current `hash_reader` code is not well tested.


tcharding: I'm not sure whats up with the semver check, the `io` macro is private? 
apoelstra: In `api/io/all-features.txt` and others you can see `pub macro bitcoin_io::impl_write!`... but I also don't see where this is coming from.
apoelstra: I propose:

* We PR to remove the `Default` impl on `HmacEngine` since this is small and easy
* We PR to fix the `GeneralHash` thing with sha256t which is also small and easy (if you hold your nose)
* We then attempt to rearrange the hash impls so that each one has an "API" .rs file and a "implementation" .rs file which has the hard crypto and nothing else. Our goal should be that the API file for all the hashes should be nearly identical. (In fact I would propose a CI bot which complains if the diff between them every changes, with the idea that most changes to one should apply to all of them.) We then attempt to eliminate macros from each of these. To the extent possible, it's okay to reuse code by use of default methods on traits.
* We then come back to this
tcharding: Aight, I'll hack it up.
Kixunil: I think it'll be more productive to create tagged hash engine newtype. It's something we wanted already anyway and if we want to release hashes 1.0 we'll have to do it anyway so we might as well do it right now and not touch the macros.
apoelstra: Yes, we should do that -- though I think it's totally orthogonal to this stuff.

>and not touch the macros.

Nobody can read the macros and we know that their code is sometimes wrong. How can we avoid touching them before 1.0?
Kixunil: > we know that their code is sometimes wrong

Which *other* code?
tcharding: > I propose:
> 
> * We PR to remove the `Default` impl on `HmacEngine` since this is small and easy
> * We PR to fix the `GeneralHash` thing with sha256t which is also small and easy (if you hold your nose)
> * We then attempt to rearrange the hash impls so that each one has an "API" .rs file and a "implementation" .rs file which has the hard crypto and nothing else. Our goal should be that the API file for all the hashes should be nearly identical. (In fact I would propose a CI bot which complains if the diff between them every changes, with the idea that most changes to one should apply to all of them.) We then attempt to eliminate macros from each of these. To the extent possible, it's okay to reuse code by use of default methods on traits.
> * We then come back to this

The first two bullet points should be covered by #3981, #3988, and #3960

Point 3 was attempted in #3973 which I closed, I might bring it back to life sans the macro inlining.
github-actions[bot]: :rotating_light: API BREAKING CHANGE DETECTED

To see the changes click details on "Check semver breaks / PR Semver - stable toolchain" job then expand "Run semver checker script" and scroll to the end of the section.
github-actions[bot]: :rotating_light: API BREAKING CHANGE DETECTED

To see the changes click details on "Check semver breaks / PR Semver - stable toolchain" job then expand "Run semver checker script" and scroll to the end of the section.
github-actions[bot]: :rotating_light: API BREAKING CHANGE DETECTED

To see the changes click details on "Check semver breaks / PR Semver - stable toolchain" job then expand "Run semver checker script" and scroll to the end of the section.
github-actions[bot]: :rotating_light: API BREAKING CHANGE DETECTED

To see the changes click details on "Check semver breaks / PR Semver - stable toolchain" job then expand "Run semver checker script" and scroll to the end of the section.

Patch:
diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock
index 5e47891e..f90f321d 100644
--- a/Cargo-minimal.lock
+++ b/Cargo-minimal.lock
@@ -98,6 +98,7 @@ name = "bitcoin-io"
 version = "0.2.0"
 dependencies = [
  "bitcoin-internals",
+ "bitcoin_hashes 0.16.0",
 ]
 
 [[package]]
@@ -107,7 +108,6 @@ dependencies = [
  "arbitrary",
  "bincode",
  "bitcoin-internals",
- "bitcoin-io",
  "bitcoin-units",
  "bitcoin_hashes 0.16.0",
  "hex-conservative 0.3.0",
@@ -141,7 +141,6 @@ dependencies = [
 name = "bitcoin_hashes"
 version = "0.16.0"
 dependencies = [
- "bitcoin-io",
  "hex-conservative 0.3.0",
  "serde",
  "serde_json",
diff --git a/Cargo-recent.lock b/Cargo-recent.lock
index 47641c99..8b590d8a 100644
--- a/Cargo-recent.lock
+++ b/Cargo-recent.lock
@@ -97,6 +97,7 @@ name = "bitcoin-io"
 version = "0.2.0"
 dependencies = [
  "bitcoin-internals",
+ "bitcoin_hashes 0.16.0",
 ]
 
 [[package]]
@@ -106,7 +107,6 @@ dependencies = [
  "arbitrary",
  "bincode",
  "bitcoin-internals",
- "bitcoin-io",
  "bitcoin-units",
  "bitcoin_hashes 0.16.0",
  "hex-conservative 0.3.0",
@@ -140,7 +140,6 @@ dependencies = [
 name = "bitcoin_hashes"
 version = "0.16.0"
 dependencies = [
- "bitcoin-io",
  "hex-conservative 0.3.0",
  "serde",
  "serde_json",
diff --git a/bitcoin/Cargo.toml b/bitcoin/Cargo.toml
index cf057502..9713bf20 100644
--- a/bitcoin/Cargo.toml
+++ b/bitcoin/Cargo.toml
@@ -27,10 +27,10 @@ arbitrary = ["dep:arbitrary", "units/arbitrary", "primitives/arbitrary"]
 [dependencies]
 base58 = { package = "base58ck", version = "0.2.0", default-features = false, features = ["alloc"] }
 bech32 = { version = "0.11.0", default-features = false, features = ["alloc"] }
-hashes = { package = "bitcoin_hashes", version = "0.16.0", default-features = false, features = ["alloc", "bitcoin-io", "hex"] }
+hashes = { package = "bitcoin_hashes", version = "0.16.0", default-features = false, features = ["alloc", "hex"] }
 hex = { package = "hex-conservative", version = "0.3.0", default-features = false, features = ["alloc"] }
 internals = { package = "bitcoin-internals", version = "0.4.0", features = ["alloc"] }
-io = { package = "bitcoin-io", version = "0.2.0", default-features = false, features = ["alloc"] }
+io = { package = "bitcoin-io", version = "0.2.0", default-features = false, features = ["alloc", "hashes"] }
 primitives = { package = "bitcoin-primitives", version = "0.101.0", default-features = false, features = ["alloc"] }
 secp256k1 = { version = "0.29.0", default-features = false, features = ["hashes", "alloc"] }
 units = { package = "bitcoin-units", version = "0.2.0", default-features = false, features = ["alloc"] }
diff --git a/hashes/Cargo.toml b/hashes/Cargo.toml
index c34f2f04..35760c87 100644
--- a/hashes/Cargo.toml
+++ b/hashes/Cargo.toml
@@ -15,8 +15,8 @@ exclude = ["tests", "contrib"]
 
 [features]
 default = ["std"]
-std = ["alloc", "bitcoin-io?/std", "hex/std"]
-alloc = ["bitcoin-io?/alloc", "hex/alloc"]
+std = ["alloc", "hex/std"]
+alloc = ["hex/alloc"]
 serde = ["dep:serde", "hex"]
 # Smaller (but slower) implementation of sha256, sha512 and ripemd160
 small-hash = []
@@ -24,7 +24,6 @@ small-hash = []
 [dependencies]
 
 hex = { package = "hex-conservative", version = "0.3.0", default-features = false, optional = true }
-bitcoin-io = { version = "0.2.0", default-features = false, optional = true }
 serde = { version = "1.0", default-features = false, optional = true }
 
 [dev-dependencies]
diff --git a/hashes/contrib/sanitizer.sh b/hashes/contrib/sanitizer.sh
index e3d1e815..495e404c 100755
--- a/hashes/contrib/sanitizer.sh
+++ b/hashes/contrib/sanitizer.sh
@@ -5,7 +5,7 @@
 set -euox pipefail
 
 # Run the sanitizer with these features.
-FEATURES="std bitcoin-io serde"
+FEATURES="std serde"
 
 cargo clean
 CC='clang -fsanitize=address -fno-omit-frame-pointer'                                        \
diff --git a/hashes/embedded/Cargo.toml b/hashes/embedded/Cargo.toml
index be73fe56..dcf5a7ec 100644
--- a/hashes/embedded/Cargo.toml
+++ b/hashes/embedded/Cargo.toml
@@ -19,8 +19,8 @@ cortex-m-rt = "0.6.10"
 cortex-m-semihosting = "0.3.3"
 panic-halt = "0.2.0"
 alloc-cortex-m = { version = "0.4.1", optional = true }
-bitcoin_hashes = { path="../", default-features = false, features = ["bitcoin-io"] }
-bitcoin-io = { path = "../../io", default_features = false }
+bitcoin_hashes = { path="../", default-features = false, features = [] }
+bitcoin-io = { path = "../../io", default_features = false, features = ["hashes"] }
 
 [[bin]]
 name = "embedded"
@@ -32,6 +32,9 @@ codegen-units = 1 # better optimizations
 debug = true # symbols are nice and they don't increase the size on Flash
 lto = true # better optimizations
 
+[patch.crates-io.bitcoin_hashes]
+path = "../../hashes"
+
 [patch.crates-io.bitcoin-internals]
 path = "../../internals"
 
diff --git a/hashes/src/hmac/mod.rs b/hashes/src/hmac/mod.rs
index 5cddfdc5..eace7f95 100644
--- a/hashes/src/hmac/mod.rs
+++ b/hashes/src/hmac/mod.rs
@@ -155,7 +155,8 @@ fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Hmac<T>, D::Error> {
     }
 }
 
-crate::internal_macros::impl_io_write!(
+#[cfg(feature = "std")]
+crate::internal_macros::impl_write!(
     HmacEngine<T>,
     |us: &mut HmacEngine<T>, buf| {
         us.input(buf);
diff --git a/hashes/src/internal_macros.rs b/hashes/src/internal_macros.rs
index 5ae79f11..67b9f617 100644
--- a/hashes/src/internal_macros.rs
+++ b/hashes/src/internal_macros.rs
@@ -91,12 +91,6 @@ pub fn hash_byte_chunks<B, I>(byte_slices: I) -> Self
             {
                 <Self as $crate::GeneralHash>::hash_byte_chunks(byte_slices)
             }
-
-            /// Hashes the entire contents of the `reader`.
-            #[cfg(feature = "bitcoin-io")]
-            pub fn hash_reader<R: io::BufRead>(reader: &mut R) -> Result<Self, io::Error> {
-                <Self as $crate::GeneralHash>::hash_reader(reader)
-            }
         }
     };
 }
@@ -156,7 +150,7 @@ pub fn from_slice(
 
         $crate::internal_macros::hash_trait_impls!($bits, $reverse);
 
-        $crate::internal_macros::impl_io_write!(
+        $crate::internal_macros::impl_write!(
             HashEngine,
             |us: &mut HashEngine, buf| {
                 crate::HashEngine::input(us, buf);
@@ -168,29 +162,16 @@ pub fn from_slice(
 }
 pub(crate) use hash_type_no_default;
 
-// We do not use the `bitcoin_io::impl_write` macro because we don't have an unconditional
-// dependency on `bitcoin-io` and we want to implement `std:io::Write` even when we don't depend on
-// `bitcoin-io`.
-macro_rules! impl_io_write {
+macro_rules! impl_write {
     ($ty: ty, $write_fn: expr, $flush_fn: expr $(, $bounded_ty: ident : $bounds: path),*) => {
-        #[cfg(feature = "bitcoin-io")]
-        impl<$($bounded_ty: $bounds),*> bitcoin_io::Write for $ty {
-            #[inline]
-            fn write(&mut self, buf: &[u8]) -> bitcoin_io::Result<usize> {
-                $write_fn(self, buf)
-            }
-            #[inline]
-            fn flush(&mut self) -> bitcoin_io::Result<()> {
-                $flush_fn(self)
-            }
-        }
-
+        // `bitcoin_io::Write` is implemented in `bitcoin_io`.
         #[cfg(feature = "std")]
         impl<$($bounded_ty: $bounds),*> std::io::Write for $ty {
             #[inline]
             fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
                 $write_fn(self, buf)
             }
+
             #[inline]
             fn flush(&mut self) -> std::io::Result<()> {
                 $flush_fn(self)
@@ -198,7 +179,7 @@ fn flush(&mut self) -> std::io::Result<()> {
         }
     }
 }
-pub(crate) use impl_io_write;
+pub(crate) use impl_write;
 
 macro_rules! engine_input_impl(
     () => (
diff --git a/hashes/src/lib.rs b/hashes/src/lib.rs
index 9f0067aa..0324da01 100644
--- a/hashes/src/lib.rs
+++ b/hashes/src/lib.rs
@@ -75,9 +75,6 @@
 #[cfg(feature = "std")]
 extern crate std;
 
-#[cfg(feature = "bitcoin-io")]
-extern crate bitcoin_io as io;
-
 /// A generic serialization/deserialization framework.
 #[cfg(feature = "serde")]
 pub extern crate serde;
@@ -239,28 +236,6 @@ fn hash_byte_chunks<B, I>(byte_slices: I) -> Self
         }
         Self::from_engine(engine)
     }
-
-    /// Hashes the entire contents of the `reader`.
-    #[cfg(feature = "bitcoin-io")]
-    fn hash_reader<R: io::BufRead>(reader: &mut R) -> Result<Self, io::Error>
-    where
-        Self::Engine: Default,
-    {
-        let mut engine = Self::engine();
-        loop {
-            let bytes = reader.fill_buf()?;
-
-            let read = bytes.len();
-            // Empty slice means EOF.
-            if read == 0 {
-                break;
-            }
-
-            engine.input(bytes);
-            reader.consume(read);
-        }
-        Ok(Self::from_engine(engine))
-    }
 }
 
 /// Trait which applies to hashes of all types.
@@ -367,13 +342,4 @@ fn newtype_fmt_roundtrip() {
         let rinsed = hex.parse::<TestNewtype>().expect("failed to parse hex");
         assert_eq!(rinsed, orig)
     }
-
-    #[test]
-    #[cfg(feature = "bitcoin-io")]
-    fn hash_reader() {
-        use crate::sha256;
-
-        let mut reader: &[u8] = b"hello";
-        assert_eq!(sha256::Hash::hash_reader(&mut reader).unwrap(), sha256::Hash::hash(b"hello"),)
-    }
 }
diff --git a/hashes/src/sha256t/mod.rs b/hashes/src/sha256t/mod.rs
index 5d28b9d9..e2461eeb 100644
--- a/hashes/src/sha256t/mod.rs
+++ b/hashes/src/sha256t/mod.rs
@@ -87,25 +87,6 @@ pub fn hash_byte_chunks<B, I>(byte_slices: I) -> Self
         Self::from_engine(engine)
     }
 
-    /// Hashes the entire contents of the `reader`.
-    #[cfg(feature = "bitcoin-io")]
-    pub fn hash_reader<R: io::BufRead>(reader: &mut R) -> Result<Self, io::Error> {
-        let mut engine = Self::engine();
-        loop {
-            let bytes = reader.fill_buf()?;
-
-            let read = bytes.len();
-            // Empty slice means EOF.
-            if read == 0 {
-                break;
-            }
-
-            engine.input(bytes);
-            reader.consume(read);
-        }
-        Ok(Self::from_engine(engine))
-    }
-
     /// Returns the underlying byte array.
     pub const fn to_byte_array(self) -> [u8; 32] { self.0 }
 
@@ -152,7 +133,7 @@ fn input(&mut self, data: &[u8]) { self.0.input(data) }
     fn n_bytes_hashed(&self) -> u64 { self.0.n_bytes_hashed() }
 }
 
-crate::internal_macros::impl_io_write!(
+crate::internal_macros::impl_write!(
     HashEngine<T>,
     |us: &mut HashEngine<T>, buf| {
         us.input(buf);
diff --git a/io/Cargo.toml b/io/Cargo.toml
index 224d3780..d245121d 100644
--- a/io/Cargo.toml
+++ b/io/Cargo.toml
@@ -15,12 +15,14 @@ exclude = ["tests", "contrib"]
 
 [features]
 default = ["std"]
-std = ["alloc"]
-alloc = []
+std = ["alloc", "hashes?/std"]
+alloc = ["hashes?/alloc"]
 
 [dependencies]
 internals = { package = "bitcoin-internals", version = "0.4.0" }
 
+hashes = { package = "bitcoin_hashes", version = "0.16.0", default-features = false, optional = true }
+
 [package.metadata.docs.rs]
 all-features = true
 rustdoc-args = ["--cfg", "docsrs"]
diff --git a/io/src/hash.rs b/io/src/hash.rs
new file mode 100644
index 00000000..a6a364e9
--- /dev/null
+++ b/io/src/hash.rs
@@ -0,0 +1,378 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! I/O hashing support.
+//!
+//! Support for various hashing related things e.g.
+//!
+//! - Hashing to a writer.
+//! - Implement I/O traits for hash engines.
+
+use hashes::hmac::HmacEngine;
+use hashes::{
+    hash160, ripemd160, sha1, sha256, sha256d, sha256t, sha384, sha512, sha512_256, siphash24,
+    GeneralHash, HashEngine as _,
+};
+
+macro_rules! impl_write {
+    ($ty: ty, $write_fn: expr, $flush_fn: expr $(, $bounded_ty: ident : $bounds: path),*) => {
+        // `std::io::Write` is implemented in `bitcoin_hashes` because of the orphan rule.
+        impl<$($bounded_ty: $bounds),*> crate::Write for $ty {
+            #[inline]
+            fn write(&mut self, buf: &[u8]) -> crate::Result<usize> {
+                $write_fn(self, buf)}
+
+            #[inline]
+            fn flush(&mut self) -> crate::Result<()> {
+                $flush_fn(self)
+            }
+        }
+    }
+}
+pub(crate) use impl_write;
+
+impl_write!(
+    hash160::HashEngine,
+    |us: &mut hash160::HashEngine, buf| {
+        hashes::HashEngine::input(us, buf);
+        Ok(buf.len())
+    },
+    |_us| { Ok(()) }
+);
+
+impl_write!(
+    ripemd160::HashEngine,
+    |us: &mut ripemd160::HashEngine, buf| {
+        hashes::HashEngine::input(us, buf);
+        Ok(buf.len())
+    },
+    |_us| { Ok(()) }
+);
+
+impl_write!(
+    sha1::HashEngine,
+    |us: &mut sha1::HashEngine, buf| {
+        hashes::HashEngine::input(us, buf);
+        Ok(buf.len())
+    },
+    |_us| { Ok(()) }
+);
+
+impl_write!(
+    sha256::HashEngine,
+    |us: &mut sha256::HashEngine, buf| {
+        hashes::HashEngine::input(us, buf);
+        Ok(buf.len())
+    },
+    |_us| { Ok(()) }
+);
+
+impl_write!(
+    sha256d::HashEngine,
+    |us: &mut sha256d::HashEngine, buf| {
+        hashes::HashEngine::input(us, buf);
+        Ok(buf.len())
+    },
+    |_us| { Ok(()) }
+);
+
+impl_write!(
+    sha256t::HashEngine<T>,
+    |us: &mut sha256t::HashEngine<T>, buf| {
+        hashes::HashEngine::input(us, buf);
+        Ok(buf.len())
+    },
+    |_us| { Ok(()) },
+    T: sha256t::Tag
+);
+
+impl_write!(
+    sha384::HashEngine,
+    |us: &mut sha384::HashEngine, buf| {
+        hashes::HashEngine::input(us, buf);
+        Ok(buf.len())
+    },
+    |_us| { Ok(()) }
+);
+
+impl_write!(
+    sha512::HashEngine,
+    |us: &mut sha512::HashEngine, buf| {
+        hashes::HashEngine::input(us, buf);
+        Ok(buf.len())
+    },
+    |_us| { Ok(()) }
+);
+
+impl_write!(
+    sha512_256::HashEngine,
+    |us: &mut sha512_256::HashEngine, buf| {
+        hashes::HashEngine::input(us, buf);
+        Ok(buf.len())
+    },
+    |_us| { Ok(()) }
+);
+
+impl_write!(
+    siphash24::HashEngine,
+    |us: &mut siphash24::HashEngine, buf| {
+        hashes::HashEngine::input(us, buf);
+        Ok(buf.len())
+    },
+    |_us| { Ok(()) }
+);
+
+impl_write!(
+    HmacEngine<T>,
+    |us: &mut HmacEngine<T>, buf| {
+        us.input(buf);
+        Ok(buf.len())
+    },
+    |_us| { Ok(()) },
+    T: hashes::GeneralHash
+);
+
+/// Hashes data from a reader.
+///
+/// Adds functionality to a [`hashes::GeneralHash`] type to support hashing data read from a
+/// buffered reader.
+pub trait GeneralHashExt: GeneralHash + sealed::Sealed {
+    /// Hashes the entire contents of the `reader`.
+    fn hash_reader<R: crate::BufRead>(reader: &mut R) -> Result<Self, crate::Error>
+    where
+        Self::Engine: Default,
+    {
+        let mut engine = Self::engine(); // This calls `Self::Engine::default()`.
+        loop {
+            let bytes = reader.fill_buf()?;
+
+            let read = bytes.len();
+            // Empty slice means EOF.
+            if read == 0 {
+                break;
+            }
+
+            engine.input(bytes);
+            reader.consume(read);
+        }
+        Ok(Self::from_engine(engine))
+    }
+}
+
+impl<T: GeneralHash> GeneralHashExt for T {}
+
+mod sealed {
+    /// Used to seal the `GeneralHashExt` trait.
+    pub trait Sealed {}
+
+    impl<T: hashes::GeneralHash> Sealed for T {}
+}
+
+#[cfg(test)]
+#[cfg(feature = "alloc")]
+mod tests {
+    use alloc::format;
+
+    use hashes::{hmac, Hmac};
+
+    use super::*;
+    use crate::{Cursor, Write as _};
+
+    macro_rules! write_test {
+        ($mod:ident, $exp_empty:expr, $exp_256:expr, $exp_64k:expr,) => {
+            #[test]
+            fn $mod() {
+                let mut engine = $mod::Hash::engine();
+                engine.write_all(&[]).unwrap();
+                assert_eq!(format!("{}", $mod::Hash::from_engine(engine)), $exp_empty);
+
+                let mut engine = $mod::Hash::engine();
+                engine.write_all(&[1; 256]).unwrap();
+                assert_eq!(format!("{}", $mod::Hash::from_engine(engine)), $exp_256);
+
+                let mut engine = $mod::Hash::engine();
+                engine.write_all(&[99; 64000]).unwrap();
+                assert_eq!(format!("{}", $mod::Hash::from_engine(engine)), $exp_64k);
+            }
+        };
+    }
+
+    write_test!(
+        sha1,
+        "da39a3ee5e6b4b0d3255bfef95601890afd80709",
+        "ac458b067c6b021c7e9358229b636e9d1e4cb154",
+        "e4b66838f9f7b6f91e5be32a02ae78094df402e7",
+    );
+
+    write_test!(
+        sha256,
+        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+        "2661920f2409dd6c8adeb0c44972959f232b6429afa913845d0fd95e7e768234",
+        "5c5e904f5d4fd587c7a906bf846e08a927286f388c54c39213a4884695271bbc",
+    );
+
+    write_test!(
+        sha256d,
+        "56944c5d3f98413ef45cf54545538103cc9f298e0575820ad3591376e2e0f65d",
+        "374000d830c75d10d9417e493a7652920f30efbd300e3fb092f24c28c20baf64",
+        "0050d4148ad7a0437ca0643fad5bf4614cd95d9ba21fde52370b37dcc3f03307",
+    );
+
+    write_test!(
+        sha384,
+        "38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b",
+        "82135637ef6d6dd31a20e2bc9998681a3eecaf8f8c76d45e545214de38439d9a533848ec75f53e4b1a8805709c5124d0",
+        "fb7511d9a98c5686f9c2f55e242397815c9229d8759451e1710b8da6861e08d52f0357176f4b74f8cad9e23ab65411c7",
+    );
+
+    write_test!(
+        sha512,
+        "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce\
+         47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e",
+        "57ecf739d3a7ca647639adae80a05f4f361304bfcbfa1ceba93296b096e74287\
+         45fc10c142cecdd3bb587a3dba598c072f6f78b31cc0a06a3da0105ee51f75d6",
+        "dd28f78c53f3bc9bd0c2dca9642a1ad402a70412f985c1f6e54fadb98ce9c458\
+         4761df8d04ed04bb734ba48dd2106bb9ea54524f1394cdd18e6da3166e71c3ee",
+    );
+
+    write_test!(
+        sha512_256,
+        "c672b8d1ef56ed28ab87c3622c5114069bdd3ad7b8f9737498d0c01ecef0967a",
+        "8d4bb96e7956cf5f08bf5c45f7982630c46b0b022f25cbaf722ae97c06a6e7a2",
+        "3367646f3e264653f7dd664ac2cb6d3b96329e86ffb7a29a1082e2a4ddc9ee7a",
+    );
+
+    write_test!(
+        ripemd160,
+        "9c1185a5c5e9fc54612808977ee8f548b2258d31",
+        "e571a1ca5b780aa52bafdb9ec852544ffca418ba",
+        "ddd2ecce739e823629c7d46ab18918e9c4a51c75",
+    );
+
+    write_test!(
+        hash160,
+        "b472a266d0bd89c13706a4132ccfb16f7c3b9fcb",
+        "671356a1a874695ad3bc20cae440f4360835bd5a",
+        "a9608c952c8dbcc20c53803d2ca5ad31d64d9313",
+    );
+
+    #[test]
+    fn hmac() {
+        let mut engine = hmac::HmacEngine::<sha256::Hash>::new(&[0xde, 0xad, 0xbe, 0xef]);
+        engine.write_all(&[]).unwrap();
+        assert_eq!(
+            format!("{}", hmac::Hmac::from_engine(engine)),
+            "bf5515149cf797955c4d3194cca42472883281951697c8375d9d9b107f384225"
+        );
+
+        let mut engine = hmac::HmacEngine::<sha256::Hash>::new(&[0xde, 0xad, 0xbe, 0xef]);
+        engine.write_all(&[1; 256]).unwrap();
+        assert_eq!(
+            format!("{}", hmac::Hmac::from_engine(engine)),
+            "59c9aca10c81c73cb4c196d94db741b6bf2050e0153d5a45f2526bff34675ac5"
+        );
+
+        let mut engine = hmac::HmacEngine::<sha256::Hash>::new(&[0xde, 0xad, 0xbe, 0xef]);
+        engine.write_all(&[99; 64000]).unwrap();
+        assert_eq!(
+            format!("{}", hmac::Hmac::from_engine(engine)),
+            "30df499717415a395379a1eaabe50038036e4abb5afc94aa55c952f4aa57be08"
+        );
+    }
+
+    #[test]
+    fn siphash24() {
+        let mut engine = siphash24::HashEngine::with_keys(0, 0);
+        engine.write_all(&[]).unwrap();
+        assert_eq!(format!("{}", siphash24::Hash::from_engine(engine)), "d70077739d4b921e");
+
+        let mut engine = siphash24::HashEngine::with_keys(0, 0);
+        engine.write_all(&[1; 256]).unwrap();
+        assert_eq!(format!("{}", siphash24::Hash::from_engine(engine)), "3a3ccefde9b5b1e3");
+
+        let mut engine = siphash24::HashEngine::with_keys(0, 0);
+        engine.write_all(&[99; 64000]).unwrap();
+        assert_eq!(format!("{}", siphash24::Hash::from_engine(engine)), "ce456e4e4ecbc5bf");
+    }
+
+    // Data and expected hashes taken from `bitcoin_hashes/tests/regression.rs`.
+    const DATA: &str = "arbitrary data to hash as a regression test";
+    const HMAC_KEY: &[u8] = b"some key";
+
+    macro_rules! impl_hash_reader_test {
+        ($($test_name:ident, $module:ident, $want:literal);* $(;)?) => {
+            $(
+                #[test]
+                fn $test_name() {
+                    let hash = $module::Hash::hash(DATA.as_bytes());
+                    let got = format!("{}", hash);
+                    assert_eq!(got, $want);
+
+                    let mut reader = Cursor::new(DATA);
+                    let hash_from_reader = $module::Hash::hash_reader(&mut reader).unwrap();
+                    assert_eq!(hash_from_reader, hash)
+                }
+            )*
+        }
+    }
+
+    impl_hash_reader_test! {
+        hash_from_reader_hash160, hash160, "a17909f6d5373b0085c4180ba207126e5040f74d";
+        hash_from_reader_ripemd160, ripemd160, "e6801701c77a1cd85662335258c7869631b4a9a8";
+        hash_from_reader_sha1, sha1, "e1e81eeabadafa3d5d41cc3f405385426b0f47fd";
+        hash_from_reader_sha256, sha256, "d291c6c5a07fa1d9315cdae090ebe14169fbe0a219cd55a48d0d2104eab6ec51";
+        hash_from_reader_sha256d, sha256d, "93a743b022290bde3233a619b21aaebe06c5cf5cc959464c41be35711e37731b";
+        hash_from_reader_sha384, sha384, "f545bd83d297978d47a7f26b858a54188499dfb4d7d570a6a2362c765031d57a29d7e002df5e34d184e70b65a4f47153";
+        hash_from_reader_sha512, sha512, "057d0a37e9e0ac9a93acde0752748da059a27bcf946c7af00692ac1a95db8d21f965f40af22efc4710f100f8d3e43f79f77b1f48e1e400a95b7344b7bc0dfd10";
+        hash_from_reader_sha512_256, sha512_256, "e204244c429b5bca037a2a8a6e7ed8a42b808ceaff182560840bb8c5c8e9a2ec";
+    }
+
+    #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Hash)]
+    pub struct RegHashTag; // Name comes from regression tests in `bitcoin_hashes`.
+
+    impl sha256t::Tag for RegHashTag {
+        const MIDSTATE: sha256::Midstate = sha256::Midstate::new([0xab; 32], 64);
+    }
+
+    type RegHash = sha256t::Hash<RegHashTag>;
+
+    #[test]
+    fn regression_sha256t() {
+        let hash = RegHash::hash(DATA.as_bytes());
+        let got = format!("{}", hash);
+        let want = "17db326d7c13867376ccca1f8a211377be3cbeaeb372f167822284866ddf14ca";
+        assert_eq!(got, want);
+    }
+
+    #[test]
+    fn regression_hmac_sha256_with_key() {
+        let mut engine = HmacEngine::<sha256::Hash>::new(HMAC_KEY);
+        engine.input(DATA.as_bytes());
+        let hash = Hmac::from_engine(engine);
+
+        let got = format!("{}", hash);
+        let want = "d159cecaf4adf90b6a641bab767e4817d3a51c414acea3682686c35ec0b37b52";
+        assert_eq!(got, want);
+    }
+
+    #[test]
+    fn regression_hmac_sha512_with_key() {
+        let mut engine = HmacEngine::<sha512::Hash>::new(HMAC_KEY);
+        engine.input(DATA.as_bytes());
+        let hash = Hmac::from_engine(engine);
+
+        let got = format!("{}", hash);
+        let want = "8511773748f89ba22c07fb3a2981a12c1823695119de41f4a62aead6b848bd34939acf16475c35ed7956114fead3e794cc162ecd35e447a4dabc3227d55f757b";
+        assert_eq!(got, want);
+    }
+
+    #[test]
+    fn regression_siphash24_with_key() {
+        let mut engine = siphash24::HashEngine::with_keys(0, 0);
+        engine.input(DATA.as_bytes());
+        let hash = siphash24::Hash::from_engine(engine);
+
+        let got = format!("{}", hash);
+        let want = "e823ed82311d601a";
+        assert_eq!(got, want);
+    }
+}
diff --git a/io/src/lib.rs b/io/src/lib.rs
index 0e1d3312..3c020d0b 100644
--- a/io/src/lib.rs
+++ b/io/src/lib.rs
@@ -25,11 +25,17 @@
 #[cfg(feature = "alloc")]
 extern crate alloc;
 
+#[cfg(feature = "hashes")]
+pub extern crate hashes;
+
 #[cfg(feature = "std")]
 mod bridge;
 mod error;
 mod macros;
 
+#[cfg(feature = "hashes")]
+mod hash;
+
 #[cfg(all(not(feature = "std"), feature = "alloc"))]
 use alloc::vec::Vec;
 use core::cmp;
@@ -39,6 +45,8 @@
 
 #[rustfmt::skip]                // Keep public re-exports separate.
 pub use self::error::{Error, ErrorKind};
+#[cfg(feature = "hashes")]
+pub use self::hash::GeneralHashExt;
 
 /// Result type returned by functions in this crate.
 pub type Result<T> = core::result::Result<T, Error>;
diff --git a/primitives/Cargo.toml b/primitives/Cargo.toml
index a44c42c6..241be623 100644
--- a/primitives/Cargo.toml
+++ b/primitives/Cargo.toml
@@ -16,16 +16,15 @@ exclude = ["tests", "contrib"]
 
 [features]
 default = ["std"]
-std = ["alloc", "hashes/std", "hex/std", "internals/std", "io/std", "units/std"]
-alloc = ["hashes/alloc", "hex/alloc", "internals/alloc", "io/alloc", "units/alloc"]
+std = ["alloc", "hashes/std", "hex/std", "internals/std", "units/std"]
+alloc = ["hashes/alloc", "hex/alloc", "internals/alloc", "units/alloc"]
 serde = ["dep:serde", "hashes/serde", "internals/serde", "units/serde", "alloc"]
 arbitrary = ["dep:arbitrary", "units/arbitrary"]
 
 [dependencies]
-hashes = { package = "bitcoin_hashes", version = "0.16.0", default-features = false, features = ["bitcoin-io", "hex"] }
+hashes = { package = "bitcoin_hashes", version = "0.16.0", default-features = false, features = ["hex"] }
 hex = { package = "hex-conservative", version = "0.3.0", default-features = false }
 internals = { package = "bitcoin-internals", version = "0.4.0" }
-io = { package = "bitcoin-io", version = "0.2.0", default-features = false }
 units = { package = "bitcoin-units", version = "0.2.0", default-features = false }
 
 arbitrary = { version = "1.4", optional = true }


Test patch:
diff --git a/hashes/contrib/test_vars.sh b/hashes/contrib/test_vars.sh
index 35360740..efcd3dcf 100644
--- a/hashes/contrib/test_vars.sh
+++ b/hashes/contrib/test_vars.sh
@@ -5,10 +5,10 @@
 # shellcheck disable=SC2034
 
 # Test all these features with "std" enabled.
-FEATURES_WITH_STD="bitcoin-io serde small-hash"
+FEATURES_WITH_STD="serde small-hash"
 
 # Test all these features without "std" enabled.
-FEATURES_WITHOUT_STD="alloc bitcoin-io serde small-hash"
+FEATURES_WITHOUT_STD="alloc serde small-hash"
 
 # Run these examples.
 EXAMPLES=""
diff --git a/hashes/tests/io.rs b/hashes/tests/io.rs
deleted file mode 100644
index 9151bba3..00000000
--- a/hashes/tests/io.rs
+++ /dev/null
@@ -1,129 +0,0 @@
-// SPDX-License-Identifier: CC0-1.0
-
-//! Test the `bitcoin-io` implementations.
-
-#![cfg(feature = "bitcoin-io")]
-#![cfg(feature = "hex")]
-
-use bitcoin_hashes::{
-    hash160, hmac, ripemd160, sha1, sha256, sha256d, sha384, sha512, sha512_256, siphash24,
-    GeneralHash as _,
-};
-use bitcoin_io::Write;
-
-macro_rules! write_test {
-    ($mod:ident, $exp_empty:expr, $exp_256:expr, $exp_64k:expr,) => {
-        #[test]
-        fn $mod() {
-            let mut engine = $mod::Hash::engine();
-            engine.write_all(&[]).unwrap();
-            assert_eq!(format!("{}", $mod::Hash::from_engine(engine)), $exp_empty);
-
-            let mut engine = $mod::Hash::engine();
-            engine.write_all(&[1; 256]).unwrap();
-            assert_eq!(format!("{}", $mod::Hash::from_engine(engine)), $exp_256);
-
-            let mut engine = $mod::Hash::engine();
-            engine.write_all(&[99; 64000]).unwrap();
-            assert_eq!(format!("{}", $mod::Hash::from_engine(engine)), $exp_64k);
-        }
-    };
-}
-
-write_test!(
-    sha1,
-    "da39a3ee5e6b4b0d3255bfef95601890afd80709",
-    "ac458b067c6b021c7e9358229b636e9d1e4cb154",
-    "e4b66838f9f7b6f91e5be32a02ae78094df402e7",
-);
-
-write_test!(
-    sha256,
-    "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
-    "2661920f2409dd6c8adeb0c44972959f232b6429afa913845d0fd95e7e768234",
-    "5c5e904f5d4fd587c7a906bf846e08a927286f388c54c39213a4884695271bbc",
-);
-
-write_test!(
-    sha256d,
-    "56944c5d3f98413ef45cf54545538103cc9f298e0575820ad3591376e2e0f65d",
-    "374000d830c75d10d9417e493a7652920f30efbd300e3fb092f24c28c20baf64",
-    "0050d4148ad7a0437ca0643fad5bf4614cd95d9ba21fde52370b37dcc3f03307",
-);
-
-write_test!(
-    sha384,
-    "38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b",
-    "82135637ef6d6dd31a20e2bc9998681a3eecaf8f8c76d45e545214de38439d9a533848ec75f53e4b1a8805709c5124d0",
-    "fb7511d9a98c5686f9c2f55e242397815c9229d8759451e1710b8da6861e08d52f0357176f4b74f8cad9e23ab65411c7",
-);
-
-write_test!(
-    sha512,
-    "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce\
-     47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e",
-    "57ecf739d3a7ca647639adae80a05f4f361304bfcbfa1ceba93296b096e74287\
-     45fc10c142cecdd3bb587a3dba598c072f6f78b31cc0a06a3da0105ee51f75d6",
-    "dd28f78c53f3bc9bd0c2dca9642a1ad402a70412f985c1f6e54fadb98ce9c458\
-     4761df8d04ed04bb734ba48dd2106bb9ea54524f1394cdd18e6da3166e71c3ee",
-);
-
-write_test!(
-    sha512_256,
-    "c672b8d1ef56ed28ab87c3622c5114069bdd3ad7b8f9737498d0c01ecef0967a",
-    "8d4bb96e7956cf5f08bf5c45f7982630c46b0b022f25cbaf722ae97c06a6e7a2",
-    "3367646f3e264653f7dd664ac2cb6d3b96329e86ffb7a29a1082e2a4ddc9ee7a",
-);
-
-write_test!(
-    ripemd160,
-    "9c1185a5c5e9fc54612808977ee8f548b2258d31",
-    "e571a1ca5b780aa52bafdb9ec852544ffca418ba",
-    "ddd2ecce739e823629c7d46ab18918e9c4a51c75",
-);
-
-write_test!(
-    hash160,
-    "b472a266d0bd89c13706a4132ccfb16f7c3b9fcb",
-    "671356a1a874695ad3bc20cae440f4360835bd5a",
-    "a9608c952c8dbcc20c53803d2ca5ad31d64d9313",
-);
-
-#[test]
-fn hmac() {
-    let mut engine = hmac::HmacEngine::<sha256::Hash>::new(&[0xde, 0xad, 0xbe, 0xef]);
-    engine.write_all(&[]).unwrap();
-    assert_eq!(
-        format!("{}", hmac::Hmac::from_engine(engine)),
-        "bf5515149cf797955c4d3194cca42472883281951697c8375d9d9b107f384225"
-    );
-
-    let mut engine = hmac::HmacEngine::<sha256::Hash>::new(&[0xde, 0xad, 0xbe, 0xef]);
-    engine.write_all(&[1; 256]).unwrap();
-    assert_eq!(
-        format!("{}", hmac::Hmac::from_engine(engine)),
-        "59c9aca10c81c73cb4c196d94db741b6bf2050e0153d5a45f2526bff34675ac5"
-    );
-
-    let mut engine = hmac::HmacEngine::<sha256::Hash>::new(&[0xde, 0xad, 0xbe, 0xef]);
-    engine.write_all(&[99; 64000]).unwrap();
-    assert_eq!(
-        format!("{}", hmac::Hmac::from_engine(engine)),
-        "30df499717415a395379a1eaabe50038036e4abb5afc94aa55c952f4aa57be08"
-    );
-}
-
-#[test]
-fn siphash24() {
-    let mut engine = siphash24::HashEngine::with_keys(0, 0);
-    engine.write_all(&[]).unwrap();
-    assert_eq!(format!("{}", siphash24::Hash::from_engine(engine)), "d70077739d4b921e");
-
-    let mut engine = siphash24::HashEngine::with_keys(0, 0);
-    engine.write_all(&[1; 256]).unwrap();
-    assert_eq!(format!("{}", siphash24::Hash::from_engine(engine)), "3a3ccefde9b5b1e3");
-
-    let mut engine = siphash24::HashEngine::with_keys(0, 0);
-    engine.write_all(&[99; 64000]).unwrap();
-    assert_eq!(format!("{}", siphash24::Hash::from_engine(engine)), "ce456e4e4ecbc5bf");
-}
diff --git a/hashes/tests/regression.rs b/hashes/tests/regression.rs
index a4ffddc2..319d6ecf 100644
--- a/hashes/tests/regression.rs
+++ b/hashes/tests/regression.rs
@@ -1,6 +1,8 @@
 //! Regression tests for each hash type.
 //!
 //! Note that if `bitcoin-io` is enabled then we get more regression-like testing from `./io.rs`.
+//!
+//! Test input data and expected hashes is the same as in `io/src/hash.rs`.
 
 #![cfg(feature = "hex")]
 
diff --git a/io/contrib/test_vars.sh b/io/contrib/test_vars.sh
index 92dc0940..279c2507 100644
--- a/io/contrib/test_vars.sh
+++ b/io/contrib/test_vars.sh
@@ -5,10 +5,10 @@
 # shellcheck disable=SC2034
 
 # Test all these features with "std" enabled.
-FEATURES_WITH_STD=""
+FEATURES_WITH_STD="hashes"
 
 # Test all these features without "std" enabled.
-FEATURES_WITHOUT_STD="alloc"
+FEATURES_WITHOUT_STD="alloc hashes"
 
 # Run these examples.
 EXAMPLES=""