← All posts

What’s inside a failed download file? Recovering data from a `.crdownload`

I was downloading a ZIP of a full Dangerous-era Michael Jackson album. The download process got stuck halfway through. The chrome browser left a file named something like Unconfirmed 804785.crdownload. The progress bar showed that the download process failed. The file had no real extension. It looked like I needed to just download again (which I did).

But I am curious.

I had a ~94 MB leftover like that. Curiosity won over “just delete and re-download.” I wanted to learn about what the leftover file really was. After checking with some commands, it turned out that I could retrieve most of the songs, 13 of 14 tracks out of it. They came out playable, without fetching a single new byte from the network. (Of course I still need to download the last song after that.)

This is that walk-through of the process: what I tried, what the bytes said, and the bits of ZIP format that made recovery possible. Every command and script is below if you want to follow along and test on your own half-finished downloads.


What is .crdownload?

Chromium-based browsers (Chrome, Arc, Brave, Edge, etc) don’t write straight to the final filename. They stream bytes into a temporary file that ends in .crdownload.

  • Download succeeds → the browser renames it to the real file name (e.g. MichaelJackson_Dangerous.zip)
  • Download fails → you’re left with whatever arrived before the connection dropped, no change to the file name

The extension is only a flag to indicate whether the download process has finished or not. There’s no special wrapper format. The file is raw downloaded data from the server. So the first job is to check the bytes to see what file format what they are.


Finding the leftover

So this is the file I am going to work on:

ls -lah ~/Downloads/ | grep -i crdownload

Something like:

-rw-r--r--@  94M  user  8 Jul 22:20  Unconfirmed 804785.crdownload

94 megabytes is a lot of “nothing.”


Peeking at the first bytes

File extensions mean nothing when download is incomplete, so I went to the magic bytes.

Identify the type

file ~/Downloads/"Unconfirmed 804785.crdownload"
Zip archive data, at least v2.0 to extract, compression method=deflate

file only sniffs the start of the file for header, but that was already a green light. I wanted to see it myself.

Hex dump the start

xxd ~/Downloads/"Unconfirmed 804785.crdownload" | head -20

xxd turns raw bytes into hex lines; head trims to the first 20 of those lines.

00000000: 504b 0304 1400 0000 0800 e36a af5c 439a  PK.........j.\C.
00000010: 4fff 790c 1f00 e737 1f00 2100 0000 4d69  O.y....7..!...Mi
00000020: 6368 6165 6c4a 6163 6b73 6f6e 5f44 616e  chaelJackson_Dan
00000030: 6765 726f 7573 5f4a 616d 2e6d 7033 94fd  gerous_Jam.mp3..
00000040: 7958 1357 ffc7 0f9f 99c9 6425 2440 2010  yX.W......d%$@ .
Bytes Meaning
50 4b ASCII PK — ZIP magic (Phil Katz’s PKZIP)
03 04 Local file header — start of a file inside the archive
MichaelJackson_Dangerous_Jam.mp3 First filename, sitting as plain text in the header

No browser history required. The data was already telling its own story.

Hex dump the end

A normal ZIP file’s tail should contain the central directory index (PK\x01\x02 entries) followed by the end-of-central-directory record (PK\x05\x06). I can check it using this command:

xxd ~/Downloads/"Unconfirmed 804785.crdownload" | tail -20

Obviously, there is no such string for this file.


Trying the obvious (and watching it fail)

unzip -l ~/Downloads/"Unconfirmed 804785.crdownload"
Archive:  .../Unconfirmed 804785.crdownload
  End-of-central-directory signature not found.  Either this file is not
  a zipfile, or it constitutes one disk of a multi-part archive. ...
unzip:  cannot find zipfile directory ...

Fair. This file does not contain the ZIP’s central directory, which everyday tools lean on. They give up even when most of the file data is still there. What do I do next?

How a ZIP is actually laid out

┌─────────────────────────────┐
│ Local header + File 1 data  │
├─────────────────────────────┤
│ Local header + File 2 data  │
├─────────────────────────────┤
│           ...               │
├─────────────────────────────┤
│ Local header + File N data  │
├─────────────────────────────┤
│ Central directory (index)   │  ← lists every file, sizes, offsets
├─────────────────────────────┤
│ End of central directory    │  ← points to the index above
└─────────────────────────────┘

My partial download had local headers and payload for thirteen complete entries and the bottom two sections never arrived.

Actually, I don’t need the index, I can parse those headers myself. I have the header for each file: filename, compressed size, uncompressed size, compression method already.


Surveying the ZIP markers

Before trying to extract anything, I use the following to find all the local headers (including searching for central directory or end record)

python3 << 'EOF'
import os

path = os.path.expanduser('~/Downloads/Unconfirmed 804785.crdownload')
size = os.path.getsize(path)
print(f'File size: {size:,} bytes ({size / 1024 / 1024:.1f} MB)')

with open(path, 'rb') as f:
    data = f.read()

markers = []
for sig, name in [
    (b'PK\x03\x04', 'local'),
    (b'PK\x01\x02', 'central'),
    (b'PK\x05\x06', 'eocd'),
    (b'PK\x07\x08', 'data_desc'),
]:
    pos = 0
    while True:
        pos = data.find(sig, pos)
        if pos == -1:
            break
        markers.append((pos, name))
        pos += 4

markers.sort()
print(f'ZIP markers found: {len(markers)}')
for pos, name in markers[:10]:
    print(f'  0x{pos:08x} ({pos:,}) - {name}')
if len(markers) > 10:
    print(f'  ... and {len(markers) - 10} more')

print('Has EOCD (end of central directory):', any(n == 'eocd' for _, n in markers))
print('Has central directory:', any(n == 'central' for _, n in markers))
EOF
File size: 94,004,023 bytes (89.6 MB)
ZIP markers found: 14
  0x00000000 (0) - local
  0x006b1a40 (7,020,096) - local
  0x00d44e20 (13,910,048) - local
  ...
Has EOCD (end of central directory): False
Has central directory: False

Fourteen local headers, no central directory, no end record. Matches what unzip complained about—and also means fourteen candidates to try recovering.


Parsing headers and checking completeness

For each local header I compared how many compressed bytes were still on disk against what the header claimed it needed.

python3 << 'EOF'
import struct
import os

path = os.path.expanduser('~/Downloads/Unconfirmed 804785.crdownload')
with open(path, 'rb') as f:
    data = f.read()

file_size = len(data)
entries = []
pos = 0

while pos < file_size - 30:
    if data[pos:pos + 4] != b'PK\x03\x04':
        pos += 1
        continue
    try:
        (ver, flags, method, mtime, mdate, crc, comp_size, uncomp_size,
         name_len, extra_len) = struct.unpack_from('<HHHHHIIIHH', data, pos + 4)
        name_start = pos + 30
        name = data[name_start:name_start + name_len].decode('utf-8', errors='replace')
        data_start = name_start + name_len + extra_len
        entries.append({
            'offset': pos,
            'name': name,
            'method': method,
            'comp_size': comp_size,
            'uncomp_size': uncomp_size,
            'data_start': data_start,
        })
        if comp_size > 0:
            pos = data_start + comp_size
        else:
            pos = data_start
    except Exception:
        pos += 1

print(f'Found {len(entries)} zip local entries')
print(f'Downloaded: {file_size:,} bytes\n')

complete = []
partial = []

for i, e in enumerate(entries):
    avail = file_size - e['data_start']
    needed = e['comp_size']
    if needed > 0 and avail >= needed:
        status = 'complete'
        complete.append(e)
    elif avail > 0:
        status = 'partial'
        partial.append((e, avail, needed))
    else:
        status = 'missing'

    pct = f'{avail}/{needed}' if needed else f'{avail}/?'
    print(f'  [{status:8}] {e["name"]}  compressed: {pct}')

print(f'\nSummary: {len(complete)} complete, {len(partial)} partial')

# Sanity check: try decompressing the first complete entry
if complete:
    import zlib
    e = complete[0]
    comp = data[e['data_start']:e['data_start'] + e['comp_size']]
    try:
        raw = zlib.decompress(comp, -15) if e['method'] == 8 else comp
        print(f'\nDecompressed first complete file: {e["name"]} ({len(raw):,} bytes)')
    except Exception as ex:
        print(f'\nDecompress test failed: {ex}')

# Report on the truncated entry
if partial:
    e, avail, needed = partial[-1]
    print(f'\nLast partial file: {e["name"]}')
    print(f'  Got {avail:,} of {needed:,} compressed bytes ({100 * avail / needed:.1f}%)')
    try:
        import zlib
        comp = data[e['data_start']:e['data_start'] + avail]
        raw = zlib.decompress(comp, -15)
        print(f'  Partial decompress succeeded: {len(raw):,} bytes (may be corrupt)')
    except Exception as ex:
        print(f'  Partial decompress failed: {ex}')
EOF
Found 14 zip local entries
Downloaded: 94,004,023 bytes

  [complete] MichaelJackson_Dangerous_Jam.mp3                      compressed: 7019991/7019991
  [complete] MichaelJackson_Dangerous_WhyYouWannaTripOnMe.mp3       compressed: 6889201/6889201
  [complete] MichaelJackson_Dangerous_InTheCloset.mp3                compressed: 6720103/6720103
  ...
  [complete] MichaelJackson_Dangerous_GoneTooSoon.mp3                compressed: 1500958/1500958
  [partial ] MichaelJackson_Dangerous_Dangerous.mp3                  compressed: 1203182/1500958

Summary: 13 complete, 1 partial

Decompressed first complete file: MichaelJackson_Dangerous_Jam.mp3 (7,234,112 bytes)

Last partial file: MichaelJackson_Dangerous_Dangerous.mp3
  Got 1,203,182 of 1,500,958 compressed bytes (80.2%)
  Partial decompress failed: Error -5 while decompressing data: incomplete or truncated stream

The rule for each entry is simple:

bytes_available = file_size - data_start
bytes_needed    = compressed_size   (from the header)
Condition Verdict
available >= needed Complete — safe to extract
available < needed Truncated — cut off mid-file

Thirteen complete, one partial. This matched what I see from the hex dump.


Extracting what we could

The MP3s used deflate (method 8). For each complete entry: slice the compressed bytes, decompress, write to disk.

python3 << 'EOF'
import struct
import os
import zlib
import glob
import subprocess

src = os.path.expanduser('~/Downloads/Unconfirmed 804785.crdownload')
out_dir = os.path.expanduser('~/Downloads/Recovered_dangerous')
os.makedirs(out_dir, exist_ok=True)

with open(src, 'rb') as f:
    data = f.read()

file_size = len(data)
entries = []
pos = 0

while pos < file_size - 30:
    if data[pos:pos + 4] != b'PK\x03\x04':
        pos += 1
        continue
    (ver, flags, method, mtime, mdate, crc, comp_size, uncomp_size,
     name_len, extra_len) = struct.unpack_from('<HHHHHIIIHH', data, pos + 4)
    name_start = pos + 30
    name = data[name_start:name_start + name_len].decode('utf-8', errors='replace')
    data_start = name_start + name_len + extra_len
    entries.append({
        'name': name,
        'method': method,
        'comp_size': comp_size,
        'data_start': data_start,
    })
    if comp_size > 0:
        pos = data_start + comp_size
    else:
        pos = data_start

extracted = 0
failed = []

for e in entries:
    avail = file_size - e['data_start']
    if e['comp_size'] > 0 and avail < e['comp_size']:
        failed.append((e['name'], f'truncated ({avail}/{e["comp_size"]} bytes)'))
        continue
    comp = data[e['data_start']:e['data_start'] + e['comp_size']]
    try:
        if e['method'] == 0:
            raw = comp
        elif e['method'] == 8:
            raw = zlib.decompress(comp, -15)
        else:
            failed.append((e['name'], f'unsupported method {e["method"]}'))
            continue
        out_path = os.path.join(out_dir, os.path.basename(e['name']))
        with open(out_path, 'wb') as out:
            out.write(raw)
        extracted += 1
    except Exception as ex:
        failed.append((e['name'], str(ex)))

print(f'Extracted {extracted} files to: {out_dir}')
print(f'Failed: {len(failed)}')
for name, reason in failed:
    print(f'  - {name}: {reason}')

mp3s = sorted(glob.glob(os.path.join(out_dir, '*.mp3')))
print(f'Total recovered MP3s: {len(mp3s)}')
for p in mp3s[:3]:
    subprocess.run(['file', p])
EOF
Extracted 13 files to: .../Recovered_dangerous
Failed: 1
  - MichaelJackson_Dangerous_Dangerous.mp3: truncated (1203182/1500958 bytes)
Total recovered MP3s: 13
...Jam.mp3: Audio file with ID3 version 2.3.0, MPEG ADTS, layer III, 320 kbps, 44.1 kHz, Stereo
...

A quick spot-check:

file ~/Downloads/Recovered_dangerous/MichaelJackson_Dangerous_Jam.mp3
Audio file with ID3 version 2.3.0, MPEG ADTS, layer III, 320 kbps, 44.1 kHz, Stereo

All thirteen extracted tracks were valid, playable MP3s. The truncated Dangerous.mp3 couldn’t decompress cleanly—deflate is sequential, and the last ~20% of compressed bytes never arrived.


Side note: how terminal “readers” see binary

While doing this I kept bumping into a distinction that’s easy to forget.

Commands like cat, head, tail, and less are byte readers, not format parsers. They don’t know ZIP from MP3 from plain text. They open a file, read bytes, and hand them to the terminal—which tries to treat each byte as a character.

For logs and source code, that’s fine. For binary, you get garbage, escape sequences, or beeps. Those tools also think in lines (bytes split on \n). A 94 MB ZIP might barely contain any newlines, so head on the raw file can dump almost everything as one unreadable blob.

That’s why this pipeline worked:

xxd file | head -20    # start of file
xxd file | tail -20    # end of file
#      ↑       ↑
#  bytes→text  first/last N text lines

xxd turns binary into readable hex lines first. Then head / tail have something sensible to trim.

Command Role
cat Dump entire file as “text”
head First N lines
tail Last N lines
less Interactive scroll-through
xxd / hexdump Bytes → hex (makes binary readable)
file Magic-byte sniffing (guesses type from the start)
unzip Actually understands ZIP structure

Rule of thumb: cat / head / less for logs and code. xxd and format-aware tools for ZIPs, audio, PDFs, and failed downloads.


What came out

Unconfirmed 804785.crdownload  (94 MB, download failed)
│
├── Jam.mp3                        ✓  extracted
├── WhyYouWannaTripOnMe.mp3        ✓  extracted
├── InTheCloset.mp3                ✓  extracted
├── SheDrivesMeWild.mp3            ✓  extracted
├── RememberTheTime.mp3            ✓  extracted
├── CantLetHerGetAway.mp3          ✓  extracted
├── HealTheWorld.mp3               ✓  extracted
├── BlackOrWhite.mp3               ✓  extracted
├── WhoIsIt.mp3                    ✓  extracted
├── GiveInToMe.mp3                 ✓  extracted
├── WillYouBeThere.mp3             ✓  extracted
├── KeepTheFaith.mp3               ✓  extracted
├── GoneTooSoon.mp3                ✓  extracted
├── Dangerous.mp3                  ✗  truncated at ~80%
│
└── (central directory never arrived)

13 of 14 tracks landed in ~/Downloads/Recovered_dangerous/—no re-download, and nothing fancier than xxd, file, unzip, and three short Python scripts.


What I’d tell a friend who hits the same wall

  1. .crdownload is just partial raw data. Rename it, hex-dump it, treat it as whatever the bytes claim to be.
  2. Magic bytes don’t lie. PK at offset 0 means ZIP, no matter what the filename says.
  3. Standard tools quit early. unzip wants the end-of-file index; walking local headers doesn’t.
  4. ZIP entries are self-describing. Each file carries its own metadata—that’s why partial archives are often partially recoverable.
  5. Terminal readers aren’t format-aware. Use xxd for binary, not cat.
  6. The last file in a truncated archive is usually the casualty. Everything before the cut-off point is often fine.

If you’ve got a stale .crdownload from failed download process, it’s worth five minutes before you delete it. Depends on the ratio of data downloaded, the data you wanted might already be there.