Shared, minimal filesystem interfaces used by the concrete filesystem implementations in this repository.
github.com/go-filesystems/interface
This package defines a small, stable contract that filesystem drivers implement so higher-level tools can operate on different filesystem images without depending on concrete types. The interface intentionally focuses on common file and directory operations needed by tooling and tests.
Filesystem— minimal filesystem API implemented by concrete packages:
type Filesystem interface {
Close() error
ReadFile(path string) ([]byte, error)
ListDir(path string) ([]DirEntry, error)
Stat(path string) (Stat, error)
WriteFile(path string, data []byte, perm os.FileMode) error
ReadLink(path string) (string, error)
MkDir(path string, perm os.FileMode) error
DeleteFile(path string) error
DeleteDir(path string) error
Rename(oldPath, newPath string) error
}Labeller— optional interface for filesystems that expose a volume label. Implementations cap the label at their own length limit (ext2/3/4: 16 bytes; FAT: 11 bytes). Probe via type assertion; not part ofFilesystembecause some filesystems genuinely have no label concept or where label mutation is non-trivial.
type Labeller interface {
Label() string
SetLabel(label string) error
}
if l, ok := fs.(filesystem.Labeller); ok {
l.SetLabel("rootfs")
}LabelReader— optional read-only counterpart toLabeller, for drivers that can decode a volume label but can't yet rewrite it through their regular commit machinery. EveryLabellerembeds this, so type-assertLabelReaderwhen only reading is needed:
type LabelReader interface {
Label() string
}Symlinker— optional interface for creating symbolic links (ReadLinkis already part ofFilesystem; this gates the write side):
type Symlinker interface {
Symlink(target, linkPath string) error
}HardLinker— optional interface for POSIX hardlinks (directories cannot be hardlinked; implementations must reject that case):
type HardLinker interface {
Link(oldPath, newPath string) error
}MetadataSetter— optional interface bundling the POSIX metadata mutators (chmod / chown / utimes):
type MetadataSetter interface {
Chmod(path string, perm os.FileMode) error
Chown(path string, uid, gid uint32) error
Chtimes(path string, atime, mtime time.Time) error
}TimesReader— optional read side of the timestampsChtimeswrites. One call returns all four (Atime,Mtime,Ctime,Btime) asTimestampvalues. A time the format does not keep is the zeroTimestamp, never a zerotime.Time:Get()returns(time, ok)andOr(fallback)makes the caller name the substitute.Resolution()reports the format's unit (FAT mtime 2 s, atime 1 day, HFS+ 1 s, NTFS 100 ns, ext4 1 ns) andSamecompares at the coarser of two:
type TimesReader interface {
Times(path string) (FileTimes, error)
}
if tr, ok := fs.(filesystem.TimesReader); ok {
ft, _ := tr.Times(path)
mtime := ft.Mtime.Or(time.Now())
}Truncater— optional interface for resizing a regular file in place (grow zero-fills, shrink drops trailing data):
type Truncater interface {
Truncate(path string, newSize int64) error
}Grower/Resizer— optional interfaces for changing a filesystem's on-disk size.Grower.GrowTois grow-only;Resizer.Resizeis the newer, uniform entry point that also handles shrink where the format allows it, returning the sentinelErrShrinkUnsupportedwhen it doesn't:
type Grower interface {
GrowTo(newSizeBytes int64) error
}
type Resizer interface {
Resize(newSize int64) error
}
var ErrShrinkUnsupported = errors.New("filesystem: shrink not supported")
if r, ok := fs.(filesystem.Resizer); ok {
if err := r.Resize(newSize); errors.Is(err, filesystem.ErrShrinkUnsupported) {
// driver only grows — caller decides how to handle
}
}Opener/File— optional interface for reading part of a file without materialising the whole thing.Filesystem.ReadFileis per-path and returns the entire file, which no mount or network export can build on: serving a 4 KiB request out of a 4 GiB file would allocate 4 GiB.Openeris the capability that makes those callers possible, and it is optional and non-breaking — probe for it, fall back toReadFilewhen a driver lacks it:
type Opener interface {
OpenFile(path string) (File, error)
}
type File interface {
io.ReaderAt
io.Closer
Size() int64
}
if o, ok := fs.(filesystem.Opener); ok {
f, err := o.OpenFile("/big.img")
if err != nil {
return err
}
defer f.Close()
n, err := f.ReadAt(buf, off) // only the bytes asked for
_, _ = n, err
} else {
data, err := fs.ReadFile("/big.img") // fallback: the whole file
_, _ = data, err
}ReadAt follows io.ReaderAt to the letter: n < len(p) only ever comes
back with a non-nil error, end of file reports io.EOF, and an offset at or
past Size() returns 0, io.EOF. Anything looser breaks io.SectionReader
and every consumer built on it, silently. Reads are safe to issue
concurrently on one File, as io.ReaderAt requires. Size() comes from
metadata already read at open time, so a caller can answer a stat without
touching the data.
A driver whose format genuinely cannot answer a byte range without decoding
everything before it should simply not implement Opener, rather than
emulate it with a hidden full read — the probe exists so the caller learns
the truth.
WritableFile— the write-side twin ofOpener/File: the optional upgrade of aFile, for a driver that can write at an offset in place.Filesystem's only write isWriteFile, which replaces the whole file, so a caller handed a write at a non-zero offset must read the file, splice, and write it all back — O(filesize) per request, and O(n²) for a client streaming a file in blocks. Measured, not supposed: a 2 MiB sequential write over a real NFS mount in 64 KiB blocks took 23 s (90 kB/s), and asoftmount gave up withEIOpartway through.
type WritableFile interface {
File // io.ReaderAt + io.Closer + Size() int64
io.WriterAt
Truncate(size int64) error
Sync() error
}
f, err := o.OpenFile(path)
if err != nil {
return err
}
defer f.Close()
if w, ok := f.(filesystem.WritableFile); ok {
if _, err := w.WriteAt(p, off); err != nil { // only the bytes given
return err
}
return w.Sync()
}
// otherwise: ReadFile, splice, WriteFile — correct, and quadratic.The probe is on the File, not on the Filesystem, because writability
is a property of the opened object. A read-only driver (iso9660,
squashfs) simply returns a plain File and nothing breaks: Filesystem,
Opener and File are all unchanged.
WriteAt follows io.WriterAt to the letter: it writes len(p) bytes
or returns a non-nil error — never a short write with a nil error, which a
caller reads as success. Whether it may extend the file is the driver's to
state, and each one states it; a driver that cannot extend refuses the write
rather than writing short. Concurrent WriteAt calls are safe provided
their ranges do not overlap, which is io.WriterAt's own wording.
Size() on a WritableFile is not the frozen snapshot File describes:
it reflects this handle's own writes, so it follows an extending WriteAt or
a Truncate with no I/O and no reopen.
Sync() exists because a network server must be able to answer "it is on
the medium" as an event distinct from "I accepted the write" — NFSv3
COMMIT (RFC 1813 §3.21) and SFTP's fsync@openssh.com both demand exactly
that answer. A driver whose Sync guarantees nothing must say so, rather
than return nil and let the caller promise durability it has not got.
HostFile— the optional capability of aFilethat is a file of the host: itsRead,SeekandSyscallConnare those of a host descriptor holding exactly this file's bytes. It lets a server send the file without copying it through user space:*net.TCPConn'sReadFromcallssendfile(2)for asyscall.Connsource, which is whatnet/httpdoes with a response body.go-filesystems/osfsreturns one; an image driver does not.
type HostFile interface {
File
io.ReadSeeker // the descriptor's own position
syscall.Conn // the descriptor
HostFile() // the promise, made on purpose
}HostFile() exists only to be implemented on purpose. Read, Seek and
SyscallConn alone cannot tell: a driver whose File embeds the
*os.File of its image would have all three, and sending from that
descriptor would send the image's bytes. Read and Seek share one
position, so they are for a handle one goroutine reads from start to end;
ReadAt keeps File's concurrency terms.
DirEntry— accessor interface for directory entries:
type DirEntry interface {
Inode() uint64
Name() string
FileType() uint8
}Stat— file metadata accessor:
type Stat interface {
Mode() uint16
Size() uint64
Inode() uint64
}Constructors NewDirEntry(inode, name, fileType) and NewStat(mode,size,inode) are
provided for convenience.
Known implementations, across the go-filesystems org:
github.com/go-filesystems/apfsgithub.com/go-filesystems/btrfsgithub.com/go-filesystems/exfatgithub.com/go-filesystems/ext4github.com/go-filesystems/fat32github.com/go-filesystems/ffs(re-export ofufs)github.com/go-filesystems/hfsplusgithub.com/go-filesystems/iso9660github.com/go-filesystems/ntfsgithub.com/go-filesystems/ocigithub.com/go-filesystems/squashfsgithub.com/go-filesystems/uefigithub.com/go-filesystems/ufsgithub.com/go-filesystems/xfsgithub.com/go-filesystems/zfs
See each implementor's README for format-specific details, examples, and
which optional interfaces above it satisfies; or see
go-filesystems.github.io/docs/drivers
for a capability matrix. github.com/go-filesystems/detect composes over
this interface too, as a type-probing dispatch registry rather than a driver.
The interface can be used as a programming abstraction so callers accept a
filesystem.Filesystem regardless of the concrete implementation:
import (
filesystem "github.com/go-filesystems/interface"
fsx "github.com/go-filesystems/xfs"
)
func example() error {
f, err := fsx.Open("image.img", -1)
if err != nil {
return err
}
defer f.Close()
// Use as the generic interface
var fs filesystem.Filesystem = f
data, err := fs.ReadFile("/hello.txt")
if err != nil {
return err
}
_ = data
return nil
}Keep the interface minimal; add helpers in implementor packages when format-specific functionality is required.
