treewide: introduce osbase package and move things around

All except localregistry moved from metropolis/pkg to osbase,
localregistry moved to metropolis/test as its only used there anyway.

Change-Id: If1a4bf377364bef0ac23169e1b90379c71b06d72
Reviewed-on: https://review.monogon.dev/c/monogon/+/3079
Tested-by: Jenkins CI
Reviewed-by: Serge Bazanski <serge@monogon.tech>
diff --git a/osbase/fat32/BUILD.bazel b/osbase/fat32/BUILD.bazel
new file mode 100644
index 0000000..d9aec3d
--- /dev/null
+++ b/osbase/fat32/BUILD.bazel
@@ -0,0 +1,37 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+load("//osbase/test/ktest:ktest.bzl", "ktest")
+
+go_library(
+    name = "fat32",
+    srcs = [
+        "dos83.go",
+        "fat32.go",
+        "structs.go",
+        "utils.go",
+    ],
+    importpath = "source.monogon.dev/osbase/fat32",
+    visibility = ["//visibility:public"],
+)
+
+go_test(
+    name = "fat32_test",
+    srcs = [
+        "fsck_test.go",
+        "linux_test.go",
+        "structs_test.go",
+    ],
+    data = ["@com_github_dosfstools_dosfstools//:fsck"],
+    embed = [":fat32"],
+    deps = [
+        "@com_github_stretchr_testify//assert",
+        "@com_github_stretchr_testify//require",
+        "@io_bazel_rules_go//go/runfiles:go_default_library",
+        "@org_golang_x_mod//semver",
+        "@org_golang_x_sys//unix",
+    ],
+)
+
+ktest(
+    cmdline = "ramdisk_size=266240",
+    tester = ":fat32_test",
+)
diff --git a/osbase/fat32/dos83.go b/osbase/fat32/dos83.go
new file mode 100644
index 0000000..650df96
--- /dev/null
+++ b/osbase/fat32/dos83.go
@@ -0,0 +1,93 @@
+package fat32
+
+import (
+	"errors"
+	"fmt"
+	"math"
+	"regexp"
+	"strings"
+)
+
+// By default, DOS names would be encoded as what Microsoft calls the OEM
+// code page. This is however dependant on the code page settings of the
+// OS reading the file name as it's not mentioned in FAT32 metadata.
+// To get maximum compatibility and make it easy to read in hex editors
+// this only encodes ASCII characters and not any specific code page.
+// This can still result in garbled data when using a non-latin code page,
+// but this is unavoidable.
+// This is legal as there is no specific requirements for generating these
+// DOS names and any semi-modern system should use the unicode filenames
+// anyways.
+
+var invalidDOSNameChar = regexp.MustCompile("^[^A-Z0-9!#$%&'()@^_\x60{}~-]$")
+
+// validDOSName matches names which are valid and unique DOS 8.3 file names as
+// well as valid ASCII
+var validDOSName = regexp.MustCompile(`^^([A-Z0-9!#$%&'()@^_\x60{}~-]{0,8})(\.[A-Z0-9!#$%&'()-@^_\x60{}~-]{1,3})?$`)
+
+func makeUniqueDOSNames(inodes []*Inode) error {
+	taken := make(map[[11]byte]bool)
+	var lossyNameInodes []*Inode
+	// Make two passes to ensure that names can always be passed through even
+	// if they would conflict with a generated name.
+	for _, i := range inodes {
+		for j := range i.dosName {
+			i.dosName[j] = ' '
+		}
+		nameUpper := strings.ToUpper(i.Name)
+		dosParts := validDOSName.FindStringSubmatch(nameUpper)
+		if dosParts != nil {
+			// Name is pass-through
+			copy(i.dosName[:8], dosParts[1])
+			if len(dosParts[2]) > 0 {
+				// Skip the dot, it is implicit
+				copy(i.dosName[8:], dosParts[2][1:])
+			}
+			if taken[i.dosName] {
+				// Mapping is unique, complain about the actual file name, not
+				// the 8.3 one
+				return fmt.Errorf("name %q occurs more than once in the same directory", i.Name)
+			}
+			taken[i.dosName] = true
+			continue
+		}
+		lossyNameInodes = append(lossyNameInodes, i)
+	}
+	// Willfully ignore the recommended short name generation algorithm as it
+	// requires tons of bookkeeping and doesn't result in stable names so
+	// cannot be relied on anyway.
+	// A FAT32 directory is limited to 2^16 entries (in practice less than half
+	// of that because of long file name entries), so 4 hex characters
+	// guarantee uniqueness, regardless of the rest of name.
+	var nameIdx int
+	for _, i := range lossyNameInodes {
+		nameUpper := strings.ToUpper(i.Name)
+		dotParts := strings.Split(nameUpper, ".")
+		for j := range dotParts {
+			// Remove all invalid chars
+			dotParts[j] = invalidDOSNameChar.ReplaceAllString(dotParts[j], "")
+		}
+		var fileName string
+		lastDotPart := dotParts[len(dotParts)-1]
+		if len(dotParts) > 1 && len(dotParts[0]) > 0 && len(lastDotPart) > 0 {
+			// We have a valid 8.3 extension
+			copy(i.dosName[8:], lastDotPart)
+			fileName = strings.Join(dotParts[:len(dotParts)-1], "")
+		} else {
+			fileName = strings.Join(dotParts[:], "")
+		}
+		copy(i.dosName[:4], fileName)
+
+		for {
+			copy(i.dosName[4:], fmt.Sprintf("%04X", nameIdx))
+			nameIdx++
+			if nameIdx >= math.MaxUint16 {
+				return errors.New("invariant violated: unable to find unique name with 16 bit counter in 16 bit space")
+			}
+			if !taken[i.dosName] {
+				break
+			}
+		}
+	}
+	return nil
+}
diff --git a/osbase/fat32/fat32.go b/osbase/fat32/fat32.go
new file mode 100644
index 0000000..7a45aa4
--- /dev/null
+++ b/osbase/fat32/fat32.go
@@ -0,0 +1,533 @@
+// Package fat32 implements a writer for the FAT32 filesystem.
+package fat32
+
+import (
+	"crypto/rand"
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"math"
+	"math/bits"
+	"strings"
+	"time"
+	"unicode/utf16"
+)
+
+// This package contains multiple references to the FAT32 specification, called
+// Microsoft Extensible Firmware Initiative FAT32 File System Specification
+// version 1.03 (just called the spec from now on). You can get it at
+// https://download.microsoft.com/download/0/8/4/\
+// 084c452b-b772-4fe5-89bb-a0cbf082286a/fatgen103.doc
+
+type Options struct {
+	// Size of a logical block on the block device. Needs to be a power of two
+	// equal or bigger than 512. If left at zero, defaults to 512.
+	BlockSize uint16
+
+	// Number of blocks the filesystem should span. If zero, it will be exactly
+	// as large as it needs to be.
+	BlockCount uint32
+
+	// Human-readable filesystem label. Maximum 10 bytes (gets cut off), should
+	// be uppercase alphanumeric.
+	Label string
+
+	// Filesystem identifier. If unset (i.e. left at zero) a random value will
+	// be assigned by WriteFS.
+	ID uint32
+}
+
+// SizedReader is an io.Reader with a known size
+type SizedReader interface {
+	io.Reader
+	Size() int64
+}
+
+// Attribute is a bitset of flags set on an inode.
+// See also the spec page 24
+type Attribute uint8
+
+const (
+	// AttrReadOnly marks a file as read-only
+	AttrReadOnly Attribute = 0x01
+	// AttrHidden indicates that directory listings should not show this file.
+	AttrHidden Attribute = 0x02
+	// AttrSystem indicates that this is an operating system file.
+	AttrSystem Attribute = 0x04
+	// AttrDirectory indicates that this is a directory and not a file.
+	AttrDirectory Attribute = 0x10
+	// AttrArchive canonically indicates that a file has been created/modified
+	// since the last backup. Its use in practice is inconsistent.
+	AttrArchive Attribute = 0x20
+)
+
+// Inode is file or directory on the FAT32 filesystem. Note that the concept
+// of an inode doesn't really exist on FAT32, its directories are just special
+// files.
+type Inode struct {
+	// Name of the file or directory (not including its path)
+	Name string
+	// Time the file or directory was last modified
+	ModTime time.Time
+	// Time the file or directory was created
+	CreateTime time.Time
+	// Attributes
+	Attrs Attribute
+	// Children of this directory (only valid when Attrs has AttrDirectory set)
+	Children []*Inode
+	// Content of this file
+	// Only valid when Attrs doesn't have AttrDirectory set.
+	Content SizedReader
+
+	// Filled out on placement and write-out
+	startCluster int
+	parent       *Inode
+	dosName      [11]byte
+}
+
+// Number of LFN entries + normal entry (all 32 bytes)
+func (i Inode) metaSize() (int64, error) {
+	fileNameUTF16 := utf16.Encode([]rune(i.Name))
+	// VFAT file names are null-terminated
+	fileNameUTF16 = append(fileNameUTF16, 0x00)
+	if len(fileNameUTF16) > 255 {
+		return 0, errors.New("file name too long, maximum is 255 UTF-16 code points")
+	}
+
+	// ⌈len(fileNameUTF16)/codepointsPerEntry⌉
+	numEntries := (len(fileNameUTF16) + codepointsPerEntry - 1) / codepointsPerEntry
+	return (int64(numEntries) + 1) * 32, nil
+}
+
+func lfnChecksum(dosName [11]byte) uint8 {
+	var sum uint8
+	for _, b := range dosName {
+		sum = ((sum & 1) << 7) + (sum >> 1) + b
+	}
+	return sum
+}
+
+// writeMeta writes information about this inode into the contents of the parent
+// inode.
+func (i Inode) writeMeta(w io.Writer) error {
+	fileNameUTF16 := utf16.Encode([]rune(i.Name))
+	// VFAT file names are null-terminated
+	fileNameUTF16 = append(fileNameUTF16, 0x00)
+	if len(fileNameUTF16) > 255 {
+		return errors.New("file name too long, maximum is 255 UTF-16 code points")
+	}
+
+	// ⌈len(fileNameUTF16)/codepointsPerEntry⌉
+	numEntries := (len(fileNameUTF16) + codepointsPerEntry - 1) / codepointsPerEntry
+	// Fill up to space in given number of entries with fill code point 0xffff
+	fillCodePoints := (numEntries * codepointsPerEntry) - len(fileNameUTF16)
+	for j := 0; j < fillCodePoints; j++ {
+		fileNameUTF16 = append(fileNameUTF16, 0xffff)
+	}
+
+	// Write entries in reverse order
+	for j := numEntries; j > 0; j-- {
+		// Index of the code point being processed
+		cpIdx := (j - 1) * codepointsPerEntry
+		var entry lfnEntry
+		entry.Checksum = lfnChecksum(i.dosName)
+		// Downcast is safe as i <= numEntries <= ⌈255/codepointsPerEntry⌉
+		entry.SequenceNumber = uint8(j)
+		if j == numEntries {
+			entry.SequenceNumber |= lastSequenceNumberFlag
+		}
+		entry.Attributes = 0x0F
+		copy(entry.NamePart1[:], fileNameUTF16[cpIdx:])
+		cpIdx += len(entry.NamePart1)
+		copy(entry.NamePart2[:], fileNameUTF16[cpIdx:])
+		cpIdx += len(entry.NamePart2)
+		copy(entry.NamePart3[:], fileNameUTF16[cpIdx:])
+		cpIdx += len(entry.NamePart3)
+
+		if err := binary.Write(w, binary.LittleEndian, entry); err != nil {
+			return err
+		}
+	}
+	selfSize, err := i.dataSize()
+	if err != nil {
+		return err
+	}
+	if selfSize >= 4*1024*1024*1024 {
+		return errors.New("single file size exceeds 4GiB which is prohibited in FAT32")
+	}
+	if i.Attrs&AttrDirectory != 0 {
+		selfSize = 0 // Directories don't have an explicit size
+	}
+	date, t, _ := timeToMsDosTime(i.ModTime)
+	if err := binary.Write(w, binary.LittleEndian, &dirEntry{
+		DOSName:           i.dosName,
+		Attributes:        uint8(i.Attrs),
+		FirstClusterHigh:  uint16(i.startCluster >> 16),
+		LastWrittenToTime: t,
+		LastWrittenToDate: date,
+		FirstClusterLow:   uint16(i.startCluster & 0xffff),
+		FileSize:          uint32(selfSize),
+	}); err != nil {
+		return err
+	}
+	return nil
+}
+
+// writeData writes the contents of this inode (including possible metadata
+// of its children, but not its children's data)
+func (i Inode) writeData(w io.Writer, volumeLabel [11]byte) error {
+	if i.Attrs&AttrDirectory != 0 {
+		if i.parent == nil {
+			if err := binary.Write(w, binary.LittleEndian, &dirEntry{
+				DOSName:    volumeLabel,
+				Attributes: 0x08, // Volume ID, internal use only
+			}); err != nil {
+				return err
+			}
+		} else {
+			date, t, _ := timeToMsDosTime(i.ModTime)
+			cdate, ctime, ctens := timeToMsDosTime(i.CreateTime)
+			if err := binary.Write(w, binary.LittleEndian, &dirEntry{
+				DOSName:           [11]byte{'.', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '},
+				CreationDate:      cdate,
+				CreationTime:      ctime,
+				CreationTenMilli:  ctens,
+				LastWrittenToTime: t,
+				LastWrittenToDate: date,
+				Attributes:        uint8(i.Attrs),
+				FirstClusterHigh:  uint16(i.startCluster >> 16),
+				FirstClusterLow:   uint16(i.startCluster & 0xffff),
+			}); err != nil {
+				return err
+			}
+			startCluster := i.parent.startCluster
+			if i.parent.parent == nil {
+				// Special case: When the dotdot directory points to the root
+				// directory, the start cluster is defined to be zero even if
+				// it isn't.
+				startCluster = 0
+			}
+			// Time is intentionally taken from this directory, not the parent
+			if err := binary.Write(w, binary.LittleEndian, &dirEntry{
+				DOSName:           [11]byte{'.', '.', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '},
+				LastWrittenToTime: t,
+				LastWrittenToDate: date,
+				Attributes:        uint8(AttrDirectory),
+				FirstClusterHigh:  uint16(startCluster >> 16),
+				FirstClusterLow:   uint16(startCluster & 0xffff),
+			}); err != nil {
+				return err
+			}
+		}
+		err := makeUniqueDOSNames(i.Children)
+		if err != nil {
+			return err
+		}
+		for _, c := range i.Children {
+			if err := c.writeMeta(w); err != nil {
+				return err
+			}
+		}
+	} else {
+		if _, err := io.CopyN(w, i.Content, i.Content.Size()); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (i Inode) dataSize() (int64, error) {
+	if i.Attrs&AttrDirectory != 0 {
+		var size int64
+		if i.parent != nil {
+			// Dot and dotdot directories
+			size += 2 * 32
+		} else {
+			// Volume ID
+			size += 1 * 32
+		}
+		for _, c := range i.Children {
+			cs, err := c.metaSize()
+			if err != nil {
+				return 0, err
+			}
+			size += cs
+		}
+		if size > 2*1024*1024 {
+			return 0, errors.New("directory contains > 2MiB of metadata which is prohibited in FAT32")
+		}
+		return size, nil
+	} else {
+		return i.Content.Size(), nil
+	}
+}
+
+func (i *Inode) PlaceFile(path string, reader SizedReader) error {
+	pathParts := strings.Split(path, "/")
+	inodeRef := i
+	for j, part := range pathParts {
+		var childExists bool
+		for _, child := range inodeRef.Children {
+			if strings.EqualFold(child.Name, part) {
+				inodeRef = child
+				childExists = true
+				break
+			}
+		}
+		if j == len(pathParts)-1 { // Is last path part (i.e. file name)
+			if childExists {
+				return &fs.PathError{Path: path, Err: fs.ErrExist, Op: "create"}
+			}
+			newInode := &Inode{
+				Name:    part,
+				Content: reader,
+			}
+			inodeRef.Children = append(inodeRef.Children, newInode)
+			return nil
+		} else if !childExists {
+			newInode := &Inode{
+				Name:  part,
+				Attrs: AttrDirectory,
+			}
+			inodeRef.Children = append(inodeRef.Children, newInode)
+			inodeRef = newInode
+		}
+	}
+	panic("unreachable")
+}
+
+type planningState struct {
+	// List of inodes in filesystem layout order
+	orderedInodes []*Inode
+	// File Allocation Table
+	fat []uint32
+	// Size of a single cluster in the FAT in bytes
+	clusterSize int64
+}
+
+// Allocates clusters capable of holding at least b bytes and returns the
+// starting cluster index
+func (p *planningState) allocBytes(b int64) int {
+	// Zero-byte data entries are located at the cluster zero by definition
+	// No actual allocation is performed
+	if b == 0 {
+		return 0
+	}
+	// Calculate the number of clusters to be allocated
+	n := (b + p.clusterSize - 1) / p.clusterSize
+	allocStartCluster := len(p.fat)
+	for i := int64(0); i < n-1; i++ {
+		p.fat = append(p.fat, uint32(len(p.fat)+1))
+	}
+	p.fat = append(p.fat, fatEOF)
+	return allocStartCluster
+}
+
+func (i *Inode) placeRecursively(p *planningState) error {
+	selfDataSize, err := i.dataSize()
+	if err != nil {
+		return fmt.Errorf("%s: %w", i.Name, err)
+	}
+	i.startCluster = p.allocBytes(selfDataSize)
+	p.orderedInodes = append(p.orderedInodes, i)
+	for _, c := range i.Children {
+		c.parent = i
+		err = c.placeRecursively(p)
+		if err != nil {
+			return fmt.Errorf("%s/%w", i.Name, err)
+		}
+	}
+	return nil
+}
+
+// WriteFS writes a filesystem described by a root inode and its children to a
+// given io.Writer.
+func WriteFS(w io.Writer, rootInode Inode, opts Options) error {
+	if opts.BlockSize == 0 {
+		opts.BlockSize = 512
+	}
+	if bits.OnesCount16(opts.BlockSize) != 1 {
+		return fmt.Errorf("option BlockSize is not a power of two")
+	}
+	if opts.BlockSize < 512 {
+		return fmt.Errorf("option BlockSize must be at least 512 bytes")
+	}
+	if opts.ID == 0 {
+		var buf [4]byte
+		if _, err := rand.Read(buf[:]); err != nil {
+			return fmt.Errorf("failed to assign random FAT ID: %v", err)
+		}
+		opts.ID = binary.BigEndian.Uint32(buf[:])
+	}
+	if rootInode.Attrs&AttrDirectory == 0 {
+		return errors.New("root inode must be a directory (i.e. have AttrDirectory set)")
+	}
+	wb := newBlockWriter(w)
+	bs := bootSector{
+		// Assembled x86_32 machine code corresponding to
+		// jmp $
+		// nop
+		// i.e. an infinite loop doing nothing. Nothing created in the last 35
+		// years should boot this anyway.
+		// TODO(q3k): write a stub
+		JmpInstruction: [3]byte{0xEB, 0xFE, 0x90},
+		// Identification
+		OEMName: [8]byte{'M', 'O', 'N', 'O', 'G', 'O', 'N'},
+		ID:      opts.ID,
+		// Block geometry
+		BlockSize:   opts.BlockSize,
+		TotalBlocks: opts.BlockCount,
+		// BootSector block + FSInfo Block, backup copy at blocks 6 and 7
+		ReservedBlocks: 8,
+		// FSInfo block is always in block 1, right after this block
+		FSInfoBlock: 1,
+		// Start block of the backup of the boot block and FSInfo block
+		// De facto this must be 6 as it is only used when the primary
+		// boot block is damaged at which point this field can no longer be
+		// read.
+		BackupStartBlock: 6,
+		// A lot of implementations only work with 2, so use that
+		NumFATs:          2,
+		BlocksPerCluster: 1,
+		// Flags and signatures
+		MediaCode:     0xf8,
+		BootSignature: 0x29,
+		Label:         [11]byte{' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '},
+		Type:          [8]byte{'F', 'A', 'T', '3', '2', ' ', ' ', ' '},
+		Signature:     [2]byte{0x55, 0xaa},
+	}
+
+	copy(bs.Label[:], opts.Label)
+
+	fs := fsinfo{
+		// Signatures
+		LeadSignature:     [4]byte{0x52, 0x52, 0x61, 0x41},
+		StructSignature:   [4]byte{0x72, 0x72, 0x41, 0x61},
+		TrailingSignature: [2]byte{0x55, 0xAA},
+
+		// This is the unset value which is always legal
+		NextFreeCluster: 0xFFFFFFFF,
+	}
+
+	p := planningState{
+		clusterSize: int64(bs.BlocksPerCluster) * int64(bs.BlockSize),
+	}
+	if opts.BlockCount != 0 {
+		// Preallocate FAT if we know how big it needs to be
+		p.fat = make([]uint32, 0, opts.BlockCount/uint32(bs.BlocksPerCluster))
+	} else {
+		// Preallocate minimum size FAT
+		// See the spec page 15 for the origin of this calculation.
+		p.fat = make([]uint32, 0, 65525+2)
+	}
+	// First two clusters are special
+	p.fat = append(p.fat, 0x0fffff00|uint32(bs.MediaCode), 0x0fffffff)
+	err := rootInode.placeRecursively(&p)
+	if err != nil {
+		return err
+	}
+
+	allocClusters := len(p.fat)
+	if allocClusters >= fatMask&math.MaxUint32 {
+		return fmt.Errorf("filesystem contains more than 2^28 FAT entries, this is unsupported. Note that this package currently always creates minimal clusters")
+	}
+
+	// Fill out FAT to minimum size for FAT32
+	for len(p.fat) < 65525+2 {
+		p.fat = append(p.fat, fatFree)
+	}
+
+	bs.RootClusterNumber = uint32(rootInode.startCluster)
+
+	bs.BlocksPerFAT = uint32(binary.Size(p.fat)+int(opts.BlockSize)-1) / uint32(opts.BlockSize)
+	occupiedBlocks := uint32(bs.ReservedBlocks) + (uint32(len(p.fat)-2) * uint32(bs.BlocksPerCluster)) + bs.BlocksPerFAT*uint32(bs.NumFATs)
+	if bs.TotalBlocks == 0 {
+		bs.TotalBlocks = occupiedBlocks
+	} else if bs.TotalBlocks < occupiedBlocks {
+		return fmt.Errorf("content (minimum %d blocks) would exceed number of blocks specified (%d blocks)", occupiedBlocks, bs.TotalBlocks)
+	} else { // Fixed-size file system with enough space
+		blocksToDistribute := bs.TotalBlocks - uint32(bs.ReservedBlocks)
+		// Number of data blocks which can be described by one metadata/FAT
+		// block. Always an integer because 4 (bytes per uint32) is a divisor of
+		// all powers of two equal or bigger than 8 and FAT32 requires a minimum
+		// of 512.
+		dataBlocksPerFATBlock := (uint32(bs.BlocksPerCluster) * uint32(bs.BlockSize)) / (uint32(binary.Size(p.fat[0])))
+		// Split blocksToDistribute between metadata and data so that exactly as
+		// much metadata (FAT) exists for describing the amount of data blocks
+		// while respecting alignment.
+		divisor := dataBlocksPerFATBlock + uint32(bs.NumFATs)
+		// 2*blocksPerCluster compensates for the first two "magic" FAT entries
+		// which do not have corresponding data.
+		bs.BlocksPerFAT = (bs.TotalBlocks + 2*uint32(bs.BlocksPerCluster) + (divisor - 1)) / divisor
+		dataBlocks := blocksToDistribute - (uint32(bs.NumFATs) * bs.BlocksPerFAT)
+		// Align to full clusters
+		dataBlocks -= dataBlocks % uint32(bs.BlocksPerCluster)
+		// Magic +2 as the first two entries do not describe data
+		for len(p.fat) < (int(dataBlocks)/int(bs.BlocksPerCluster))+2 {
+			p.fat = append(p.fat, fatFree)
+		}
+	}
+	fs.FreeCount = uint32(len(p.fat) - allocClusters)
+	if fs.FreeCount > 1 {
+		fs.NextFreeCluster = uint32(allocClusters) + 1
+	}
+
+	// Write superblock
+	if err := binary.Write(wb, binary.LittleEndian, bs); err != nil {
+		return err
+	}
+	if err := wb.FinishBlock(int64(opts.BlockSize), true); err != nil {
+		return err
+	}
+	if err := binary.Write(wb, binary.LittleEndian, fs); err != nil {
+		return err
+	}
+	if err := wb.FinishBlock(int64(opts.BlockSize), true); err != nil {
+		return err
+	}
+
+	block := make([]byte, opts.BlockSize)
+	for i := 0; i < 4; i++ {
+		if _, err := wb.Write(block); err != nil {
+			return err
+		}
+	}
+	// Backup of superblock at block 6
+	if err := binary.Write(wb, binary.LittleEndian, bs); err != nil {
+		return err
+	}
+	if err := wb.FinishBlock(int64(opts.BlockSize), true); err != nil {
+		return err
+	}
+	if err := binary.Write(wb, binary.LittleEndian, fs); err != nil {
+		return err
+	}
+	if err := wb.FinishBlock(int64(opts.BlockSize), true); err != nil {
+		return err
+	}
+
+	for i := uint8(0); i < bs.NumFATs; i++ {
+		if err := binary.Write(wb, binary.LittleEndian, p.fat); err != nil {
+			return err
+		}
+		if err := wb.FinishBlock(int64(opts.BlockSize), true); err != nil {
+			return err
+		}
+	}
+
+	for _, i := range p.orderedInodes {
+		if err := i.writeData(wb, bs.Label); err != nil {
+			return fmt.Errorf("failed to write inode %q: %v", i.Name, err)
+		}
+		if err := wb.FinishBlock(int64(opts.BlockSize)*int64(bs.BlocksPerCluster), false); err != nil {
+			return err
+		}
+	}
+	// Creatively use block writer to write out all empty data at the end
+	if err := wb.FinishBlock(int64(opts.BlockSize)*int64(bs.TotalBlocks), false); err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/osbase/fat32/fsck_test.go b/osbase/fat32/fsck_test.go
new file mode 100644
index 0000000..27de542
--- /dev/null
+++ b/osbase/fat32/fsck_test.go
@@ -0,0 +1,103 @@
+package fat32
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/bazelbuild/rules_go/go/runfiles"
+)
+
+func testWithFsck(t *testing.T, rootInode Inode, opts Options) {
+	t.Helper()
+	fsckPath, err := runfiles.Rlocation("com_github_dosfstools_dosfstools/fsck")
+	if err != nil {
+		t.Fatalf("unable to get path to fsck: %v", err)
+	}
+	testFile, err := os.CreateTemp("", "fat32-fsck-test")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer os.Remove(testFile.Name())
+	if err := WriteFS(testFile, rootInode, opts); err != nil {
+		t.Fatalf("failed to write test FS: %v", err)
+	}
+	// Run fsck non-interactively (-n), disallow spaces in short file names (-S)
+	// as well as perform deep verification (-V)
+	// If the file system is OK (i.e. fsck does not want to fix it) it returns
+	// 0, otherwise 1.
+	fsckCmd := exec.Command(fsckPath, "-n", "-S", "-V", testFile.Name())
+	result, err := fsckCmd.CombinedOutput()
+	if err != nil {
+		t.Errorf("fsck failed: %v", string(result))
+	}
+}
+
+func TestBasicFsck(t *testing.T) {
+	if os.Getenv("IN_KTEST") == "true" {
+		t.Skip("In ktest")
+	}
+	var largeString strings.Builder
+	for i := 0; i < 16384; i++ {
+		fmt.Fprintf(&largeString, "part%d", i)
+	}
+	// Test both common block sizes (512 and 4096 bytes) as well as the largest
+	// supported one (32K)
+	for _, blockSize := range []uint16{512, 4096, 32768} {
+		for _, fixed := range []string{"", "Fixed"} {
+			t.Run(fmt.Sprintf("BlockSize%d%v", blockSize, fixed), func(t *testing.T) {
+				rootInode := Inode{
+					Attrs:      AttrDirectory,
+					ModTime:    time.Date(2022, 03, 04, 5, 6, 7, 8, time.UTC),
+					CreateTime: time.Date(2022, 03, 04, 5, 6, 7, 8, time.UTC),
+				}
+				files := []struct {
+					name    string
+					path    string
+					content string
+				}{
+					{"FileInRoot", "test1.txt", "test1 content"},
+					{"LongFileInRoot", "verylongtest1.txt", "test1 content long"},
+					{"LongPath", "test1/test2/test3/test4/longdirname.ext/hello", "long path test content"},
+					{"LargeFile", "test1/largefile.txt", largeString.String()},
+				}
+				for _, c := range files {
+					err := rootInode.PlaceFile(c.path, strings.NewReader(c.content))
+					if err != nil {
+						t.Errorf("failed to place file: %v", err)
+					}
+				}
+				opts := Options{ID: 1234, Label: "TEST", BlockSize: blockSize}
+				if fixed == "Fixed" {
+					// Use a block count that is slightly higher than the minimum
+					opts.BlockCount = 67000
+				}
+				testWithFsck(t, rootInode, opts)
+			})
+		}
+	}
+}
+
+func TestLotsOfFilesFsck(t *testing.T) {
+	if os.Getenv("IN_KTEST") == "true" {
+		t.Skip("In ktest")
+	}
+	rootInode := Inode{
+		Attrs:   AttrDirectory,
+		ModTime: time.Date(2022, 03, 04, 5, 6, 7, 8, time.UTC),
+	}
+	for i := 0; i < (32*1024)-2; i++ {
+		rootInode.Children = append(rootInode.Children, &Inode{
+			Name:    fmt.Sprintf("test%d", i),
+			Content: strings.NewReader("random test content"),
+			// Add some random attributes
+			Attrs: AttrHidden | AttrSystem,
+			// And a random ModTime
+			ModTime: time.Date(2022, 03, 04, 5, 6, 7, 8, time.UTC),
+		})
+	}
+	testWithFsck(t, rootInode, Options{ID: 1234, Label: "TEST"})
+}
diff --git a/osbase/fat32/linux_test.go b/osbase/fat32/linux_test.go
new file mode 100644
index 0000000..ca62b76
--- /dev/null
+++ b/osbase/fat32/linux_test.go
@@ -0,0 +1,281 @@
+package fat32
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"math/rand"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"golang.org/x/mod/semver"
+	"golang.org/x/sys/unix"
+)
+
+func TestKernelInterop(t *testing.T) {
+	if os.Getenv("IN_KTEST") != "true" {
+		t.Skip("Not in ktest")
+	}
+
+	// ONCHANGE(//third_party/linux): Drop this once we move to a Kernel version
+	// newer than 5.19 which will have FAT btime support.
+	kernelVersion, err := os.ReadFile("/proc/sys/kernel/osrelease")
+	if err != nil {
+		t.Fatalf("unable to determine kernel version: %v", err)
+	}
+	haveBtime := semver.Compare("v"+string(kernelVersion), "v5.19.0") >= 0
+
+	type testCase struct {
+		name     string
+		setup    func(root *Inode) error
+		validate func(t *testing.T) error
+	}
+
+	// Random timestamp in UTC, divisible by 10ms
+	testTimestamp1 := time.Date(2022, 03, 04, 5, 6, 7, 10, time.UTC)
+	// Random timestamp in UTC, divisible by 2s
+	testTimestamp2 := time.Date(2022, 03, 04, 5, 6, 8, 0, time.UTC)
+	// Random timestamp in UTC, divisible by 10ms
+	testTimestamp3 := time.Date(2052, 03, 02, 5, 6, 7, 10, time.UTC)
+	// Random timestamp in UTC, divisible by 2s
+	testTimestamp4 := time.Date(2052, 10, 04, 5, 3, 4, 0, time.UTC)
+
+	testContent1 := "testcontent1"
+
+	tests := []testCase{
+		{
+			name: "SimpleFolder",
+			setup: func(root *Inode) error {
+				root.Children = []*Inode{{
+					Name:       "testdir",
+					Attrs:      AttrDirectory,
+					CreateTime: testTimestamp1,
+					ModTime:    testTimestamp2,
+				}}
+				return nil
+			},
+			validate: func(t *testing.T) error {
+				var stat unix.Statx_t
+				if err := unix.Statx(0, "/dut/testdir", 0, unix.STATX_TYPE|unix.STATX_MTIME|unix.STATX_BTIME, &stat); err != nil {
+					availableFiles, err := os.ReadDir("/dut")
+					var availableFileNames []string
+					for _, f := range availableFiles {
+						availableFileNames = append(availableFileNames, f.Name())
+					}
+					if err != nil {
+						t.Fatalf("Failed to list filesystem root directory: %v", err)
+					}
+					t.Fatalf("Failed to stat output: %v (available: %v)", err, strings.Join(availableFileNames, ", "))
+				}
+				if stat.Mode&unix.S_IFDIR == 0 {
+					t.Errorf("testdir is expected to be a directory, but has mode %v", stat.Mode)
+				}
+				btime := time.Unix(stat.Btime.Sec, int64(stat.Btime.Nsec))
+				if !btime.Equal(testTimestamp1) && haveBtime {
+					t.Errorf("testdir btime expected %v, got %v", testTimestamp1, btime)
+				}
+				mtime := time.Unix(stat.Mtime.Sec, int64(stat.Mtime.Nsec))
+				if !mtime.Equal(testTimestamp2) {
+					t.Errorf("testdir mtime expected %v, got %v", testTimestamp2, mtime)
+				}
+				return nil
+			},
+		},
+		{
+			name: "SimpleFile",
+			setup: func(root *Inode) error {
+				root.Children = []*Inode{{
+					Name:       "testfile",
+					CreateTime: testTimestamp3,
+					ModTime:    testTimestamp4,
+					Content:    strings.NewReader(testContent1),
+				}}
+				return nil
+			},
+			validate: func(t *testing.T) error {
+				var stat unix.Statx_t
+				if err := unix.Statx(0, "/dut/testfile", 0, unix.STATX_TYPE|unix.STATX_MTIME|unix.STATX_BTIME, &stat); err != nil {
+					t.Fatalf("failed to stat output: %v", err)
+				}
+				if stat.Mode&unix.S_IFREG == 0 {
+					t.Errorf("testfile is expected to be a file, but has mode %v", stat.Mode)
+				}
+				btime := time.Unix(stat.Btime.Sec, int64(stat.Btime.Nsec))
+				if !btime.Equal(testTimestamp3) && haveBtime {
+					t.Errorf("testfile ctime expected %v, got %v", testTimestamp3, btime)
+				}
+				mtime := time.Unix(stat.Mtime.Sec, int64(stat.Mtime.Nsec))
+				if !mtime.Equal(testTimestamp4) {
+					t.Errorf("testfile mtime expected %v, got %v", testTimestamp3, mtime)
+				}
+				contents, err := os.ReadFile("/dut/testfile")
+				if err != nil {
+					t.Fatalf("failed to read back test file: %v", err)
+				}
+				if string(contents) != testContent1 {
+					t.Errorf("testfile contains %x, got %x", contents, []byte(testContent1))
+				}
+				return nil
+			},
+		},
+		{
+			name: "FolderHierarchy",
+			setup: func(i *Inode) error {
+				i.Children = []*Inode{{
+					Name:       "l1",
+					Attrs:      AttrDirectory,
+					CreateTime: testTimestamp1,
+					ModTime:    testTimestamp2,
+					Children: []*Inode{{
+						Name:       "l2",
+						Attrs:      AttrDirectory,
+						CreateTime: testTimestamp1,
+						ModTime:    testTimestamp2,
+					}},
+				}}
+				return nil
+			},
+			validate: func(t *testing.T) error {
+				dirInfo, err := os.ReadDir("/dut/l1")
+				if err != nil {
+					t.Fatalf("Failed to read top-level directory: %v", err)
+				}
+				require.Len(t, dirInfo, 1, "more subdirs than expected")
+				require.Equal(t, "l2", dirInfo[0].Name(), "unexpected subdir")
+				require.True(t, dirInfo[0].IsDir(), "l1 not a directory")
+				subdirInfo, err := os.ReadDir("/dut/l1/l2")
+				assert.NoError(t, err, "cannot read empty subdir")
+				require.Len(t, subdirInfo, 0, "unexpected subdirs in empty directory")
+				return nil
+			},
+		},
+		{
+			name: "LargeFile",
+			setup: func(i *Inode) error {
+				content := make([]byte, 6500)
+				io.ReadFull(rand.New(rand.NewSource(1)), content)
+				i.Children = []*Inode{{
+					Name:    "test.bin",
+					Content: bytes.NewReader(content),
+				}}
+				return nil
+			},
+			validate: func(t *testing.T) error {
+				var stat unix.Stat_t
+				err := unix.Stat("/dut/test.bin", &stat)
+				assert.NoError(t, err, "failed to stat file")
+				require.EqualValues(t, 6500, stat.Size, "wrong size")
+				file, err := os.Open("/dut/test.bin")
+				assert.NoError(t, err, "failed to open test file")
+				defer file.Close()
+				r := io.LimitReader(rand.New(rand.NewSource(1)), 6500) // Random but deterministic data
+				expected, _ := io.ReadAll(r)
+				actual, err := io.ReadAll(file)
+				assert.NoError(t, err, "failed to read test file")
+				assert.Equal(t, expected, actual, "content not identical")
+				return nil
+			},
+		},
+		{
+			name: "Unicode",
+			setup: func(i *Inode) error {
+				i.Children = []*Inode{{
+					Name:    "✨😂", // Really exercise that UTF-16 conversion
+					Content: strings.NewReader("😂"),
+				}}
+				return nil
+			},
+			validate: func(t *testing.T) error {
+				file, err := os.Open("/dut/✨😂")
+				if err != nil {
+					availableFiles, err := os.ReadDir("/dut")
+					var availableFileNames []string
+					for _, f := range availableFiles {
+						availableFileNames = append(availableFileNames, f.Name())
+					}
+					if err != nil {
+						t.Fatalf("Failed to list filesystem root directory: %v", err)
+					}
+					t.Fatalf("Failed to open unicode file: %v (available files: %v)", err, strings.Join(availableFileNames, ", "))
+				}
+				defer file.Close()
+				contents, err := io.ReadAll(file)
+				if err != nil {
+					t.Errorf("Wrong content: expected %x, got %x", []byte("😂"), contents)
+				}
+				return nil
+			},
+		},
+		{
+			name: "MultipleMetaClusters",
+			setup: func(root *Inode) error {
+				// Only test up to 2048 files as Linux gets VERY slow if going
+				// up to the maximum of approximately 32K
+				for i := 0; i < 2048; i++ {
+					root.Children = append(root.Children, &Inode{
+						Name:    fmt.Sprintf("verylongtestfilename%d", i),
+						Content: strings.NewReader("random test content"),
+					})
+				}
+				return nil
+			},
+			validate: func(t *testing.T) error {
+				files, err := os.ReadDir("/dut")
+				if err != nil {
+					t.Errorf("failed to list directory: %v", err)
+				}
+				if len(files) != 2048 {
+					t.Errorf("wrong number of files: expected %d, got %d", 2048, len(files))
+				}
+				return nil
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			file, err := os.OpenFile("/dev/ram0", os.O_WRONLY|os.O_TRUNC, 0644)
+			if err != nil {
+				t.Fatalf("failed to create test image: %v", err)
+			}
+			size, err := unix.IoctlGetInt(int(file.Fd()), unix.BLKGETSIZE64)
+			if err != nil {
+				t.Fatalf("failed to get ramdisk size: %v", err)
+			}
+			blockSize, err := unix.IoctlGetInt(int(file.Fd()), unix.BLKBSZGET)
+			if err != nil {
+				t.Fatalf("failed to get ramdisk block size: %v", err)
+			}
+			defer file.Close()
+			rootInode := Inode{
+				Attrs: AttrDirectory,
+			}
+			if err := test.setup(&rootInode); err != nil {
+				t.Fatalf("setup failed: %v", err)
+			}
+			if err := WriteFS(file, rootInode, Options{
+				ID:         1234,
+				Label:      "KTEST",
+				BlockSize:  uint16(blockSize),
+				BlockCount: uint32(size / blockSize),
+			}); err != nil {
+				t.Fatalf("failed to write fileystem: %v", err)
+			}
+			_ = file.Close()
+			if err := os.MkdirAll("/dut", 0755); err != nil {
+				t.Error(err)
+			}
+			// TODO(lorenz): Set CONFIG_FAT_DEFAULT_UTF8 for Monogon Kernel
+			if err := unix.Mount("/dev/ram0", "/dut", "vfat", unix.MS_NOEXEC|unix.MS_NODEV, "utf8=1"); err != nil {
+				t.Fatal(err)
+			}
+			defer unix.Unmount("/dut", 0)
+			test.validate(t)
+		})
+
+	}
+}
diff --git a/osbase/fat32/structs.go b/osbase/fat32/structs.go
new file mode 100644
index 0000000..396361d
--- /dev/null
+++ b/osbase/fat32/structs.go
@@ -0,0 +1,133 @@
+package fat32
+
+const (
+	// FAT32 entries are only 28 bits
+	fatMask = 0x0fffffff
+	// Free entries are 0
+	fatFree = 0x0
+	// Entry at the end of a cluster chain
+	fatEOF = 0x0ffffff8
+)
+
+// FAT32 Boot Sector and BIOS Parameter Block. This structure is 512 bytes long,
+// even if the logical block size is longer. The rest should be filled up with
+// zeroes.
+type bootSector struct {
+	// Jump instruction to boot code.
+	JmpInstruction [3]byte
+	// Creator name. "MSWIN4.1" recommended for compatibility.
+	OEMName [8]byte
+	// Count of bytes per block (i.e. logical block size)
+	// Must be one of 512, 1024, 2048 or 4096
+	BlockSize uint16
+	// Number of blocks per allocation unit (cluster).
+	// Must be a power of 2 that is greater than 0.
+	BlocksPerCluster uint8
+	// Number of reserved blocks in the reserved region of the volume starting
+	// at the first block of the volume. This field must not be 0.
+	ReservedBlocks uint16
+	// The count of FAT data structures on the volume. This field should always
+	// contain the value of 2 for any FAT volume of any type.
+	NumFATs uint8
+	_       [4]byte
+	// Legacy value for media determination, must be 0xf8.
+	MediaCode uint8
+	_         [2]byte
+	// Number of sectors per track for 0x13 interrupts.
+	SectorsPerTrack uint16
+	// Number of heads for 0x13 interrupts.
+	NumHeads uint16
+	// Count of hidden blocks preceding the partition that contains this FAT
+	// volume.
+	HiddenBlocks uint32
+	// Total count of blocks on the volume.
+	TotalBlocks uint32
+	// Count of blocks per FAT.
+	BlocksPerFAT uint32
+	// Flags for FAT32
+	Flags uint16
+	_     [2]byte
+	// Cluster number of the first cluster of the root directory. Usually 2.
+	RootClusterNumber uint32
+	// Block number of the FSINFO structure in the reserved area.
+	FSInfoBlock uint16
+	// Block number of the copy of the boot record in the reserved area.
+	BackupStartBlock uint16
+	_                [12]byte
+	// Drive number for 0x13 interrupts.
+	DriveNumber   uint8
+	_             [1]byte
+	BootSignature uint8
+	// ID of this filesystem
+	ID uint32
+	// Human-readable label of this filesystem, padded with spaces (0x20)
+	Label [11]byte
+	// Always set to ASCII "FAT32    "
+	Type [8]byte
+	_    [420]byte
+	// Always 0x55, 0xAA
+	Signature [2]byte
+}
+
+// Special block (usually at block 1) containing additional metadata,
+// specifically the number of free clusters and the next free cluster.
+// Always 512 bytes, rest of the block should be padded with zeroes.
+type fsinfo struct {
+	// Validates that this is an FSINFO block. Always 0x52, 0x52, 0x61, 0x41
+	LeadSignature [4]byte
+	_             [480]byte
+	// Another signature. Always 0x72, 0x72, 0x41, 0x61
+	StructSignature [4]byte
+	// Last known number of free clusters on the volume.
+	FreeCount uint32
+	// Next free cluster hint. All 1's is interpreted as undefined.
+	NextFreeCluster uint32
+	_               [14]byte
+	// One more signature. Always 0x55, 0xAA.
+	TrailingSignature [2]byte
+}
+
+// Directory entry
+type dirEntry struct {
+	// DOS 8.3 file name.
+	DOSName [11]byte
+	// Attribtes of the file or directory, 0x0f reserved to mark entry as a
+	// LFN entry (see lfnEntry below)
+	Attributes        uint8
+	_                 byte
+	CreationTenMilli  uint8 // Actually 10ms units, 0-199 range
+	CreationTime      uint16
+	CreationDate      uint16
+	_                 [2]byte
+	FirstClusterHigh  uint16
+	LastWrittenToTime uint16
+	LastWrittenToDate uint16
+	FirstClusterLow   uint16
+	FileSize          uint32
+}
+
+const (
+	// lastSequenceNumberFlag is logically-ORed with the sequence number of the
+	// last Long File Name entry to mark it as such.
+	lastSequenceNumberFlag = 0x40
+	// codepointsPerEntry is the number of UTF-16 codepoints that fit into a
+	// single Long File Name entry.
+	codepointsPerEntry = 5 + 6 + 2
+)
+
+// VFAT long file name prepended entry
+type lfnEntry struct {
+	SequenceNumber uint8
+	// First 5 UTF-16 code units
+	NamePart1 [5]uint16
+	// Attributes (must be 0x0f)
+	Attributes uint8
+	_          byte
+	// Checksum of the 8.3 name.
+	Checksum uint8
+	// Next 6 UTF-16 code units
+	NamePart2 [6]uint16
+	_         [2]byte
+	// Next 2 UTF-16 code units
+	NamePart3 [2]uint16
+}
diff --git a/osbase/fat32/structs_test.go b/osbase/fat32/structs_test.go
new file mode 100644
index 0000000..77a7df0
--- /dev/null
+++ b/osbase/fat32/structs_test.go
@@ -0,0 +1,27 @@
+package fat32
+
+import (
+	"encoding/binary"
+	"reflect"
+	"testing"
+)
+
+func TestStructureSizes(t *testing.T) {
+	cases := []struct {
+		StructInstance interface{}
+		ExpectedSize   int
+	}{
+		{bootSector{}, 512},
+		{fsinfo{}, 512},
+		{dirEntry{}, 32},
+		{lfnEntry{}, 32},
+	}
+	for _, c := range cases {
+		t.Run(reflect.TypeOf(c.StructInstance).String(), func(t *testing.T) {
+			actualSize := binary.Size(c.StructInstance)
+			if actualSize != c.ExpectedSize {
+				t.Errorf("Expected %d bytes, got %d", c.ExpectedSize, actualSize)
+			}
+		})
+	}
+}
diff --git a/osbase/fat32/utils.go b/osbase/fat32/utils.go
new file mode 100644
index 0000000..833665c
--- /dev/null
+++ b/osbase/fat32/utils.go
@@ -0,0 +1,74 @@
+package fat32
+
+import (
+	"fmt"
+	"io"
+	"time"
+)
+
+// Wraps a writer and provides support for writing padding up to a specified
+// alignment.
+// TODO(lorenz): Implement WriterTo when w implements it to allow for copy
+// offload
+type blockWriter struct {
+	w io.Writer
+	n int64
+}
+
+func newBlockWriter(w io.Writer) *blockWriter {
+	return &blockWriter{w: w}
+}
+
+func (b *blockWriter) Write(p []byte) (n int, err error) {
+	n, err = b.w.Write(p)
+	b.n += int64(n)
+	return
+}
+
+func (b *blockWriter) FinishBlock(alignment int64, mustZero bool) (err error) {
+	requiredBytes := (alignment - (b.n % alignment)) % alignment
+	if requiredBytes == 0 {
+		return nil
+	}
+	// Do not actually write out zeroes if not necessary
+	if s, ok := b.w.(io.Seeker); ok && !mustZero {
+		if _, err := s.Seek(requiredBytes-1, io.SeekCurrent); err != nil {
+			return fmt.Errorf("failed to seek to create hole for empty block: %w", err)
+		}
+		if _, err := b.w.Write([]byte{0x00}); err != nil {
+			return fmt.Errorf("failed to write last byte to create hole: %w", err)
+		}
+		b.n += requiredBytes
+		return
+	}
+	emptyBuf := make([]byte, 1*1024*1024)
+	for requiredBytes > 0 {
+		curBlockBytes := requiredBytes
+		if curBlockBytes > int64(len(emptyBuf)) {
+			curBlockBytes = int64(len(emptyBuf))
+		}
+		_, err = b.Write(emptyBuf[:curBlockBytes])
+		if err != nil {
+			return
+		}
+		requiredBytes -= curBlockBytes
+	}
+	return
+}
+
+// timeToMsDosTime converts a time.Time to an MS-DOS date and time.
+// The resolution is 2s with fTime and 10ms if fTenMils is also used.
+// See: http://msdn.microsoft.com/en-us/library/ms724274(v=VS.85).aspx
+func timeToMsDosTime(t time.Time) (fDate uint16, fTime uint16, fTenMils uint8) {
+	t = t.In(time.UTC)
+	if t.Year() < 1980 {
+		t = time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC)
+	}
+	if t.Year() > 2107 {
+		t = time.Date(2107, 12, 31, 23, 59, 59, 0, time.UTC)
+	}
+	fDate = uint16(t.Day() + int(t.Month())<<5 + (t.Year()-1980)<<9)
+	fTime = uint16(t.Second()/2 + t.Minute()<<5 + t.Hour()<<11)
+	fTenMils = uint8(t.Nanosecond()/1e7 + (t.Second()%2)*100)
+	return
+}