DEV Community

meng meng
meng meng

Posted on Originally published at meng4099244-ship-it.github.io

Hash Generator: When sha256sum -c Says FAILED but the Tarball Is Fine

A vendor sent us a signed build of their SDK last month. Version 2.4.1, one tarball, one checksums.txt. Straightforward. I dropped both into CI, ran sha256sum -c checksums.txt, and got:

app-2.4.1.tar.gz: FAILED
sha256sum: WARNING: 1 computed checksum did NOT match
Enter fullscreen mode Exit fullscreen mode

My first thought was a corrupted download. My second thought was worse: an attacker. It turned out to be neither. The tarball was fine, the checksum was fine, and the actual problems were the kind you only find by re-hashing the same file four different ways. Here's everything I tried, with the real outputs, so you can skip three of the four detours.

Method 1: Python, done properly

Python 3.11.1 on the CI box. The job: hash the tarball, read the expected digest out of the manifest, compare. My first attempt was six lines and it exploded:

# WRONG - do not run this on a binary
with open("app-2.4.1.tar.gz") as f:
    data = f.read()

import hashlib
print(hashlib.sha256(data).hexdigest())
Enter fullscreen mode Exit fullscreen mode
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x1f in position 0: invalid start byte
Enter fullscreen mode Exit fullscreen mode

That 0x1f is the gzip magic byte. open() defaults to text mode, and text mode tries to decode every byte as UTF-8 before it ever reaches hashlib. Two fixes: open in binary mode, and read in chunks so a 900 MB archive doesn't sit in RAM as one bytes object.

# Python 3.11.1, standard library only
import hashlib
from pathlib import Path

def sha256_file(path: str) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:          # "rb", not "r"
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()

digest = sha256_file("app-2.4.1.tar.gz")
manifest = Path("checksums.txt").read_text()

expected = next(
    line.split()[0] for line in manifest.splitlines()
    if "app-2.4.1.tar.gz" in line
)
print(digest)                             # 4f1b2c...e7a902 (64 hex chars)
print("match" if digest == expected.lower() else "MISMATCH")
Enter fullscreen mode Exit fullscreen mode

This printed match while sha256sum -c kept saying FAILED. That contradiction is what sent me down the rabbit hole.

Method 2: the shell, with the manifest itself as the suspect

GNU coreutils 8.32 on Ubuntu 22.04. When a per-file hash matches but sha256sum -c fails, stop hashing the tarball and start inspecting the manifest:

$ cat -A checksums.txt | head -2
4f1b2c9d...e7a902^M  app-2.4.1.tar.gz$
Enter fullscreen mode Exit fullscreen mode

That ^M is a carriage return. The vendor generated checksums.txt on Windows, so every line ends in CRLF. GNU sha256sum treats app-2.4.1.tar.gz\r as the filename, and my verbose run confirmed it:

sha256sum: app-2.4.1.tar.gz: No such file or directory
sha256sum: WARNING: 1 listed file could not be read
Enter fullscreen mode Exit fullscreen mode

The quiet FAILED from earlier was actually a read failure, not a hash mismatch. The one-line fix on Linux:

tr -d '\r' < checksums.txt > checksums.fixed.txt
sha256sum -c checksums.fixed.txt
# app-2.4.1.tar.gz: OK
Enter fullscreen mode Exit fullscreen mode

On macOS it fails even louder, because BSD shasum rejects the whole file:

shasum: standard input: no properly formatted checksum lines found
Enter fullscreen mode Exit fullscreen mode

One more shell detail worth knowing: sha256sum -c resolves paths relative to the current directory, not relative to the manifest. Running it from the repo root when the manifest lists dist/app-2.4.1.tar.gz gives you the same "No such file or directory" again. cd into the directory that holds the artifact, or run sha256sum -c ../checksums.fixed.txt from inside dist/.

Method 3: no scripts, just paste

After the third false alarm I stopped wanting to run anything at all. For a single file on a machine where I don't trust my own aliases, I now generate the digest with a browser-based hash generator like this free SHA-256 hash tool: drop the file in, read the digest, compare against the vendor's value by eye. It runs client-side, so the archive never leaves the machine, which matters more than it sounds — I'm not uploading an unreleased vendor build to some random hashing web service.

It computes MD5, SHA-1, SHA-256 and SHA-512 in one pass, which is handy when a vendor ships a manifest with mixed algorithms. The same site's hash generator also takes pasted text, useful for hashing a small token or config snippet where echo vs printf quirks (a trailing newline changes everything) would otherwise bite. When I need to explain the process to a non-CLI teammate, this browser-based checksum tool is the link I send.

For the record, the JavaScript way (if you must do it in Node instead of the browser) is crypto.subtle.digest('SHA-256', arrayBuffer) per the MDN SubtleCrypto docs, and the Python hashlib reference documents the chunked-update pattern I used above.

The three pitfalls that actually cost me time

1. Text-mode file reads. The UnicodeDecodeError above looks like a corrupt file. It isn't. Any open(path) without "rb" will die on the first non-UTF-8 byte, and gzip's 0x1f 0x8b header guarantees it. Binary hashes need binary reads, always.

2. CRLF in the checksum manifest. The failure message is misleading: FAILED on GNU sha256sum can mean "hash mismatch" or "file not readable". The \r glued to the filename causes the latter. cat -A the manifest before you distrust the tarball. And note WARNING: 1 listed file could not be read appears only with -c; the bare per-file run just exits 1 with no filename hint.

3. Case-sensitive hex comparison. Some vendors emit uppercase digests. digest == expected then reports MISMATCH for identical hashes. Compare with .lower() on both sides, and sanity-check the length is exactly 64 characters. I once chased a "mismatch" that was a 63-character paste with the last character lost to a terminal selection.

Recap

Check Command / method Gotcha
File digest Python hashlib chunked, "rb" mode text mode → UnicodeDecodeError
Manifest verify sha256sum -c CRLF manifest → false FAILED
macOS verify shasum -a 256 -c rejects CRLF outright
One-off check browser hash tool none, but eyeball the length

The vendor has since regenerated their manifest on Linux. My CI has the tr -d '\r' guard anyway, because somebody else's Windows-generated checksum file is a matter of time.

More free browser-based utilities at utilvy.com.

Top comments (0)