hfsplus

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 6, 2026 License: BSD-3-Clause Imports: 11 Imported by: 0

README

go-filesystems/hfsplus

hfsplus

Go Reference CI

Pure-Go, CGO-free read/write driver for the HFS+ (Mac OS Extended) on-disk format and its HFSX (case-sensitive) variant — part of the go-filesystems family.

HFS+ is the filesystem Apple shipped on Macs from Mac OS 8.1 through the move to APFS. It stores all metadata big-endian: a volume header at byte offset 1024, a catalog B-tree keyed by (parent CNID, UTF-16 name) for the directory hierarchy, an extents-overflow B-tree for fragmented files, and per-fork allocation-block extents for file contents. This driver decodes and writes those structures and exposes the volume through the shared interface Filesystem API.

Both paths are validated against the native macOS tooling. The pure-Go formatter (Format / Mkfs) and write path produce images that pass fsck_hfs -n clean and that macOS mounts read/write, reading back the exact files and bytes the Go side wrote — verified in both directions (Go-formatted → macOS-read, and hdiutil-created → Go-written → macOS-read). A small (≈7 KB gzipped) raw fixture is committed so the cross-arch round-trip runs everywhere — including the big-endian s390x CI job, the endianness correctness test.

Support summary

Feature Status Notes
Open / OpenFile / Close Read-only; volume header at offset 1024; H+ (HFS+) and HX (HFSX) signatures
OpenWritable / OpenFileWritable / Sync Read/write; image held in memory, mutated in place, flushed by Sync
ListDir / Stat / ReadFile / ReadLink Catalog walk; BSD perms; data fork via 8 inline extents + extents-overflow; S_IFLNK targets
Format / Mkfs (pure Go) Lays down a valid empty HFS+/HFSX volume on every arch; fsck_hfs -n clean + macOS mounts RW
WriteFile Allocates blocks, writes the data fork, inserts catalog file + thread records
MkDir / DeleteFile / DeleteDir / Rename Catalog insert/delete with B-tree node splitting + tree-height growth; valence/freeBlocks kept in sync
SetLabel / Symlink / Truncate Optional Labeller / Symlinker / Truncater capabilities
Case-insensitive names Apple FastUnicodeCompare over the practical character set incl. ignorable-NUL (documented subset of the full fold table)
Case-sensitive (HFSX) Binary UTF-16 key comparison; HasFolderCount maintained
FormatAppleDmg (alt) ⚠️ Optional darwin-only escape hatch that shells to hdiutil; off-darwin returns ErrUnsupported. The primary Format is pure-Go.

Status

Implemented and validated against real macOS images (read + write):

  • Volume header decode/encode (H+ / HX, primary + alternate, block counts, special-file fork descriptors, clean-unmount attributes).
  • Generic HFS+ B-tree node read/write (descriptor + trailing record-offset table) shared by the catalog and extents-overflow trees.
  • Catalog & extents-overflow B-trees: index descent, leaf scan, insert/delete with leaf and index node splitting, index-record propagation, new-root growth, node-underflow rebalancing (rotate/merge), emptied-node freeing, and tree-height shrink; folder/file/thread records; valence and HasFolderCount maintenance.
  • Growable B-tree files — when a tree exhausts its node reservation it allocates more blocks, extends its backing fork's extents (inline, then spilling into the extents-overflow tree), grows the node-allocation bitmap (adding map nodes), and updates the header-node totalNodes/freeNodes and the volume-header special-file fork descriptor + freeBlocks.
  • Allocation bitmap allocate/free of contiguous runs and multi-fragment allocations with freeBlocks sync.
  • Pure-Go Format/Mkfs; WriteFile/MkDir/DeleteFile/DeleteDir/Rename; SetLabel/Symlink/Truncate.
  • File read/write: data-fork inline extents plus extents-overflow continuation for both reading and writing fragmented forks — a written file needing more than eight extents fills the inline descriptors and inserts the remainder into the extents-overflow B-tree, round-tripping exactly.

Out of scope (as with the btrfs/xfs siblings — these are deliberate non-goals, not partial implementations):

  • Journaling — the HFS+ journal is not written/replayed (volumes are authored cleanly-unmounted).
  • Hardlinks / compression / resource forks / xattrs — indirect-node hardlinks, decmpfs compression, resource forks, and the Attributes B-tree are not handled (compression is detected and rejected rather than returning wrong bytes).

Module

github.com/go-filesystems/hfsplus

Install

go get github.com/go-filesystems/hfsplus

Usage

package main

import (
	"fmt"

	"github.com/go-filesystems/hfsplus"
)

func main() {
	// Format a fresh volume in pure Go and write into it.
	fs, err := hfsplus.Format("disk.hfs", 16<<20, hfsplus.FormatConfig{Label: "DATA"})
	if err != nil {
		panic(err)
	}
	_ = fs.MkDir("/sub", 0o755)
	_ = fs.WriteFile("/sub/hello.txt", []byte("hello\n"), 0o644)
	fs.Close() // flushes to disk.hfs

	// Reopen read-only and read it back.
	v, _ := hfsplus.OpenFile("disk.hfs")
	defer v.Close()
	entries, _ := v.ListDir("/sub")
	for _, e := range entries {
		fmt.Println(e.Name(), e.FileType())
	}
	data, _ := v.ReadFile("/sub/hello.txt")
	fmt.Printf("%d bytes\n", len(data))
}

Open(io.ReaderAt, size) / OpenWritable([]byte, io.WriterAt) are also available when you already hold the image bytes (e.g. a decompressed DMG in memory). The returned *Volume implements github.com/go-filesystems/interface.Filesystem plus the optional Labeller / Symlinker / Truncater capabilities.

API

Function / method Purpose
Open(rs io.ReaderAt, size int64) (*Volume, error) Parse an HFS+/HFSX volume read-only from a ReaderAt
OpenFile(path string) (*Volume, error) Open an image file read-only
OpenWritable(img []byte, wa io.WriterAt) (*Volume, error) Open an in-memory image read/write
OpenFileWritable(path string) (*Volume, error) Open an image file read/write (mutations flushed by Sync)
Mkfs(size int64, cfg FormatConfig) ([]byte, error) Build a fresh empty HFS+/HFSX image in pure Go
Format(path string, size int64, cfg FormatConfig) (filesystem.Filesystem, error) Pure-Go format + open read/write
FormatAppleDmg(path, size, cfg) Optional darwin-only hdiutil alternative
(*Volume) WriteFile / MkDir / DeleteFile / DeleteDir / Rename Mutate the volume
(*Volume) SetLabel / Symlink / Truncate Optional Labeller / Symlinker / Truncater capabilities
(*Volume) Sync() error Flush the in-memory image to the backing store
(*Volume) ListDir / ReadFile / Stat / ReadLink Read paths
(*Volume) CaseSensitive() bool Report HFSX binary key comparison

Validation

The committed read-path fixture (testdata/hfsplus.dmg.gz) was produced on macOS with hdiutil create -fs "HFS+" and populated with known files; the reader lists those entries and reads each back with the exact MD5 macOS computed (also the big-endian s390x correctness test).

The write path is validated by darwin-only tests (macos_validation_test.go) that exercise the native tooling:

# Pure-Go format → fsck clean + macOS reads the bytes Go wrote
go test -run TestDarwinWriteRoundTrip
# hdiutil-created image → Go writes into it → fsck clean + macOS reads it
go test -run TestDarwinWriteIntoAppleImage

Each test formats/writes with the pure-Go driver, attaches the raw image with hdiutil, asserts fsck_hfs -n reports the volume clean, then mounts it read/write and compares the files and bytes macOS sees against what Go wrote. The cross-arch pure-Go round-trip (format → write → read) in write_test.go runs on every architecture, including big-endian s390x.

References

  • Apple TN1150, HFS Plus Volume Format.
  • man hdiutil, man fsck_hfs, man newfs_hfs.

License

BSD-3-Clause © the go-filesystems authors.

Documentation

Overview

Package hfsplus is a pure-Go, CGO-free read/write driver for the HFS+ (Mac OS Extended) on-disk format, including its HFSX (case-sensitive) variant.

HFS+ stores every multi-byte field big-endian. The volume header lives at byte offset 1024 and carries the block size, block counts, and the special fork descriptors for the catalog, extents-overflow, and allocation files. File and directory metadata live in the catalog B-tree, keyed by the parent CNID plus the UTF-16 node name; file contents are addressed by allocation blocks via up to eight inline extents per fork, spilling into the extents-overflow B-tree for fragmented files.

The package implements the full shared github.com/go-filesystems/interface Filesystem contract:

  • Open / OpenFile open a volume read-only.
  • OpenWritable / OpenFileWritable open it for read/write; the whole image is held in memory, mutated in place, and flushed by Sync.
  • Format (and the lower-level Mkfs) lay down a fresh, empty HFS+/HFSX volume in pure Go — no host tooling — that passes fsck_hfs -n clean and mounts read/write on macOS.
  • WriteFile, MkDir, DeleteFile, DeleteDir, Rename mutate the catalog B-tree (insert/delete with node splitting and tree-height growth), manage the allocation bitmap, and keep the volume-header counters in sync. The optional Labeller (SetLabel), Symlinker (Symlink), and Truncater (Truncate) capabilities are implemented too.

Every write path is validated against the native macOS tooling: fsck_hfs -n reports the volume clean and macOS mounts the image read/write and reads the exact files and bytes the Go side wrote, in both directions (Go-formatted → macOS-read and macOS-created → Go-written → macOS-read). The cross-arch, big-endian (s390x) round-trip runs in pure Go on every architecture.

Case-folding: case-insensitive name comparison implements Apple's FastUnicodeCompare for the practical character set (ASCII, Latin-1, Latin Extended-A, and the ignorable-NUL handling fsck requires) rather than embedding the full 8 KiB fold table; exotic case-folding corner cases outside that range fall back to identity ordering.

Out of scope (deliberate non-goals, as with the btrfs/xfs siblings):

  • Journaling, decmpfs compression, resource forks, and indirect-node hardlink following are not implemented; see the README Status section.

Fragmented data forks (more than eight extents), catalog and extents-overflow B-tree growth, and node-underflow rebalancing/merging on delete ARE implemented: a written fork fills its eight inline extents and spills the remainder into the extents-overflow tree, the B-tree files grow their backing forks when their node reservation is exhausted, and deletion rebalances/merges underflowing nodes and frees emptied ones.

Index

Constants

View Source
const (
	// FinderFlagHasCustomIcon marks a folder or volume as carrying its own
	// icon — for a volume root, that is what makes the Finder look for
	// .VolumeIcon.icns and draw it instead of the generic disk. Writing the
	// file without the flag does nothing at all.
	FinderFlagHasCustomIcon = uint16(0x0400)

	// FinderFlagIsInvisible hides an entry, which is how a disk image keeps
	// its .background folder out of the window it decorates.
	FinderFlagIsInvisible = uint16(0x4000)
)

Finder flags worth naming. The rest are documented in TN1150 and in CarbonCore/Finder.h; these are the ones a disk-image builder needs.

View Source
const (

	// FinderFlagsOffset is where the flags word lives inside the 32 bytes
	// FinderInfo returns: DInfo.frFlags for a folder, FInfo.fdFlags for a
	// file. Big-endian, like everything else in HFS+.
	FinderFlagsOffset = 8
)

The Finder's own 32 bytes, which every catalog record carries and which nothing here could read or write until now.

HFS+ (Apple TN1150) puts them at the same place in both record kinds: a 16-byte userInfo at record offset 48 followed by a 16-byte finderInfo at 64. For a file those are FInfo/FXInfo, for a folder DInfo/DXInfo, and the two differ in what the first eight bytes mean — but the FLAGS word sits at +8 of userInfo either way, so it is addressable without knowing which kind a record is.

Variables

View Source
var (
	// ErrReadOnly is returned by every mutating method when the volume was
	// opened read-only (Open / OpenFile). Open it writable (OpenWritable /
	// OpenFileWritable / Format) to mutate it.
	ErrReadOnly = errors.New("hfsplus: filesystem is read-only")

	// ErrBadHeader is returned when the volume header at offset 1024 lacks a
	// recognized HFS+ ("H+") or HFSX ("HX") signature.
	ErrBadHeader = errors.New("hfsplus: no valid volume header")

	// ErrNotFound is returned when a path component cannot be located in the
	// catalog.
	ErrNotFound = errors.New("hfsplus: path not found")

	// ErrNotDirectory is returned when ListDir targets a non-directory.
	ErrNotDirectory = errors.New("hfsplus: not a directory")

	// ErrNotRegular is returned when ReadFile targets a non-regular file.
	ErrNotRegular = errors.New("hfsplus: not a regular file")

	// ErrNotSymlink is returned by ReadLink when the target is not a symlink.
	ErrNotSymlink = errors.New("hfsplus: not a symbolic link")

	// ErrCorrupt is returned when an on-disk structure fails a sanity check.
	ErrCorrupt = errors.New("hfsplus: corrupt image")

	// ErrUnsupported is returned for on-disk features the driver does not yet
	// decode/encode (e.g. compressed forks).
	ErrUnsupported = errors.New("hfsplus: unsupported feature")

	// ErrNoSpace is returned by the write path when the volume has no free
	// allocation blocks (or no contiguous run) to satisfy a request.
	ErrNoSpace = errors.New("hfsplus: no space left on volume")

	// ErrExists is returned by mutators when the target path already exists.
	ErrExists = errors.New("hfsplus: path already exists")

	// ErrNotEmpty is returned by DeleteDir when the directory still has
	// children.
	ErrNotEmpty = errors.New("hfsplus: directory not empty")
)

Sentinel errors. Compare with errors.Is so wrapped errors keep matching.

Functions

func Format

func Format(path string, sizeBytes int64, cfg FormatConfig) (filesystem.Filesystem, error)

Format creates a fresh, empty HFS+ (or HFSX) volume image at path of sizeBytes bytes using the pure-Go formatter (Mkfs), then opens it read/write. Pure Go, CGO-free, big-endian — works on every architecture.

The produced image passes `fsck_hfs -n` clean on macOS and mounts read/write; on every platform Open/OpenWritable round-trip it. The returned Volume is writable: WriteFile/MkDir/DeleteFile/DeleteDir/Rename mutate it and flush back to path.

The signature matches the apfs sibling (Format(path, sizeBytes, cfg)).

func FormatAppleDmg

func FormatAppleDmg(path string, sizeBytes int64, cfg FormatConfig) (filesystem.Filesystem, error)

FormatAppleDmg is the optional darwin-only alternative that shells out to the native hdiutil to author a real HFS+ image (the same tool that produced the read-path fixtures). It is provided as a parity escape hatch alongside the primary pure-Go Format, mirroring the apfs sibling's FormatAppleDmg. On non-darwin platforms it returns ErrUnsupported.

func Mkfs

func Mkfs(sizeBytes int64, cfg FormatConfig) ([]byte, error)

Mkfs lays down a valid empty HFS+/HFSX volume of sizeBytes bytes into a freshly-allocated byte slice and returns it. Pure Go, big-endian, no host tooling — runs on every architecture. The returned image passes fsck_hfs -n on macOS and can be opened with Open/OpenWritable.

Types

type FormatConfig

type FormatConfig struct {
	// Label is the volume name. Defaults to "GOTEST" when empty.
	Label string
	// CaseSensitive requests an HFSX (case-sensitive) volume instead of plain
	// case-insensitive HFS+.
	CaseSensitive bool
}

FormatConfig configures Format/Mkfs.

type Volume

type Volume struct {
	// contains filtered or unexported fields
}

Volume is an opened HFS+ (or HFSX) volume. When opened read-only (Open / OpenFile) the mutating methods return ErrReadOnly. When opened writable (OpenWritable / OpenFileWritable / Format) the whole image is held in an in-memory byte slice that the write path edits in place; Sync (and the mutators, which Sync implicitly) flush the bytes back to the backing io.WriterAt when one is present.

func Open

func Open(rs io.ReaderAt, size int64) (*Volume, error)

Open parses an HFS+ volume from rs. The caller retains ownership of rs unless it implements io.Closer (then Close releases it). Pass size = -1 if unknown.

func OpenFile

func OpenFile(path string) (*Volume, error)

OpenFile opens the image at path read-only.

func OpenFileWritable

func OpenFileWritable(path string) (*Volume, error)

OpenFileWritable opens the image at path for read/write. The whole image is read into memory; mutations are flushed back to the file by Sync (and implicitly by every mutator).

func OpenWritable

func OpenWritable(img []byte, wa io.WriterAt) (*Volume, error)

OpenWritable opens an HFS+ image held entirely in img for read/write. The volume edits img in place; callers can retrieve the mutated bytes with Bytes() or, if wa is non-nil, flush them with Sync. Pass wa = nil for a purely in-memory writable volume.

func (*Volume) Bytes

func (v *Volume) Bytes() []byte

Bytes returns the current (possibly mutated) image bytes for a writable volume, or nil for a read-only one. The slice aliases the volume's internal buffer; copy it if you need a stable snapshot.

func (*Volume) CaseSensitive

func (v *Volume) CaseSensitive() bool

CaseSensitive reports whether the volume is HFSX with binary key comparison.

func (*Volume) Close

func (v *Volume) Close() error

Close releases the backing handle if Volume opened one.

func (*Volume) DeleteDir

func (v *Volume) DeleteDir(p string) error

DeleteDir removes the empty directory at path.

func (*Volume) DeleteFile

func (v *Volume) DeleteFile(p string) error

DeleteFile removes the regular file (or symlink) at path.

func (*Volume) FinderInfo added in v0.2.0

func (v *Volume) FinderInfo(p string) ([finderInfoLen]byte, error)

FinderInfo returns the 32 Finder bytes of the entry at p.

p may be "/" for the volume root, which is the case that matters for a volume icon.

func (*Volume) Label

func (v *Volume) Label() string

Label returns the volume label (the root folder's name in the catalog).

func (*Volume) ListDir

func (v *Volume) ListDir(path string) ([]filesystem.DirEntry, error)

ListDir enumerates the directory at path.

func (*Volume) MkDir

func (v *Volume) MkDir(p string, perm os.FileMode) error

MkDir creates a directory at path.

func (*Volume) ReadFile

func (v *Volume) ReadFile(path string) ([]byte, error)

ReadFile returns the full contents of the regular file at path.

func (v *Volume) ReadLink(path string) (string, error)

ReadLink returns the target of a symbolic link. HFS+ stores the target as the data-fork contents of a file whose BSD mode marks it S_IFLNK.

func (*Volume) Rename

func (v *Volume) Rename(oldPath, newPath string) error

Rename moves/renames oldPath to newPath. Both parents must exist; newPath must not already exist. The data fork and CNID are preserved (catalog key change + thread parent/name update).

func (*Volume) SetFinderInfo added in v0.2.0

func (v *Volume) SetFinderInfo(p string, info [finderInfoLen]byte) error

SetFinderInfo replaces the 32 Finder bytes of the entry at p.

The record is re-keyed rather than patched in place, because the catalog is a B-tree and its writer owns node layout; delete-then-insert with the same key is how SetLabel already does it.

func (*Volume) SetLabel

func (v *Volume) SetLabel(label string) error

SetLabel renames the volume. The label lives as the root folder's catalog key (parent=1, name=label); SetLabel rewrites that key (and the root thread name) and is reflected by the reader and by macOS.

func (*Volume) Stat

func (v *Volume) Stat(path string) (filesystem.Stat, error)

Stat resolves path and returns mode, size, and the CNID as a pseudo-inode.

func (v *Volume) Symlink(target, linkPath string) error

Symlink creates a symbolic link at linkPath pointing at target. HFS+ stores the target as the data fork of an S_IFLNK file.

func (*Volume) Sync

func (v *Volume) Sync() error

Sync flushes the in-memory image back to the backing store, if any. It is a no-op for read-only or purely in-memory volumes.

func (*Volume) Truncate

func (v *Volume) Truncate(p string, newSize int64) error

Truncate resizes the regular file at path to newSize bytes. Growing reallocates a larger contiguous run (zero-filled); shrinking reallocates a smaller run. Both rewrite the file's single inline extent.

func (*Volume) VolumeHeader

func (v *Volume) VolumeHeader() *volumeHeader

VolumeHeader exposes the decoded volume header (owned by Volume).

func (*Volume) WriteFile

func (v *Volume) WriteFile(p string, data []byte, perm os.FileMode) error

WriteFile creates or overwrites the regular file at path with data. The data fork is allocated across the volume's free allocation blocks: a single contiguous run when one is available, otherwise multiple fragments. The first numInlineExtents runs are stored in the inline extent descriptors and any remaining runs are inserted into the extents-overflow B-tree, so an arbitrarily fragmented file (more than numInlineExtents extents) round-trips correctly.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL