How to check sha256 of a file

Hash the file before it leaves and hash the copy that comes back. sha256sum export.csv prints the digest; run it again on the downloaded file. Identical digests mean identical bytes. A different digest with a different size points at truncation, and a difference of one byte per line points at newline translation.

Why check this

An upload has two ends and a tester usually watches one of them. The server answers 201 and the file appears in a list, which says the request was accepted, not that the stored bytes match the bytes on disk.

Run it after a change to upload or download code, after a storage backend swap, and after a proxy goes in front of the endpoint. The failure it prevents is concrete: a 593 byte CSV export that arrives 614 bytes long, one carriage return per line, which a downstream importer reads as a trailing field on every row.

The check says nothing about what the file is, or whether the server should have accepted it. Those are How to check file type and How to check if file type is validated on the server.

Prerequisites

import { createServer } from 'node:http';
import { createHash } from 'node:crypto';

const store = new Map();

createServer((req, res) => {
  const name = req.url.split('/').pop();
  if (req.method === 'PUT' || req.method === 'POST') {
    const chunks = [];
    req.on('data', (c) => chunks.push(c));
    req.on('end', () => {
      const body = Buffer.concat(chunks);
      store.set(name, body);
      res.writeHead(201, { 'content-type': 'application/json' });
      res.end(JSON.stringify({
        name, size: body.length,
        sha256: createHash('sha256').update(body).digest('hex'),
      }) + '\n');
    });
    return;
  }
  const body = store.get(name);
  if (!body) { res.writeHead(404).end(); return; }
  // /norange/ answers 200 to a Range request, the way a server without range support does.
  const r = req.url.startsWith('/norange/') ? '' : req.headers.range || '';
  const m = /^bytes=(\d+)-(\d*)$/.exec(r);
  if (m) {
    const start = Number(m[1]);
    const end = m[2] ? Number(m[2]) : body.length - 1;
    res.writeHead(206, {
      'content-range': `bytes ${start}-${end}/${body.length}`,
      'content-length': end - start + 1,
      'accept-ranges': 'bytes',
    });
    res.end(body.subarray(start, end + 1));
    return;
  }
  res.writeHead(200, { 'content-length': body.length, 'accept-ranges': 'bytes' });
  res.end(body);
}).listen(8932, '127.0.0.1', () => console.log('integrity target on 8932'));
{ echo "id,email,amount"; for i in $(seq 1 20); do echo "$i,user$i@example.test,$((i*17)).00"; done; } > export.csv

Steps

  1. Step 1.

    Take the digest of the local file before anything is sent.

    sha256sum export.csv
    
    f536f366ce009f175a5394298bde89243d1b3ff71e8245507d9cee47dac796cd *export.csv

    The asterisk is the mode marker, not part of the name: this build reads in binary mode by default.

  2. Step 2.

    Take the same digest with the tool that is on every Windows machine.

    certutil -hashfile export.csv SHA256
    
    SHA256 hash of export.csv:
    f536f366ce009f175a5394298bde89243d1b3ff71e8245507d9cee47dac796cd
    CertUtil: -hashfile command completed successfully.

    Three lines instead of one, the name on a label, and no mode marker. The digest is identical, so compare the hex alone.

  3. Step 3.

    Take it a third time from Node, the form that fits a test runner.

    node -e "const{createHash}=require('node:crypto');const{readFileSync}=require('node:fs');console.log(createHash('sha256').update(readFileSync(process.argv[1])).digest('hex'))" export.csv
    
    f536f366ce009f175a5394298bde89243d1b3ff71e8245507d9cee47dac796cd

    Bare hex, no file name and no marker. Three tools, three output shapes, one digest.

  4. Step 4.

    Upload the file and read the digest the server computed over what arrived.

    curl -s -T export.csv http://127.0.0.1:8932/upload/export.csv
    
    {"name":"export.csv","size":593,"sha256":"f536f366ce009f175a5394298bde89243d1b3ff71e8245507d9cee47dac796cd"}

    The digest matches step 1, so the request body was the file.

  5. Step 5.

    Hash the response body on the way back, without saving it first.

    curl -s http://127.0.0.1:8932/file/export.csv | sha256sum
    
    f536f366ce009f175a5394298bde89243d1b3ff71e8245507d9cee47dac796cd *-

    The name reads as a single dash because the bytes came from standard input. Steps 1, 4 and 5 agreeing closes the round trip.

  6. Step 6.

    Send the same file with newline translation switched on, as a text mode transfer does.

    curl -s --crlf -T export.csv http://127.0.0.1:8932/upload/export-text.csv
    
    {"name":"export-text.csv","size":614,"sha256":"3a330fc9f54d361c52534c851904bac4017feb81d6b46655ef725c7b51877e6b"}

    Same file on disk, 21 bytes more on the server, one per line, and a digest with nothing in common with step 1. A hash reports damage; the size measures it.

  7. Step 7.

    Fetch part of the file, the way a transfer that stopped early leaves it.

    curl -s -r 0-399 -o partial.csv -w '%{http_code} %{size_download} bytes\n' http://127.0.0.1:8932/file/export.csv
    
    206 400 bytes

    The 206 and the byte count are the signal. The file left on disk carries neither.

  8. Step 8.

    Hash the fragment.

    sha256sum partial.csv
    
    972a914c19c5ed9bdea34501c146878d0a31cd03d8e177290ab1f0c0a84745e4 *partial.csv

    A well formed 64 character digest over 400 bytes. Nothing in it says the file is short. Only the comparison with step 1 does.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Steps 1, 4 and 5 print the same digest | The round trip preserved every byte | Record the digest as the expected value for this fixture and assert on it. | | Server digest differs, sizes are equal | The content changed without changing length | Compare the two files byte by byte. Re-encoding and an added byte order mark are the usual causes. | | Server digest differs, size larger by the line count | A text mode transfer rewrote the line endings | Send in binary mode. In curl that means dropping --crlf; in an FTP or SFTP client it is the transfer type setting. | | Server digest differs, size smaller | The transfer ended early | Read the byte count, not the file. Continue at How to test an interrupted file upload. | | Digests agree and the file still opens wrong | The bytes are intact and the reader is not | Stop testing transfer and start testing the parser, the encoding and the declared type. |

Common mistakes

Sign: You add -t to sha256sum expecting it to ignore line endings, and the digest does not change.Cause: On coreutils 8.32 in Git Bash, -t and -b select the marker printed before the file name, a space or an asterisk, and nothing else. Both modes printed f536f366 for export.csv and both printed 3a330fc9 for a CRLF copy of it, which is the digest the server reported in step 6. Newline translation happens in the transfer tool, not in the hash tool.
Sign: A resumed download leaves a file at the expected path and the pipeline treats it as complete.Cause: Against the /norange/ path in the target above, which answers 200 to a Range request, curl -C - stopped with exit 33 and the message that the server does not seem to support byte ranges and cannot resume. The file it left was the 400 byte fragment from the earlier attempt. Only the exit code said so.
Sign: The truncated file opens and parses, so it is signed off as delivered.Cause: The 400 byte fragment from step 7 ends inside a record, after 14,user14@example with no newline. A CSV reader either drops that row or reports one short field, and neither raises an error. The size and the digest catch it; opening the file does not.
Sign: The test asserts that the digest the server reported matches the digest of the file the server stored.Cause: A server that hashes what it received agrees with itself whatever arrived. The comparison that carries information is the client side digest taken before the request, set against the server figure, which is why step 1 runs before step 4.

What to check next

FAQ

How do I check a file checksum on Windows without Git Bash?

certutil -hashfile <file> SHA256 runs on every Windows install and needs no download. It prints three lines and puts the file name on a label, so a script comparing it with sha256sum output has to extract the hex line.

How do I verify file integrity after a download?

Take the digest of the source before it is served, publish it, and run sha256sum -c against it after the download. On a manifest naming the downloaded copy, a match printed roundtrip.csv: OK and exit 0, and a truncated copy printed partial.csv: FAILED and exit 1.

Do sha256sum and certutil produce the same hash?

Yes, for the same bytes. Steps 1 and 2 printed the same 64 hex characters for export.csv. The output formats differ and the letter case can differ between tools and versions, so compare lowercased hex rather than whole lines.

Is MD5 enough for a transfer check?

For catching an accidental truncation or a rewritten newline, yes. For a file another party supplied, no: MD5 collisions are constructible, so a matching MD5 does not establish that nobody swapped the file.

Verified

Verified by Maks Vernycurl 8.21.0sha256sum (GNU coreutils) 8.32certutil 10.0.22621.1node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic7 minpublished updated Maks Verny