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.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.fsforgespec-source./rootfs-outputrootfs.mtree# Say what the image needs instead.$EDITORrootfs.mtree# Lay it back over the tree at build time.fsforgemkfs-typeext4-source./rootfs\-specrootfs.mtree-size256M-outputroot.img
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.
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.
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.
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 — 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")defercloser.Close()err=img.Finalize()
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.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, and docker names a registry reference rather than a path.
shell
# A registry image, straight into an ext4 root. No daemon.fsforgeconvert-fromdocker://alpine:3.20\-toext4:rootfs.img-size256M# 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
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.