rustcryptographysystems-programmingweb3

building a cryptographic hash function from scratch in rust (part 2)

I gave TorqueHash the ability to stream data using Rust's Write trait - turning it from a byte-slice toy into a composable I/O component with a real CLI.

5 min read
7 views
Building a Cryptographic Hash Function from Scratch in Rust (part 2)

title: "Building a Cryptographic Hash Function from Scratch in Rust (part 2)" date: "2026-02-15" description: "I gave TorqueHash the ability to stream data using Rust's Write trait — turning it from a byte-slice toy into a composable I/O component with a real CLI." tags: ["rust", "cryptography", "systems-programming", "web3"] author: "Khusniddin Fakhritdinov" slug: "building-a-cryptographic-hash-function-from-scratch-in-rust-part-2"

Building a Cryptographic Hash Function from Scratch in Rust (part 2)

In part 1, I built TorqueHash — a minimal cryptographic hash function using sponge construction and ARX operations. It worked. You fed it a byte slice, it scrambled the bits through 24 rounds of permutations, and out came a 256-bit digest.

But it had a limitation: the only way to use it was to call update() with a &[u8]. Want to hash a file? You'd have to read the entire thing into memory first. That's not how real tools work. sha256sum doesn't load a 4GB file into RAM - it streams data through the hash function in chunks.

This time, I wanted TorqueHash to plug into Rust's I/O ecosystem - the same way a file, a network socket, or stdout does.

The full source code is on GitHub: github.com/hfmuzb/torque_hash


The Write Trait: Turning a Hasher into a Byte Sink

Rust's standard library has a trait called std::io::Write. Anything that implements it can receive bytes - files, TCP streams, buffered writers, even stdout. The trait requires just two methods:

fn write(&mut self, buf: &[u8]) -> std::io::Result<usize>;
fn flush(&mut self) -> std::io::Result<()>;

A hasher is, at its core, a sink for bytes. You pour data in and it absorbs it. So implementing Write for TorqueHash is natural:

impl Write for TorqueHash {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        self.update(buf);
        Ok(buf.len())
    }

    fn flush(&mut self) -> std::io::Result<()> {
        Ok(())
    }
}

flush() is a no-op — hashing is a one-way absorption, there's nothing to flush. And write() delegates to update(), returning how many bytes were consumed (all of them, always).

This is about 10 lines of code. But those 10 lines unlock something powerful.


io::copy — Streaming for Free

Once TorqueHash implements Write, it works with std::io::copy out of the box:

let mut file = File::open("some_large_file.bin")?;
let mut hasher = TorqueHash::new();
io::copy(&mut file, &mut hasher)?;
let digest = hasher.finalize();

io::copy reads from the source in chunks and writes them into the hasher. No intermediate Vec<u8>. No loading the entire file into memory. Rust's standard library handles the plumbing — you just connect the pipes.

This is what I find satisfying about Rust's trait system. You implement a small interface, and suddenly your type composes with the entire I/O stack. There's no inheritance hierarchy, no "framework" to buy into. Just a contract: "I can receive bytes."


Building the CLI

With streaming in place, turning TorqueHash into a command-line tool was straightforward:

fn main() {
    let args: Vec<String> = env::args().collect();
    if args.len() < 2 {
        println!("Usage: torque_hash <file>");
        return;
    }

    let filename = &args[1];
    let mut file = match File::open(filename) {
        Ok(f) => f,
        Err(e) => {
            println!("Error opening file {}: {}", filename, e);
            process::exit(1);
        }
    };

    let mut hasher = TorqueHash::new();
    match io::copy(&mut file, &mut hasher) {
        Ok(_) => {},
        Err(e) => {
            println!("Error reading file: {}", e);
            process::exit(1);
        }
    };

    let digest = hasher.finalize();
    let hex: String = digest.iter()
        .map(|b| format!("{:02x}", b))
        .collect();
    println!("{}", hex);
}
$ torque_hash myfile.txt
a688809d741d93faa3bd5c540515e8508bf6b5ad7c51e9ceab3b73e97e2b53d9

It works the same way sha256sum does - point it at a file, get a hash. The entire streaming pipeline - file read, chunked transfer, absorption, finalization - happens without ever holding more than a small buffer in memory.


Testing the Streaming Path

I wanted to verify that hashing through the Write trait produces the same result as calling update() directly. They take the same internal path, but a test makes that guarantee explicit:

#[test]
fn test_write_trait() {
    let mut hasher = TorqueHash::new();
    write!(hasher, "The answer is 42!").unwrap();
    let digest = hasher.finalize();

    let mut manual = TorqueHash::new();
    manual.update(b"The answer is 42!");
    assert_eq!(digest, manual.finalize());
}

And the full streaming path with io::copy, using a Cursor as a fake file:

#[test]
fn test_streaming_io_copy() {
    let mut source = Cursor::new(b"testing streaming io copy");
    let mut hasher = TorqueHash::new();
    let bytes = io::copy(&mut source, &mut hasher).unwrap();

    assert_eq!(bytes, 25);

    let mut manual = TorqueHash::new();
    manual.update(b"testing streaming io copy");
    assert_eq!(hasher.finalize(), manual.finalize());
}
running 7 tests
test tests::test_avalanche_effect ... ok
test tests::test_determinism ... ok
test tests::test_empty_input ... ok
test tests::test_incremental_update ... ok
test tests::test_output_length ... ok
test tests::test_streaming_io_copy ... ok
test tests::test_write_trait ... ok

test result: ok. 7 passed; 0 failed; 0 ignored

Both pass. The streaming path and the manual path produce identical digests.


What I Learned

Traits are interfaces with leverage. Implementing Write is ~10 lines of code, but it connects your type to the entire standard I/O stack - io::copy, BufWriter, the write! macro, anything that expects a writer.

Think in terms of data flow. A hasher isn't an "object that hashes things" - it's a sink. Once I saw it that way, implementing Write felt obvious rather than clever.

Rust rewards composability over frameworks. You don't build a "file hashing framework." You implement a trait and let the standard library do the rest. Coming from Python, where I'd reach for argparse and wrapper classes, this minimalism is refreshing.


What's Next

The next challenge is internal hardening - making TorqueHash more robust without changing its external behavior. That means length-encoded padding, chunk-based absorption instead of byte-by-byte processing, and hiding internal state from debug output. The kind of work that separates a sketch from something structurally sound.

The full source code is available on GitHub: github.com/hfmuzb/torque_hash