Skip to content

Filesystem images, in pure Go.

Turn a directory, an OCI image or another image into a valid, mountable filesystem image — ext4, squashfs, EROFS, FAT, ISO 9660 and eight more — entirely in-process.

No root, no cgo, no shelling out, no third-party dependency. Identical inputs produce byte-identical output, which is what makes an image worth signing.

go get github.com/emmanuel-deloget/fsforge

Requires Go 1.24+ · runs on Linux, macOS and Windows · fsforge help for the command

Formats

Thirteen write targets

Every format here is one fsforge writes. That is the project’s rule: a format it cannot write is a format it does not ship. Most engines open an existing image as well, which is what turns them into conversion sources.

  • 13formats fsforge writes
  • 0third-party dependencies
  • 100%Go, standard library only
  • ext2 / ext3 / ext4writereadchecked bye2fsck
  • squashfswritereadchecked byunsquashfs
  • EROFSwritereadchecked byfsck.erofs
  • cpio newcwritereadchecked bycpio
  • UDF 2.01writereadchecked byudfinfo
  • cramfswritereadchecked by7z
  • romfswritereadchecked bygenromfs
  • FAT12 / 16 / 32writereadchecked byfsck.fat
  • exFATwritereadchecked byfsck.exfat
  • ISO 9660 + Rock Ridgewritereadchecked byxorriso
  • OCI image layoutwritereadchecked bypodman

A partition table and a QCOW2 file are not filesystems — they are what a filesystem is written into. Give mkfs, a convert sink or fsforge disk an output path ending in .qcow2 and the result is a sparse QCOW2; a QCOW2 input is decoded transparently, whichever engine reads it.

  • GPT / MBR diskswritereadchecked bysfdisk
  • QCOW2writereadchecked byqemu-img

The tool named on each card is what the conformance suite hands the image to. It is used by the tests and nowhere else: at runtime the library and the command start no process at all.

Features

What it gives you

fsforge is a library first, with a thin command on top. Everything below follows from one decision: produce the bytes yourself, rather than drive the tool that would.

  • No root, no cgo, no subprocess

    The library links no C and starts no process. Building an ext4 image is ordinary user code — which is what makes it work inside an unprivileged CI container, and on a laptop that is not running Linux.

  • Zero third-party dependencies

    The module imports the standard library and nothing else: go.mod carries no require block and there is no go.sum. Nothing transitive to vendor, audit or keep up to date.

  • Streamed, never buffered

    Only the metadata tree lives in memory. File contents are read through an io.ReaderAt while the image is laid out, so image size is bounded by the disk rather than by RAM.

  • Convert through one tree

    Every engine loads into, and lays out from, the same logical tree. An OCI image becomes an ext4 root, an ext4 root becomes a squashfs archive, and a host directory is simply another kind of source.

  • Offline, never mounted

    Images are mutated on disk, never live. That removes crash consistency, incremental allocation and transactional recovery in one stroke: a journal is replayed on load, and a fresh empty one is written on finalize.

  • Disks, not only filesystems

    fsforge disk writes a GPT with an ESP and a root partition, each formatted by whichever engine you name. Any output path ending in .qcow2 comes out as a sparse QCOW2, ready for qemu.

  • OCI images, in and out

    Flatten an OCI layout into a tree, or stack another source onto an existing image as a new layer — additively, or as a delta carrying whiteouts for what the new tree removed.

  • Checked by the real tools

    The conformance suite hands every image to the tool that owns the format — e2fsck, unsquashfs, fsck.erofs, xorriso, qemu-img — and to the kernel wherever mounting one is possible.

How it works

One tree, one deterministic layout

Creating an image and mutating one are the same pipeline, not two engines. Create starts from an empty tree; mutate parses an existing image lazily into that same tree.

  • One model, every format

    A filesystem is modelled as a single tree of inodes, and each engine lays that tree out on disk its own way. Finalize is a pure function of the tree, the allocator and the environment — which is exactly what makes it deterministic.

  • Environment and policy are injected

    Block IO, the clock, identifiers, allocation and compression all arrive through interfaces, so an engine can be unit-tested against fakes. What the format mandates — on-disk struct encoding, crc32c, the half_md4 htree hash — does not: hiding that behind an interface buys nothing and adds risk.

  • Nothing is ever mounted

    Because an image is never live while fsforge touches it, an engine may repack and defragment freely, and the hardest parts of a filesystem writer simply do not arise.

The stack

  • L5fsforgeBuilder, Convert, EngineFor — wiring, and no format logic
  • L4pkg/imageImage, Dir, File, Filesystem, Deps
  • L3pkg/treeInode, Dirent, Meta, Source
  • L2pkg/ext · pkg/squashfs · …one write target each
  • L1pkg/partition · pkg/qcow2partition tables, and the disk image around them
  • L0pkg/deviceDevice and Discarder, over memory, a file or a section

Dependencies point strictly downward, and pkg/device and pkg/tree depend on nothing else in the module — which is what keeps the graph acyclic and every engine mockable.

Reproducibility

The same inputs, the same bytes

There is no reproducible mode inside an engine. A reproducible build is one wired with a fixed clock and a fixed UUID source; the engine is identical either way, and Reproducible — or -reproducible — is that wiring choice and nothing more.

  • Timestamps fixed at the epoch you pass. SourceDateEpoch() reads SOURCE_DATE_EPOCH for you.
  • The filesystem UUID zeroed rather than drawn at random.
  • A deterministic bitmap allocator, so the same tree always lands on the same blocks.

The counterpart is Host(), which wires the system clock and random UUIDs. Same engine, different environment — which is the whole of the difference.

shell
$ SOURCE_DATE_EPOCH=0 fsforge mkfs -type ext4 \
    -source ./rootfs -size 256M -reproducible \
    -output a.img
$ SOURCE_DATE_EPOCH=0 fsforge mkfs -type ext4 \
    -source ./rootfs -size 256M -reproducible \
    -output b.img
$ sha256sum a.img b.img
4c8a1f…2b7e  a.img
4c8a1f…2b7e  b.img

Two builds of the same tree, an hour apart, on two machines. sha256sum is the entire test.

The library

Two lines for the simple case, all of it for the rest

The fsforge package is a facade: it wires the layers below it and holds no format logic of its own. Everything it uses is exported, so you can drop a level the moment the Builder stops fitting.

The Builder

Pick a filesystem type, point it at a directory, get an image. The configuration chains, and BuildFromDir streams the contents rather than reading them in.

Go
err := fsforge.New("ext4").
	Reproducible(fsforge.SourceDateEpoch()). // SOURCE_DATE_EPOCH
	Size("256M").
	Label("root").
	BuildFromDir("./rootfs", "root.img")

Convert

A source and a sink, each a Kind and a Path. dir, ext2, ext4, squashfs, erofs, exfat, iso, cpio, udf, cramfs, romfs and oci all serve as either, wherever the engine can load.

Go
// An OCI image directory into an ext4 root filesystem.
err := fsforge.Convert(
	fsforge.Location{Kind: "oci", Path: "./alpine-oci"},
	fsforge.Location{Kind: "ext4", Path: "rootfs.img"},
	fsforge.Options{Size: "256M"},
)

The building blocks

EngineFor selects an engine, PopulateFromDir and Graft fill an image tree, ExtractToDir writes one back out to the host, and HostDeps / ReproducibleDeps decide which clock and UUID source get wired in.

Go
// The steps the Builder runs, wired by hand.
eng, err := fsforge.EngineFor("squashfs", fsforge.HostDeps(), 0)
img, err := eng.Format(dev, image.Params{Label: "data"})

closer, err := fsforge.PopulateFromDir(img.Root(), "./rootfs")
defer closer.Close()

err = img.Finalize()

The CLI

A thin command over the same library

The fsforge command does nothing the library cannot: it parses flags and calls the facade. Four verbs cover it.

mkfs

A directory in, an image out. -size is required for the fixed-size targets and ignored by the ones sized from their input.

shell
# An ext4 image from a directory.
fsforge mkfs -type ext4 -source ./rootfs \
  -output root.img -size 256M

# A squashfs archive, sized from its input and trimmed.
fsforge mkfs -type squashfs -source ./rootfs \
  -output rootfs.sqfs

# Deterministic output: fixed timestamps and a zeroed UUID.
SOURCE_DATE_EPOCH=0 fsforge mkfs -type ext4 \
  -source ./rootfs -output root.img \
  -size 256M -reproducible

convert

From any loadable source to any writable sink, through the shared tree. dir is a kind like the others, in both directions.

shell
# An OCI image into a squashfs archive.
fsforge convert -from oci:./alpine-oci \
  -to squashfs:rootfs.sqfs

# And back out to a host directory — no mount, no loop device.
fsforge convert -from ext4:root.img \
  -to dir:./extracted

# A directory into an OCI layout, tagged.
fsforge convert -from dir:./rootfs \
  -to oci:./image-oci -ref app:v1

disk

A GPT disk carrying one or more engine-formatted partitions. A partition size is absolute, or rest for whatever is left.

shell
# A bootable disk: an ESP in FAT32, then an ext4 root.
fsforge disk -output disk.img -size 512M \
  -part esp:fat:./esp:64M \
  -part root:ext4:./rootfs:rest

# The same thing as a sparse QCOW2, ready for qemu.
fsforge disk -output vm.qcow2 -size 2G \
  -part root:ext4:./rootfs:rest

oci-add-layer

Stack another source onto an existing OCI image, additively or as a delta whose whiteouts record what the new tree removed.

shell
# Add a patch directory as a new layer on app:v1.
fsforge oci-add-layer -image ./image-oci \
  -ref app:v1 -from ./patch

# Or a delta against whatever the image already holds.
fsforge oci-add-layer -image ./image-oci \
  -from dir:./newroot -diff

Run fsforge help for the full flag reference. Any output path ending in .qcow2 writes a QCOW2 container, whichever verb produced it.

Get started

One command, then an image

Requires Go 1.24 or newer, and nothing else: no library to link, no tool on the PATH, no privilege to ask for.

  1. Add it to a module

    The whole library is one import. go.sum stays empty, because there is nothing to record in it.

    go get github.com/emmanuel-deloget/fsforge
  2. Or install the command

    One binary, and fsforge help prints every flag it takes.

    go install github.com/emmanuel-deloget/fsforge/cmd/fsforge@latest
  3. Build an image

    Then hand it to e2fsck -fn or mount it — which is what the conformance suite does with it.

    fsforge mkfs -type ext4 -source ./rootfs -output root.img -size 256M

MIT licence. Issues and pull requests welcome — CI runs the pure-Go suite on every push, and the conformance suite against the real tools.