I have a 2 TB backup drive that was written on Linux: LUKS2 encryption, ext4 inside. Plugging it into a Mac gets you the "The disk you inserted was not readable by this computer" dialog, with Initialize… sitting right there next to Ignore — which is not the most desirable default for a backup disk. I could always attach it to a Raspberry Pi 4 I had running at the time, but the Pi USB power envelope is generally too tight for M.2 SSD drives — the supply browns out on anything this power hungry, and getting a powered USB hub just to read a file from a backup was a hassle.

The usual advice is to install a Linux VM and pass the USB device through. On an Apple Silicon Mac that is an x86-emulation-shaped afternoon, and macFUSE plus ext4fuse means a kernel extension and the security-approval dance. Both felt like too much machinery for "read some files".

So instead I did what I had recently done with vdiread and created luksext4, a pure-Python read-only reader that implements LUKS, LVM and ext4 well enough to get files off the drive. Nothing is mounted, nothing is installed into the system, and the device is opened O_RDONLY — it physically cannot damage the drive.

Like the VDI project, this one was also almost single-shot prompted with Opus 4.8, so what follows is mostly an AI-created tour of an AI-created tool. The main takeaway is that these types of tools can now be created basically on demand, since all the components (LUKS, ext4) are open source, and an agent can either reach for existing libraries or, in a pinch, read the relevant kernel modules and adapt the logic to whatever language you want.

What It Reads

The stack turned out to be more layered than I expected, because the drive was LUKS → LVM → ext4 rather than LUKS → ext4:

  • LUKS1 and LUKS2 — PBKDF2 and Argon2i/Argon2id key derivation, aes-xts-plain64, aes-cbc-essiv:sha256 and the other common cipher/IV combinations, 512- and 4096-byte encryption sectors, and the anti-forensic keyslot merge.
  • LVM2 in between — linear logical volumes on a single physical volume, which is what a cryptsetup + lvm install produces by default.
  • ext2/ext3/ext4 — extent trees and the classic indirect block maps, 64-bit block numbers, htree directories, fast and slow symlinks, sparse files, inline data, in-inode extended attributes.
  • GPT/MBR scanning, so you can point it at a whole disk or a raw image instead of a specific slice.

Not supported, deliberately: writing, journal replay, btrfs/xfs/ZFS, fscrypt, RAID, and thin or striped LVM. The journal one is worth knowing about — if the drive was not cleanly unmounted, the last writes before it was yanked may not be visible. The tool warns when the filesystem state is not clean.

Using It

Plug in the drive, click Ignore on the macOS dialog, and find the partition:

diskutil list   # look for a "Linux" or "Linux LVM" partition, e.g. disk4s1

Raw disk access needs root, so everything runs under sudo:

sudo .venv/bin/luksext4 info /dev/rdisk4s1

Here is real output, from a small LUKS2 test image rather than my actual backup drive:

LUKS2 volume
  UUID:       aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
  label:      backup
  cipher:     aes-xts-plain64
  sector:     4096
  payload at: 1048576 bytes
  keyslot 0: argon2id (t=2, m=65536 KiB, p=2)

ext4 filesystem
  label:        backup
  UUID:         daa30f4c-bbd3-4e53-828e-87a4903e6da2
  block size:   1024
  blocks:       49152 (0.05 GB)
  inodes:       12288
  last write:   2026-08-03 22:11:20
  last mounted: (unknown)
  state:        clean

Then browse and copy:

$ luksext4 ls demo-luks.img / -l
drwxr-xr-x    3   501     0         1024 2026-08-03 22:11 backups
lrwxr-xr-x    1   501     0           18 2026-08-03 22:11 latest -> backups/2026-07-20
drwx------    2     0     0        12288 2026-08-03 22:11 lost+found
-rw-r--r--    1   501     0           31 2026-08-03 22:11 notes.txt

$ luksext4 cp demo-luks.img /backups ./out -r
luksext4: copied 1 files, 2 dirs, 0 symlinks, 3145728 bytes

cp preserves mtimes and symlinks and shows progress for large files, and there is a cat for pushing a single file to stdout. Image files work exactly like devices, so you can dd the drive first and work from a copy.

The passphrase is resolved from --pass-file, --keyfile, the LUKS_PASSWORD environment variable or a .env file in the current directory, and finally an interactive prompt. The .env route is more useful than it sounds: sudo keeps the working directory, so a project-local (git-ignored) .env is found without exporting anything into your shell history.

The Symlink Gotcha

This one took a moment to figure out. Backup scripts love absolute symlinks — a latest link pointing at /mnt/luks/backup-2026-07-20/. But this tool's filesystem root is /, not /mnt/luks, so every one of those links pointed into the void.

The fix: ext4's superblock records the last-mounted path. The tool reads that field, strips the prefix from absolute symlink targets, and the links resolve correctly. --mountpoint /some/path overrides it, and --mountpoint / disables the stripping entirely.

Mounting It in Finder Over WebDAV

Command-line copying works, but sometimes you want to browse the drive in Finder, or play a video straight off it. FUSE would do that — and would reintroduce the kernel extension I was trying to avoid.

macOS has a WebDAV client built in, though. So luksext4 serve unlocks the drive once and exposes it as a read-only WebDAV share on localhost:

sudo .venv/bin/luksext4 serve /dev/rdisk4s1
Serving backup read-only at http://127.0.0.1:8080/
Finder: Go -> Connect to Server -> http://127.0.0.1:8080/
CLI:    mkdir -p /tmp/luks && mount_webdav http://127.0.0.1:8080/ /tmp/luks

Then Go → Connect to Server… (⌘K) in Finder and it mounts like any network volume. Byte-range requests are implemented, so seeking in a video does not re-download the file. Every write method (PUT, DELETE, MKCOL and friends) is answered with 403.

This fell out of the architecture almost for free. Everything in the project is built on one tiny interface — an object with read_at(offset, length) and a size — and the layers just stack:

BlockDevice / SubView        blockio.py   aligned, cached raw reads
  └─ CryptView               crypto.py    per-sector AES-XTS/CBC decrypt
       └─ LvView             lvm.py       LVM linear segment remapping
            └─ Ext4          ext4.py      filesystem: inodes, dirs, files

The WebDAV front-end only ever talks to the Ext4 object. It has no idea whether there is encryption or LVM underneath.

One warning: the server binds to 127.0.0.1 only, and it should stay that way. The connection carries decrypted data over plain HTTP, so never pass --host 0.0.0.0. If others share the Mac, --user NAME adds WebDAV authentication.

Is It Fast Enough?

Surprisingly, yes. On Apple Silicon, decryption runs at roughly 100 MB/s with 512-byte LUKS sectors and about 640 MB/s with 4096-byte sectors — the cryptography package is OpenSSL-backed, so the AES itself is hardware accelerated and the per-sector Python overhead dominates. That is well past USB enclosure speeds for the 4K case.

The raw character device /dev/rdiskNsM is noticeably faster than the buffered /dev/diskNsM, and reads are 4K-aligned and LRU-cached internally.

Testing Something Like This

Writing a filesystem reader is easy; writing a correct one is not, and the failure mode is silently wrong bytes. So the test suite tries hard to compare against independent implementations:

  • ext4 images are built by real mke2fs in five feature variants (4K and 1K blocks, 64-bit, ext3-style block maps, inline data), then read back and compared bit-for-bit.
  • LUKS containers are round-tripped through an in-tree writer that uses the forward transforms, covering LUKS1 and LUKS2, PBKDF2 and Argon2i/id, XTS and CBC-ESSIV, and both sector sizes.
  • LVM mapping is checked with a hand-built PV that stores an LV's extents out of order, so a naive sequential mapping would fail.
  • The WebDAV server is tested for PROPFIND listings, ranged GETs, non-ASCII filenames, and that every write method is refused.

Plus the real end-to-end check: it read my actual 2 TB LUKS2 backup drive.

Requires Python 3.9+ and uv:

git clone https://github.com/jokkebk/luksext4.git
cd luksext4
uv sync
sudo .venv/bin/luksext4 info /dev/rdisk4s1

Source: github.com/jokkebk/luksext4