Resolves #48448. These should help with reviewing the XAR and BOM implementations: - https://claude.ai/code/artifact/60a78c1d-2fc9-45da-9471-1517fe77adb4. - https://claude.ai/code/artifact/1c759a32-02f7-4a41-8611-04d7358367d7. The darwin only tests (bom_darwin_test.go) have been executed on my workstation. Goal is to make sure to run the tests on macOS Github runners in https://github.com/fleetdm/fleet/issues/33371. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. ## Testing - [x] QA'd all new/changed functionality manually ## fleetd/orbit/Fleet Desktop - [x] Verified compatibility with the latest released version of Fleet (see [Must rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)) - [x] Verified auto-update works from the released version of component to the new version (see [tools/tuf/test](../tools/tuf/test/README.md)) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * macOS package builds now use an internal, built-in implementation instead of external packaging tools. * `.pkg` installer creation no longer depends on Docker for macOS packaging. * **Bug Fixes** * Improved packaging reliability by reducing platform-specific build steps. * Packaging test coverage was streamlined to better match the supported build environment. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
245 lines
7.3 KiB
Go
245 lines
7.3 KiB
Go
package packaging
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/zlib"
|
|
"crypto/sha1" //nolint:gosec // xar's on-disk checksum format uses SHA-1; not used for security
|
|
"encoding/binary"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// This file implements a minimal, pure-Go writer for the xar archive format,
|
|
// sufficient to produce macOS flat .pkg installers. It simulates macOS command
|
|
// `xar --compression none` invocation.
|
|
//
|
|
// A xar archive is:
|
|
//
|
|
// [ 28-byte header ][ zlib-compressed TOC (XML) ][ heap ]
|
|
//
|
|
// The header points at the compressed TOC; the TOC is an XML description of the
|
|
// file tree whose <data>/<checksum> elements reference byte ranges ("offset" and
|
|
// "length") within the heap. The heap begins with the SHA-1 checksum of the
|
|
// compressed TOC (as declared by the TOC's own <checksum> element), followed by
|
|
// each file's contents. Files are stored uncompressed (encoding
|
|
// application/octet-stream), matching the previous `--compression none` behavior.
|
|
//
|
|
// Reference: the xar on-disk format: https://github.com/mackyle/xar.
|
|
|
|
const (
|
|
xarMagic uint32 = 0x78617221 // "xar!"
|
|
xarHeaderSize uint16 = 28
|
|
xarVersion uint16 = 1
|
|
xarChecksumSHA1 uint32 = 1 // cksum_alg value for SHA-1
|
|
xarChecksumSize int64 = 20 // size of a SHA-1 digest in bytes
|
|
)
|
|
|
|
// xarEntry is a node in the archive tree.
|
|
type xarEntry struct {
|
|
name string
|
|
isDir bool
|
|
mode os.FileMode
|
|
data []byte // file contents (nil for directories)
|
|
children []*xarEntry // populated for directories
|
|
|
|
// Populated during heap layout (files only):
|
|
id int
|
|
offset int64
|
|
size int64
|
|
sha1 string
|
|
}
|
|
|
|
// writeXar walks srcDir and writes an uncompressed xar archive of its contents
|
|
// to dstPath. The archive tree is rooted at srcDir's children (srcDir itself is
|
|
// not included as a node), mirroring `xar -cf dst -C srcDir <entries...>`.
|
|
func writeXar(srcDir, dstPath string) error {
|
|
entries, err := buildXarTree(srcDir)
|
|
if err != nil {
|
|
return fmt.Errorf("build xar tree: %w", err)
|
|
}
|
|
|
|
// Lay out the heap. Offset 0 is reserved for the compressed-TOC checksum,
|
|
// so file data starts at xarChecksumSize.
|
|
var heap bytes.Buffer
|
|
cursor := xarChecksumSize
|
|
nextID := 1
|
|
if err := layoutXarHeap(entries, &heap, &cursor, &nextID); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Build and compress the TOC.
|
|
toc := buildXarTOC(entries)
|
|
var compressed bytes.Buffer
|
|
zw := zlib.NewWriter(&compressed)
|
|
if _, err := zw.Write(toc); err != nil {
|
|
return fmt.Errorf("compress toc: %w", err)
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
return fmt.Errorf("close toc writer: %w", err)
|
|
}
|
|
|
|
tocChecksum := sha1.Sum(compressed.Bytes()) //nolint:gosec // required by the xar format
|
|
|
|
// Assemble the archive: header + compressed TOC + heap(checksum + data).
|
|
var out bytes.Buffer
|
|
if err := writeXarHeader(&out, len(compressed.Bytes()), len(toc)); err != nil {
|
|
return err
|
|
}
|
|
out.Write(compressed.Bytes())
|
|
out.Write(tocChecksum[:])
|
|
out.Write(heap.Bytes())
|
|
|
|
if err := os.WriteFile(dstPath, out.Bytes(), 0o644); err != nil {
|
|
return fmt.Errorf("write xar: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// writeXarHeader writes the 28-byte big-endian xar header.
|
|
func writeXarHeader(w *bytes.Buffer, compressedTOCLen, uncompressedTOCLen int) error {
|
|
fields := []any{
|
|
xarMagic,
|
|
xarHeaderSize,
|
|
xarVersion,
|
|
uint64(compressedTOCLen), //nolint:gosec // slice length is non-negative
|
|
uint64(uncompressedTOCLen), //nolint:gosec // slice length is non-negative
|
|
xarChecksumSHA1,
|
|
}
|
|
for _, f := range fields {
|
|
if err := binary.Write(w, binary.BigEndian, f); err != nil {
|
|
return fmt.Errorf("write xar header: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildXarTree reads dir and returns its immediate children as xar entries,
|
|
// recursing into subdirectories. Entries are sorted by name for deterministic
|
|
// output.
|
|
func buildXarTree(dir string) ([]*xarEntry, error) {
|
|
dirEntries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var entries []*xarEntry
|
|
for _, de := range dirEntries {
|
|
full := filepath.Join(dir, de.Name())
|
|
info, err := de.Info()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
entry := &xarEntry{name: de.Name(), mode: info.Mode().Perm()}
|
|
if de.IsDir() {
|
|
entry.isDir = true
|
|
children, err := buildXarTree(full)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entry.children = children
|
|
} else {
|
|
data, err := os.ReadFile(full)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entry.data = data
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
|
|
sort.Slice(entries, func(i, j int) bool { return entries[i].name < entries[j].name })
|
|
return entries, nil
|
|
}
|
|
|
|
// layoutXarHeap walks the tree in depth-first order, appending each file's data
|
|
// to heap and recording its id, offset, size, and checksum. Directories consume
|
|
// no heap space but still receive an id. cursor tracks the next free heap offset.
|
|
func layoutXarHeap(entries []*xarEntry, heap *bytes.Buffer, cursor *int64, nextID *int) error {
|
|
for _, e := range entries {
|
|
e.id = *nextID
|
|
*nextID++
|
|
|
|
if e.isDir {
|
|
if err := layoutXarHeap(e.children, heap, cursor, nextID); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
|
|
sum := sha1.Sum(e.data) //nolint:gosec // required by the xar format
|
|
e.sha1 = fmt.Sprintf("%x", sum)
|
|
e.size = int64(len(e.data))
|
|
e.offset = *cursor
|
|
heap.Write(e.data)
|
|
*cursor += e.size
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildXarTOC renders the TOC XML for the archive tree.
|
|
func buildXarTOC(entries []*xarEntry) []byte {
|
|
var b strings.Builder
|
|
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
|
|
b.WriteString("<xar>\n")
|
|
b.WriteString(" <toc>\n")
|
|
b.WriteString(` <checksum style="sha1">` + "\n")
|
|
fmt.Fprintf(&b, " <size>%d</size>\n", xarChecksumSize)
|
|
b.WriteString(" <offset>0</offset>\n")
|
|
b.WriteString(" </checksum>\n")
|
|
for _, e := range entries {
|
|
writeXarTOCEntry(&b, e, 2)
|
|
}
|
|
b.WriteString(" </toc>\n")
|
|
b.WriteString("</xar>\n")
|
|
return []byte(b.String())
|
|
}
|
|
|
|
// writeXarTOCEntry renders a single <file> element (recursing into directory
|
|
// children) at the given indentation depth.
|
|
func writeXarTOCEntry(b *strings.Builder, e *xarEntry, depth int) {
|
|
ind := strings.Repeat(" ", depth)
|
|
fmt.Fprintf(b, "%s<file id=\"%d\">\n", ind, e.id)
|
|
fmt.Fprintf(b, "%s <name>%s</name>\n", ind, xarEscape(e.name))
|
|
if e.isDir {
|
|
fmt.Fprintf(b, "%s <type>directory</type>\n", ind)
|
|
} else {
|
|
fmt.Fprintf(b, "%s <type>file</type>\n", ind)
|
|
}
|
|
fmt.Fprintf(b, "%s <mode>0%o</mode>\n", ind, e.mode)
|
|
fmt.Fprintf(b, "%s <uid>0</uid>\n", ind)
|
|
fmt.Fprintf(b, "%s <gid>80</gid>\n", ind)
|
|
|
|
if e.isDir {
|
|
for _, c := range e.children {
|
|
writeXarTOCEntry(b, c, depth+1)
|
|
}
|
|
} else {
|
|
fmt.Fprintf(b, "%s <data>\n", ind)
|
|
fmt.Fprintf(b, "%s <archived-checksum style=\"sha1\">%s</archived-checksum>\n", ind, e.sha1)
|
|
fmt.Fprintf(b, "%s <extracted-checksum style=\"sha1\">%s</extracted-checksum>\n", ind, e.sha1)
|
|
fmt.Fprintf(b, "%s <size>%d</size>\n", ind, e.size)
|
|
fmt.Fprintf(b, "%s <offset>%d</offset>\n", ind, e.offset)
|
|
fmt.Fprintf(b, "%s <encoding style=\"application/octet-stream\"/>\n", ind)
|
|
fmt.Fprintf(b, "%s <length>%d</length>\n", ind, e.size)
|
|
fmt.Fprintf(b, "%s </data>\n", ind)
|
|
}
|
|
fmt.Fprintf(b, "%s</file>\n", ind)
|
|
}
|
|
|
|
// xarEscape escapes the small set of characters that can appear in a file name
|
|
// and would otherwise be invalid in the TOC XML.
|
|
func xarEscape(s string) string {
|
|
r := strings.NewReplacer(
|
|
"&", "&",
|
|
"<", "<",
|
|
">", ">",
|
|
`"`, """,
|
|
"'", "'",
|
|
)
|
|
return r.Replace(s)
|
|
}
|