Documentation
¶
Overview ¶
Package xar is a pure-Go, read-only driver for the XAR (eXtensible ARchive) format — the container a macOS `.pkg` installer is, and the format /usr/bin/xar writes.
OpenReader returns a filesystem.Filesystem over an archive held in any io.ReaderAt, so a `.pkg` can be walked and read without being unpacked. The optional filesystem.Opener capability is implemented, so a caller can stream one member instead of materialising it.
The layout ¶
A 28-byte big-endian header, a zlib-compressed XML table of contents, then a heap of member data:
offset size field 0 4 magic, 'xar!' (0x78617221) 4 2 HeaderSize — the size of THIS header 6 2 Version 8 8 TOCLengthCompressed 16 8 TOCLengthUncompressed 24 4 ChecksumAlgorithm
The heap begins at HeaderSize + TOCLengthCompressed. HeaderSize, not the constant 28, is the base: the field exists so the header can grow, and a reader that assumes 28 is reading a different file than the one it was given. Every member's <offset> is relative to that heap start, never to the file.
The table of contents is <xar><toc>, with <file> elements nested inside one another to express directories. A member's path is therefore built by walking the nesting — there is no path string anywhere in the format, only a <name> per level.
Four things the format does that a reader will get wrong ¶
Each of these was observed in an archive written by /usr/bin/xar 1.8dev, not inferred from a specification.
encoding style="application/x-gzip" IS NOT GZIP. It is a raw zlib stream (RFC 1950): 0x78 0xda, no gzip member header, no gzip trailer. compress/gzip refuses it outright, which at least fails loudly; the naming is the trap. The stored encoding is "application/octet-stream" and bzip2 is "application/x-bzip2".
AN EMPTY FILE HAS NO <data> ELEMENT AT ALL. It is not <data> with <size>0</size>; the element is simply absent. A reader that requires <data> loses every empty member.
<mode> IS OCTAL, AND SOMETIMES HAS NO LEADING ZERO. xar writes <mode>0644</mode> for an ordinary file but <mode>4755</mode> for a setuid one. Both are octal. strconv.ParseUint with base 0 is therefore wrong in a way no leading-zero mode can reveal: it reads "0644" as octal, because of the zero, and "4755" as decimal. Only an explicit base 8 is right.
<ea> ELEMENTS LOOK EXACTLY LIKE <data>. An extended attribute is stored in the heap with its own <offset>, <length>, <size> and <encoding>, and its own <name>. A reader that searches a <file> for any descendant offset/length hands back an xattr's bytes as the file's contents — most visibly on an empty file, which has an <ea> and no <data>. A reader that takes any descendant <name> names the file after the xattr.
Members are compressed individually, each with its own encoding, so there is no single archive-wide stream and no flat offset index into decoded bytes.
What this package does not do ¶
The header's ChecksumAlgorithm and the TOC's own <checksum> element are parsed past, not verified, and neither are the per-member <extracted-checksum>/<archived-checksum> digests. Nothing here authenticates an archive; a `.pkg` signature in particular is not checked, and a caller that needs provenance must establish it by other means.
LZMA-encoded members ("application/x-lzma") are recognised and refused with ErrUnsupportedEncoding rather than decoded. The refusal is per member and happens when the data is read, so an archive with one LZMA member still opens and lists. It is untested against a real archive on purpose: macOS's xar 1.8dev reports "lzma support not compiled in" and cannot write one, and a fixture hand-built to agree with this reader would prove nothing about it.
What real .pkg files use, measured ¶
The encoding names above make "application/x-gzip" look like the format's default, because it is what /usr/bin/xar writes unless told otherwise. Real installers do not agree, and a reader that supports only zlib cannot read Apple's own packages at all:
Safari27.0TahoeAuto.pkg (Apple, signed, 255 MB) Payload application/octet-stream 255,426,183 -> 255,426,183 Scripts application/octet-stream 18,216 -> 18,216 PackageInfo application/x-bzip2 529 -> 1,002 Bom application/x-bzip2 3,448 -> 63,198 GLPI-Agent-1.7.3_arm64.pkg (third party, signed, nested) Payload application/octet-stream 21,609,167 -> 21,609,167 Bom application/x-gzip 264,415 -> 1,169,586 License.txt application/x-gzip 6,819 -> 17,987
So: the Payload — the part that holds the installed files, and the largest member by far — is STORED in both, which is why it is served here straight from the backing io.ReaderAt with no buffering. Apple compresses its metadata with BZIP2 and not with zlib at all, so compress/bzip2 is required to read a macOS system package, not an extra.
Both packages are signed. The <signature>/<x-signature> and X509 elements sit beside <file> under <toc> and are ignored here, which is what lets a signed archive be read at all; see the note above on what that does and does not mean.
Also measured: the Distribution member of the GLPI package has NO <mode> element, only <data>, <type> and <name>. An absent mode is reported as zero permission bits, because that is what the archive recorded -- not 0644 guessed on its behalf.
Extended attributes, uid/gid, the timestamps and the Finder metadata are read past. An archive is read-only: every mutating method of filesystem.Filesystem returns ErrReadOnly.
Index ¶
- Constants
- Variables
- func OpenReader(r io.ReaderAt, size int64) (filesystem.Filesystem, error)
- func OpenReaderWith(r io.ReaderAt, size int64, opt Options) (filesystem.Filesystem, error)
- type FS
- func (f *FS) Close() error
- func (f *FS) DeleteDir(string) error
- func (f *FS) DeleteFile(string) error
- func (f *FS) ListDir(p string) ([]filesystem.DirEntry, error)
- func (f *FS) MkDir(string, os.FileMode) error
- func (f *FS) OpenFile(p string) (filesystem.File, error)
- func (f *FS) ReadFile(p string) ([]byte, error)
- func (f *FS) ReadLink(p string) (string, error)
- func (f *FS) Rename(string, string) error
- func (f *FS) Stat(p string) (filesystem.Stat, error)
- func (f *FS) WriteFile(string, []byte, os.FileMode) error
- type Options
Constants ¶
const ( // DefaultMaxEntries is the most <file> elements a table of contents may hold // (1,048,576). A table of contents costs roughly two kilobytes of heap per // entry while it is parsed. DefaultMaxEntries = 1 << 20 // DefaultMaxFileSize is the largest member ReadFile will return (1 GiB). DefaultMaxFileSize = 1 << 30 )
Defaults for Options.
const Magic uint32 = 0x78617221
Magic is the four-byte signature at offset 0 of every XAR archive: 'xar!'.
Variables ¶
var ( // ErrReadOnly is returned by every mutating method (WriteFile, MkDir, // DeleteFile, DeleteDir, Rename). XAR is an archive format: an existing // archive cannot be edited in place. // // It wraps iofs.ErrPermission so a generic caller — a WebDAV, NFS or SFTP // server over this filesystem — can classify it without matching on the // message, which is what the interface's error contract asks for. ErrReadOnly = fmt.Errorf("xar: archive is read-only: %w", iofs.ErrPermission) // ErrBadMagic is returned when the first four bytes are not 'xar!'. ErrBadMagic = errors.New("xar: not a xar archive") // ErrBadHeader is returned when the 28-byte header is present but says // something impossible: a header shorter than itself, a table of contents // that extends past the end of the archive, and so on. ErrBadHeader = errors.New("xar: malformed header") // ErrUnsupportedVersion is returned for a header version this package does // not know. XAR has only ever had version 1. ErrUnsupportedVersion = errors.New("xar: unsupported archive version") // ErrCorrupt is returned when the table of contents parses but describes // something the archive cannot contain — an offset past the end of the // heap, a negative length, two entries with one name in one directory, a // member whose data stops before its declared size. ErrCorrupt = errors.New("xar: corrupt archive") // ErrTooLarge is returned when an archive asks for more than a limit allows: // more entries than Options.MaxEntries, a path longer than 4096 bytes or // paths that together exceed 256 MiB, or a ReadFile of a member larger than // Options.MaxFileSize. It is raised BEFORE the memory is reserved. The // archive is not necessarily corrupt; raise the limit, or stream the member // with OpenFile, if the size is expected. ErrTooLarge = errors.New("xar: exceeds the size limit") // ErrUnsupportedEncoding is returned when a member's encoding style is one // this package cannot decode. It is raised when the member's data is read, // not when the archive is opened, so one undecodable member does not make // the rest of the archive unreachable. ErrUnsupportedEncoding = errors.New("xar: unsupported entry encoding") // ErrNotFound is returned when a path is not in the archive. It wraps // iofs.ErrNotExist, which the interface module requires of every driver. ErrNotFound = fmt.Errorf("xar: path not found: %w", iofs.ErrNotExist) // ErrNotDirectory is returned when ListDir names something that is not a // directory. ErrNotDirectory = errors.New("xar: not a directory") // ErrNotRegular is returned when ReadFile or OpenFile names a directory, a // symlink, or one of the special types XAR can record (fifo, socket, // device node). None of them has readable contents. ErrNotRegular = errors.New("xar: not a regular file") // ErrNotSymlink is returned by ReadLink for an entry XAR did not record a // link target for. ErrNotSymlink = errors.New("xar: not a symbolic link") )
Sentinel errors. Compare with errors.Is so wrapped errors keep matching.
Functions ¶
func OpenReader ¶
func OpenReader(r io.ReaderAt, size int64) (filesystem.Filesystem, error)
OpenReader reads the header and the whole table of contents of the archive in r, which must be size bytes long, and returns a read-only filesystem over it.
Member data is NOT read: only the header and the table of contents are, and each member is decoded when it is read. r must stay usable for as long as the returned filesystem is.
It applies the default limits; see Options and OpenReaderWith.
func OpenReaderWith ¶ added in v0.3.0
func OpenReaderWith(r io.ReaderAt, size int64, opt Options) (filesystem.Filesystem, error)
OpenReaderWith is OpenReader with explicit limits. A limit that is exceeded fails with an error wrapping ErrTooLarge before the memory is reserved.
Types ¶
type FS ¶
type FS struct {
// contains filtered or unexported fields
}
FS is an opened, read-only XAR archive.
It satisfies filesystem.Filesystem and the optional filesystem.Opener. A filesystem.File obtained from OpenFile is valid only while the FS is open.
func (*FS) Close ¶
Close releases nothing: FS never opened the io.ReaderAt it was handed, so it does not close it either. It is here because filesystem.Filesystem has it.
func (*FS) DeleteFile ¶
func (*FS) ListDir ¶
func (f *FS) ListDir(p string) ([]filesystem.DirEntry, error)
ListDir returns the entries of the directory at p, sorted by name.
func (*FS) OpenFile ¶
func (f *FS) OpenFile(p string) (filesystem.File, error)
OpenFile opens the member at p for random access, implementing filesystem.Opener.
Nothing is read here: the entry's heap extent and encoding were already decoded from the table of contents, and a decoder is started on the first ReadAt. See handle for what "random access" costs on a compressed member.
func (*FS) ReadFile ¶
ReadFile returns the whole decoded contents of the member at p.
The buffer is GROWN from what is actually read rather than allocated from the declared <size>. A table of contents is untrusted input and its size field is not bounded by the archive's own length, so trusting it to size an allocation lets a few hundred bytes of XML ask for as much memory as it likes. Use OpenFile for anything whose size was not chosen here.
A member larger than Options.MaxFileSize (DefaultMaxFileSize unless set) is refused with ErrTooLarge before anything is read: compression amplifies, and a 526 KiB zlib member can decode to 512 MiB. OpenFile streams in constant memory and has no such limit.
type Options ¶ added in v0.3.0
type Options struct {
// MaxEntries bounds the number of members. Zero means DefaultMaxEntries.
MaxEntries int
// MaxFileSize bounds what ReadFile will buffer, in decoded bytes. Zero means
// DefaultMaxFileSize. OpenFile is not affected: it streams.
MaxFileSize int64
}
Options tunes the limits applied to an untrusted archive. The zero value selects the defaults.
