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.
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.
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.
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.
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")defercloser.Close()err=img.Finalize()
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.fsforgemkfs-typeext4-source./rootfs\-outputroot.img-size256M# A squashfs archive, sized from its input and trimmed.fsforgemkfs-typesquashfs-source./rootfs\-outputrootfs.sqfs# Deterministic output: fixed timestamps and a zeroed UUID.SOURCE_DATE_EPOCH=0fsforgemkfs-typeext4\-source./rootfs-outputroot.img\-size256M-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.fsforgeconvert-fromoci:./alpine-oci\-tosquashfs:rootfs.sqfs# And back out to a host directory — no mount, no loop device.fsforgeconvert-fromext4:root.img\-todir:./extracted# A directory into an OCI layout, tagged.fsforgeconvert-fromdir:./rootfs\-tooci:./image-oci-refapp: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.fsforgedisk-outputdisk.img-size512M\-partesp:fat:./esp:64M\-partroot:ext4:./rootfs:rest# The same thing as a sparse QCOW2, ready for qemu.fsforgedisk-outputvm.qcow2-size2G\-partroot: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.fsforgeoci-add-layer-image./image-oci\-refapp:v1-from./patch# Or a delta against whatever the image already holds.fsforgeoci-add-layer-image./image-oci\-fromdir:./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.