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.26+ · 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.

  • Extended attributes, stored

    ext, squashfs and EROFS write security.capability and security.selinux, and read them back. Converting an OCI image used to drop them without a word — which is the difference between a rootfs that boots enforcing and one that does not.

  • Streamed, never buffered

    Only the metadata tree lives in memory; contents are read through an io.ReaderAt while the image is laid out, and never held. So the heap follows the number of entries — roughly a kilobyte per file — and not the size of the image: pushing more bytes through a given tree costs nothing more.

  • 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. Entry names out of an image you did not build are validated before they reach a host path.

  • Straight from a registry

    -from docker://alpine:3.20 pulls the image itself: no daemon, no docker pull, no local layout to prepare, and every blob checked against the digest that named it. It is the only package that opens a socket — a build that never names a registry never touches the network.

  • 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.

  • Five lines in a workflow

    The GitHub Action builds an image in unprivileged CI: no privileged container, no loop device, no sudo. It reports the result’s path, size and sha256, so a later step can attest or publish it without rehashing.

  • Checked by the real tools

    Every image goes to the tool that owns the format — e2fsck, unsquashfs, fsck.erofs, xorriso — and to the kernel wherever mounting one is possible. Then differential tests diff a generated tree against what came back, field by field: a writer and a reader that share one misreading agree with each other perfectly.

Ownership

Without root, for real

A checkout is owned by whoever cloned it, holds no device nodes, and loses setuid bits on most CI filesystems. So state those separately, in mtree(5) — the format BSD, Yocto and Buildroot already use.

Three commands

shell
# Describe what the checkout actually holds.
fsforge spec -source ./rootfs -output rootfs.mtree

# Say what the image needs instead.
$EDITOR rootfs.mtree

# Lay it back over the tree at build time.
fsforge mkfs -type ext4 -source ./rootfs \
  -spec rootfs.mtree -size 256M -output root.img

The facts a directory cannot hold

rootfs.mtree
/set uid=0 gid=0
./bin/ping     type=file mode=4755
./dev/console  type=char mode=0600 device=native,5,1
./tmp          type=dir  mode=01777
./var/run      type=link link=../run

The files come from the directory; the facts about them come from a file you keep in the repository beside it — and nothing in that build needs a privilege. In the library it is Builder.Spec, applied between populate and finalize: the one window where something a checkout could not carry can still be added.

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.

Memory

The tree is what costs

Memory follows the tree being built, and stops when the tree is done. Everything after that is contents streaming through — three and a half gigabytes of them here, at no further cost.

Memory held and bytes written while fsforge builds a ext4 imageTwo stacked plots sharing a time axis. Memory held rises to 44 MiB while the tree is built, then stays flat as 3.36 GiB are written.0012.5125237.5350 MiB4 GiB00.81.62.43.4tree builtlarge filesmemory heldbytes writtenheld: 41 MiB44 MiB held while 3.4 GiB written · 910 B per fileext4 · 50 452 files, 3.2 GiB · Intel Core i7-8850H, 12 cores · linux go1.26.5
ext4, 50 452 files: the steps on the left are the tree being built, one directory at a time; the flat run on the right is Finalize streaming contents through tree.Source, which is where the image is actually written. The plateau does not move while the bytes written triple.
  • one 2 GiB file400 KiB
  • 50 452 files, 3.4 GiB44 MiB

Both figures are true, and neither is the whole story: memory tracks the number of files, not their volume. A single enormous file costs almost nothing; a kernel tree costs about a kilobyte an entry whatever its size. TestStreamingKeepsMemoryBounded pins the first case and fails if streaming stops. Draw the chart on your own corpus with go run ./internal/profile -svg doc/profile.svg.

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 — and docker names a registry reference, pulled as the build reads it.

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. Six verbs, and these four are the ones that build something.

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, and docker names a registry reference rather than a path.

shell
# A registry image, straight into an ext4 root. No daemon.
fsforge convert -from docker://alpine:3.20 \
  -to ext4:rootfs.img -size 256M

# 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

The other two are fsforge spec, which writes out the ownership a checkout cannot hold, and fsforge version. Run fsforge help for the full flag reference — and note that any output path ending in .qcow2 writes a QCOW2 container, whichever verb produced it.

Get started

One command, then an image

Requires Go 1.26 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.