Drop Docker fleetdm/bomutils dependency in fleetctl when generating pkg fleetd installers (#48915)

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 -->
This commit is contained in:
Lucas Manuel Rodriguez
2026-07-15 10:14:57 -03:00
committed by GitHub
parent 5cdf270d3f
commit ada55da5bd
7 changed files with 882 additions and 119 deletions
+6 -17
View File
@@ -1,11 +1,10 @@
# This workflow tests packaging of fleetd with the
# `fleetctl package` command.
# This workflow tests packaging of fleetd with the `fleetctl package` command
# on Linux (ubuntu-latest).
#
# It fetches the targets: orbit, osquery and fleet-desktop from the default
# (Fleet's) TUF server, https://tuf.fleetctl.com.
#
# Docker and colima are extremely unreliable on macOS Github runners
# thus this workflow is not testing MSI package generation on macOS.
# All package types (deb, rpm, pkg.tar.zst, msi, pkg) are built on ubuntu-latest.
name: Test packaging
on:
@@ -22,7 +21,6 @@ on:
- "ee/fleetctl/**.go"
- "tools/fleetctl-docker/**"
- "tools/wix-docker/**"
- "tools/bomutils-docker/**"
- ".github/workflows/test-packaging.yml"
pull_request:
paths:
@@ -33,7 +31,6 @@ on:
- "ee/fleetctl/**.go"
- "tools/fleetctl-docker/**"
- "tools/wix-docker/**"
- "tools/bomutils-docker/**"
- ".github/workflows/test-packaging.yml"
workflow_dispatch: # Manual
@@ -55,7 +52,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-4core, macos-15, macos-26]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
@@ -65,17 +62,11 @@ jobs:
egress-policy: audit
- name: Pull fleetdm/wix
if: ${{ !startsWith(matrix.os, 'macos') }}
# Run in background while other steps complete to speed up the workflow
# Run in background while other steps complete to speed up the workflow.
# Only MSI generation needs a Docker image now that pkg is pure Go.
run: |
docker pull fleetdm/wix:latest &
- name: Pull fleetdm/bomutils
if: ${{ !startsWith(matrix.os, 'macos') }}
# Run in background while other steps complete to speed up the workflow
run: |
docker pull fleetdm/bomutils:latest &
- name: Checkout Code
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
with:
@@ -108,11 +99,9 @@ jobs:
run: ./build/fleetctl package --type pkg.tar.zst --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop
- name: Build MSI
if: ${{ !startsWith(matrix.os, 'macos') }}
run: ./build/fleetctl package --type msi --enroll-secret=foo --fleet-url=https://localhost:8080
- name: Build MSI with Fleet Desktop
if: ${{ !startsWith(matrix.os, 'macos') }}
run: ./build/fleetctl package --type msi --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop
- name: Build PKG
+1
View File
@@ -0,0 +1 @@
- Removed the `fleetdm/bomutils` Docker dependency for generating macOS `.pkg` fleetd installers; the Bill of Materials and xar archive are now written by pure-Go code, so `fleetctl package --type pkg` no longer requires Docker, `mkbom`, or `xar`.
+406
View File
@@ -0,0 +1,406 @@
package packaging
import (
"bytes"
"encoding/binary"
"fmt"
"os"
"path/filepath"
"sort"
)
// This file implements a minimal, pure-Go writer for the macOS BOM (Bill of
// Materials) format, simulating the external `mkbom`/`lsbom` macOS tools.
//
// A BOM is a block store:
//
// [ 32-byte header ][ blocks... ][ vars ][ block table (index) ]
//
// The header locates the block table (an array of (offset,length) pointers, one
// per block index) and the vars section (named -> block index). Named variables
// point at the top-level structures: BomInfo, Paths, HLIndex, VIndex, Size64.
//
// "Paths" is a B-tree whose single leaf lists (PathInfo1, File) block-index
// pairs, one per path. PathInfo1 -> PathInfo2 holds the metadata (type, mode,
// uid/gid, size, checksum); File holds the parent path id and the base name.
// Ownership is fixed to root/admin (0/80), matching the previous mkbom -u0 -g80
// behavior. Per-file checksums use the POSIX cksum (CRC-32/CKSUM) algorithm,
// exactly as Apple's mkbom records them.
//
// This writer does not reproduce Apple's exact block layout byte-for-byte (its
// block table is pre-sized with a free list); it produces a compact, valid BOM
// that lsbom and the macOS Installer read identically. Byte-identical output was
// never a requirement -- an identical lsbom manifest is.
// bomChecksumTable is the CRC-32 table for polynomial 0x04C11DB7 (MSB-first),
// used by the POSIX cksum algorithm.
var bomChecksumTable = func() [256]uint32 {
var t [256]uint32
for i := range t {
c := uint32(i) << 24
for range 8 {
if c&0x80000000 != 0 {
c = (c << 1) ^ 0x04C11DB7
} else {
c <<= 1
}
}
t[i] = c
}
return t
}()
// bomChecksum computes the POSIX cksum (CRC-32/CKSUM) of data: the CRC-32 over
// the data followed by the little-endian minimal-byte encoding of its length,
// finally inverted. This matches the checksum Apple's mkbom stores per file.
func bomChecksum(data []byte) uint32 {
var crc uint32
for _, b := range data {
crc = (crc << 8) ^ bomChecksumTable[byte(crc>>24)^b]
}
for n := len(data); n != 0; n >>= 8 {
crc = (crc << 8) ^ bomChecksumTable[byte(crc>>24)^byte(n)]
}
return ^crc
}
// bomPath is one entry in the BOM path tree.
type bomPath struct {
id uint32
parentID uint32 // 0 for the root "."
name string // base name; "." for the root
isDir bool
mode uint16 // full st_mode (type bits | permissions)
size uint32
checksum uint32 // POSIX cksum of contents; 0 for directories
}
// Fixed block indices. Per-path blocks follow, starting at bomFirstPathBlock.
const (
// Block index 0 is always the null block.
bomInfoBlock = 1
bomPathsTree = 2
bomPathsLeaf = 3
bomHLIndexTree = 4
bomHLIndexLeaf = 5
bomVIndexBlock = 6
bomVIndexTree = 7
bomVIndexLeaf = 8
bomSize64Tree = 9
bomSize64Leaf = 10
bomFirstPathBlock = 11
)
// writeBom walks srcDir and writes a BOM describing its tree to dstPath, with
// all entries owned by root/admin (0/80).
func writeBom(srcDir, dstPath string) error {
paths, err := collectBomPaths(srcDir)
if err != nil {
return fmt.Errorf("collect bom paths: %w", err)
}
data, err := buildBom(paths)
if err != nil {
return err
}
if err := os.WriteFile(dstPath, data, 0o644); err != nil {
return fmt.Errorf("write bom: %w", err)
}
return nil
}
// collectBomPaths walks srcDir depth-first (children sorted by name), returning
// path entries with sequential ids assigned in that order. The root directory
// itself is recorded as ".".
func collectBomPaths(srcDir string) ([]*bomPath, error) {
info, err := os.Stat(srcDir)
if err != nil {
return nil, err
}
var (
out []*bomPath
nextID uint32 = 1
)
root := &bomPath{id: nextID, parentID: 0, name: ".", isDir: true, mode: bomUnixMode(info)}
nextID++
out = append(out, root)
var walk func(dir string, parentID uint32) error
walk = func(dir string, parentID uint32) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
for _, de := range entries {
// The fleetd payload is built only from files the packaging code
// writes directly and from TUF target tarballs unpacked by
// extractTarGz, which rejects any tar entry that is not a regular
// file or directory. The orbit "current" symlink is created by the
// postinstall script at install time, not shipped in the payload.
// So a symlink (or other special file) should never appear here;
// fail loudly rather than emit a malformed BOM entry (e.g. a type=3
// symlink with no link target).
if !de.IsDir() && !de.Type().IsRegular() {
return fmt.Errorf("unsupported file type %s for %q", de.Type(), filepath.Join(dir, de.Name()))
}
fi, err := de.Info()
if err != nil {
return err
}
p := &bomPath{id: nextID, parentID: parentID, name: de.Name(), isDir: de.IsDir(), mode: bomUnixMode(fi)}
nextID++
if !de.IsDir() {
contents, err := os.ReadFile(filepath.Join(dir, de.Name()))
if err != nil {
return err
}
p.size = uint32(len(contents)) //nolint:gosec // fleetd payload files are well under 4GB
p.checksum = bomChecksum(contents)
}
out = append(out, p)
if de.IsDir() {
if err := walk(filepath.Join(dir, de.Name()), p.id); err != nil {
return err
}
}
}
return nil
}
if err := walk(srcDir, root.id); err != nil {
return nil, err
}
return out, nil
}
// bomUnixMode returns the full st_mode (file-type bits OR-ed with permission
// bits) for a file, as stored in a BOM.
func bomUnixMode(info os.FileInfo) uint16 {
perm := uint16(info.Mode().Perm()) //nolint:gosec // permission bits fit in uint16
// collectBomPaths rejects anything that isn't a regular file or directory,
// so only those two types reach here.
if info.IsDir() {
return 0o040000 | perm
}
return 0o100000 | perm
}
// buildBom assembles the full BOM byte stream for the given path entries.
func buildBom(paths []*bomPath) ([]byte, error) {
n := len(paths)
blocks := make([][]byte, bomFirstPathBlock+3*n)
// Per-path blocks, laid out as [PathInfo2, File, PathInfo1] per path (the
// same ordering Apple's mkbom uses).
type leafEntry struct {
parentID uint32
name string
pi1Idx, fileID uint32
}
entries := make([]leafEntry, 0, n)
for k, p := range paths {
pi2Idx := bomFirstPathBlock + 3*k
fileIdx := pi2Idx + 1
pi1Idx := pi2Idx + 2
blocks[pi2Idx] = buildBomPathInfo2(p)
blocks[fileIdx] = buildBomFile(p)
blocks[pi1Idx] = buildBomPathInfo1(p.id, uint32(pi2Idx)) //nolint:gosec // block index fits uint32
entries = append(entries, leafEntry{p.parentID, p.name, uint32(pi1Idx), uint32(fileIdx)}) //nolint:gosec // block indices fit uint32
}
// The Paths leaf is a B-tree node keyed by (parent id, name): lsbom and the
// Installer traverse it in key order, so the pairs must be sorted that way.
sort.Slice(entries, func(i, j int) bool {
if entries[i].parentID != entries[j].parentID {
return entries[i].parentID < entries[j].parentID
}
return entries[i].name < entries[j].name
})
leafPairs := make([][2]uint32, len(entries))
for i, e := range entries {
leafPairs[i] = [2]uint32{e.pi1Idx, e.fileID}
}
// Fixed structures.
blocks[bomInfoBlock] = buildBomInfo(uint32(n) + 1) //nolint:gosec // path count fits uint32
blocks[bomPathsTree] = buildBomTree(bomPathsLeaf, uint32(n), 4096) //nolint:gosec // path count fits uint32
blocks[bomPathsLeaf] = buildBomLeaf(leafPairs)
blocks[bomHLIndexTree] = buildBomTree(bomHLIndexLeaf, 0, 4096)
blocks[bomHLIndexLeaf] = buildBomLeaf(nil)
blocks[bomVIndexBlock] = buildBomVIndex(bomVIndexTree)
blocks[bomVIndexTree] = buildBomTree(bomVIndexLeaf, 0, 128)
blocks[bomVIndexLeaf] = buildBomLeaf(nil)
blocks[bomSize64Tree] = buildBomTree(bomSize64Leaf, 0, 4096)
blocks[bomSize64Leaf] = buildBomLeaf(nil)
// Lay out block data after the 32-byte header, recording each block's
// address. The null block (index 0) has address 0 and length 0.
addrs := make([]uint32, len(blocks))
var body bytes.Buffer
cursor := uint32(32)
for i := 1; i < len(blocks); i++ {
addrs[i] = cursor
body.Write(blocks[i])
cursor += uint32(len(blocks[i])) //nolint:gosec // block sizes are small
}
// Vars section, then the block table (index).
vars := buildBomVars()
varsOffset := cursor
cursor += uint32(len(vars)) //nolint:gosec // vars section is tiny
index := buildBomIndex(blocks, addrs)
indexOffset := cursor
var out bytes.Buffer
out.WriteString("BOMStore")
be := binary.BigEndian
writeU32 := func(v uint32) { _ = binary.Write(&out, be, v) }
writeU32(1) // version
writeU32(uint32(len(blocks) - 1)) //nolint:gosec // number of non-null blocks
writeU32(indexOffset) // indexOffset
writeU32(uint32(len(index))) //nolint:gosec // indexLength
writeU32(varsOffset) // varsOffset
writeU32(uint32(len(vars))) //nolint:gosec // varsLength
out.Write(body.Bytes())
out.Write(vars)
out.Write(index)
return out.Bytes(), nil
}
// buildBomPathInfo2 renders the metadata block for a path (35 bytes for files,
// 31 for directories). Ownership is fixed to uid 0 / gid 80 (root/admin).
func buildBomPathInfo2(p *bomPath) []byte {
var b bytes.Buffer
be := binary.BigEndian
typ := byte(1) // regular file
if p.isDir {
typ = 2 // directory
}
b.WriteByte(typ)
b.WriteByte(1) // unknown0 (always 1)
_ = binary.Write(&b, be, uint16(3)) // architecture
_ = binary.Write(&b, be, p.mode)
_ = binary.Write(&b, be, uint32(0)) // uid = root
_ = binary.Write(&b, be, uint32(80)) // gid = admin
_ = binary.Write(&b, be, uint32(0)) // mtime (0, matching mkbom -i)
_ = binary.Write(&b, be, p.size)
b.WriteByte(1) // unknown1 (always 1)
_ = binary.Write(&b, be, p.checksum)
_ = binary.Write(&b, be, uint32(0)) // linkNameLength (no symlink targets in our payload)
if !p.isDir {
_ = binary.Write(&b, be, uint32(0)) // trailing reserved word present only on files
}
return b.Bytes()
}
// buildBomFile renders a File block: parent path id followed by the NUL-
// terminated base name.
func buildBomFile(p *bomPath) []byte {
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, p.parentID)
b.WriteString(p.name)
b.WriteByte(0)
return b.Bytes()
}
// buildBomPathInfo1 renders a PathInfo1 block: the path id and the block index
// of its PathInfo2.
func buildBomPathInfo1(id, pathInfo2Block uint32) []byte {
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, id)
_ = binary.Write(&b, binary.BigEndian, pathInfo2Block)
return b.Bytes()
}
// buildBomTree renders a "tree" block pointing at its (single) child leaf.
func buildBomTree(childBlock, pathCount, blockSize uint32) []byte {
var b bytes.Buffer
be := binary.BigEndian
b.WriteString("tree")
_ = binary.Write(&b, be, uint32(1)) // version
_ = binary.Write(&b, be, childBlock)
_ = binary.Write(&b, be, blockSize)
_ = binary.Write(&b, be, pathCount)
b.WriteByte(0) // unknown
return b.Bytes()
}
// buildBomLeaf renders a B-tree leaf listing (index0, index1) pairs.
func buildBomLeaf(pairs [][2]uint32) []byte {
var b bytes.Buffer
be := binary.BigEndian
_ = binary.Write(&b, be, uint16(1)) // isLeaf
_ = binary.Write(&b, be, uint16(len(pairs))) //nolint:gosec // pair count fits uint16
_ = binary.Write(&b, be, uint32(0)) // forward
_ = binary.Write(&b, be, uint32(0)) // backward
for _, pr := range pairs {
_ = binary.Write(&b, be, pr[0])
_ = binary.Write(&b, be, pr[1])
}
return b.Bytes()
}
// buildBomVIndex renders the VIndex wrapper: {version, tree block index, flag}.
func buildBomVIndex(treeBlock uint32) []byte {
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, uint32(1))
_ = binary.Write(&b, binary.BigEndian, treeBlock)
b.WriteByte(0)
return b.Bytes()
}
// buildBomInfo renders the BomInfo block.
func buildBomInfo(numPaths uint32) []byte {
var b bytes.Buffer
be := binary.BigEndian
_ = binary.Write(&b, be, uint32(1)) // version
_ = binary.Write(&b, be, numPaths)
_ = binary.Write(&b, be, uint32(0)) // numberOfInfoEntries
return b.Bytes()
}
// buildBomVars renders the named variables pointing at the top-level blocks.
func buildBomVars() []byte {
vars := []struct {
name string
block uint32
}{
{"BomInfo", bomInfoBlock},
{"Paths", bomPathsTree},
{"HLIndex", bomHLIndexTree},
{"VIndex", bomVIndexBlock},
{"Size64", bomSize64Tree},
}
var b bytes.Buffer
be := binary.BigEndian
_ = binary.Write(&b, be, uint32(len(vars))) //nolint:gosec // small fixed count
for _, v := range vars {
_ = binary.Write(&b, be, v.block)
b.WriteByte(byte(len(v.name))) //nolint:gosec // var names are short constants
b.WriteString(v.name)
}
return b.Bytes()
}
// buildBomIndex renders the block table: a pointer (offset, length) per block
// index, followed by an empty free list.
func buildBomIndex(blocks [][]byte, addrs []uint32) []byte {
var b bytes.Buffer
be := binary.BigEndian
_ = binary.Write(&b, be, uint32(len(blocks))) //nolint:gosec // block count fits uint32
for i := range blocks {
_ = binary.Write(&b, be, addrs[i])
_ = binary.Write(&b, be, uint32(len(blocks[i]))) //nolint:gosec // block sizes are small
}
_ = binary.Write(&b, be, uint32(0)) // free-list count
return b.Bytes()
}
+85
View File
@@ -0,0 +1,85 @@
//go:build darwin
package packaging
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestWriteBomMatchesMkbom builds a BOM two ways for the same tree -- via the
// native mkbom pipeline used by xarBom (mkbom -> lsbom -> 0/80 transform ->
// mkbom -i) and via the pure-Go writeBom -- then asserts lsbom reports an
// identical manifest for both. This is the functional-equivalence bar for the
// mkbom replacement.
func TestWriteBomMatchesMkbom(t *testing.T) {
for _, tool := range []string{"mkbom", "lsbom"} {
if _, err := exec.LookPath(tool); err != nil {
t.Skipf("%s not available", tool)
}
}
root := t.TempDir()
// A representative tree: nested dirs, an empty file, a binary-ish file, a
// name with a space, and varied permissions.
writeFile(t, filepath.Join(root, "opt", "orbit", "secret.txt"), []byte("SUPERSECRET"), 0o600)
writeFile(t, filepath.Join(root, "opt", "orbit", "osquery.flags"), []byte{}, 0o600)
writeFile(t, filepath.Join(root, "opt", "orbit", "bin", "orbit"), []byte("\x7fELF binary-ish payload"), 0o755)
writeFile(t, filepath.Join(root, "Library", "LaunchDaemons", "com.fleetdm.orbit.plist"), []byte("<plist/>\n"), 0o644)
writeFile(t, filepath.Join(root, "opt", "orbit", "bin", "desktop", "Fleet Desktop.app", "Contents", "Info.plist"), []byte("<x/>"), 0o644)
// Reference BOM via the native pipeline (mirrors xarBom's darwin branch).
refBom := filepath.Join(root, "..", "ref.bom")
inBom := filepath.Join(t.TempDir(), "inBom")
require.NoError(t, exec.Command("mkbom", root, inBom).Run()) //nolint:gosec
lsOut, err := exec.Command("lsbom", inBom).Output() //nolint:gosec
require.NoError(t, err)
// Rewrite ownership to root/admin (0/80), as the old darwin pipeline did.
transformed := regexp.MustCompile(`(.+)\t([0-9]+/[0-9]+)`).ReplaceAll(lsOut, []byte("$1\t0/80"))
require.NoError(t, os.WriteFile(inBom, transformed, 0o644))
cmd := exec.Command("mkbom", "-i", inBom, refBom) //nolint:gosec
require.NoError(t, cmd.Run())
// Pure-Go BOM.
myBom := filepath.Join(t.TempDir(), "my.bom")
require.NoError(t, writeBom(root, myBom))
require.Equal(t, sortedLsbom(t, refBom), sortedLsbom(t, myBom),
"lsbom manifest of writeBom output must match the native mkbom pipeline")
}
// TestWriteBomRejectsSymlink verifies writeBom fails loudly on a symlink rather
// than emitting a malformed BOM entry. Symlinks cannot legitimately appear in a
// fleetd payload (extractTarGz rejects them; the orbit "current" symlink is
// created by postinstall at install time), so this is a defensive guard.
func TestWriteBomRejectsSymlink(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "real.txt"), []byte("hi"), 0o644)
require.NoError(t, os.Symlink("real.txt", filepath.Join(root, "link.txt")))
err := writeBom(root, filepath.Join(t.TempDir(), "out.bom"))
require.Error(t, err)
require.Contains(t, err.Error(), "unsupported file type")
}
func writeFile(t *testing.T, path string, data []byte, mode os.FileMode) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, data, mode))
}
func sortedLsbom(t *testing.T, bom string) string {
t.Helper()
out, err := exec.Command("lsbom", bom).Output() //nolint:gosec
require.NoErrorf(t, err, "lsbom failed to read %s", bom)
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
sort.Strings(lines)
return strings.Join(lines, "\n")
}
+10 -102
View File
@@ -8,7 +8,6 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"github.com/Masterminds/semver"
@@ -19,10 +18,6 @@ import (
"github.com/rs/zerolog/log"
)
var bomRegexp = regexp.MustCompile(`(.+)\t([0-9]+/[0-9]+)`)
// See helful docs in http://bomutils.dyndns.org/tutorial.html
// BuildPkg builds a macOS .pkg.
//
// Building packages works out of the box in macOS, but it's also supported on
@@ -148,7 +143,7 @@ func BuildPkg(opt Options) (string, error) {
// Build package
if err := xarBom(opt, tmpDir); err != nil {
if err := xarBom(tmpDir); err != nil {
return "", fmt.Errorf("build pkg: %w", err)
}
@@ -331,12 +326,8 @@ func writeUpdateClientCertificate(opt Options, orbitRoot string) error {
return nil
}
// xarBom creates the actual .pkg format. It's a xar archive with a BOM (Bill of
// materials?). See http://bomutils.dyndns.org/tutorial.html.
func xarBom(opt Options, rootPath string) error {
// Adapted from BSD licensed
// https://github.com/go-flutter-desktop/hover/blob/v0.46.2/cmd/packaging/darwin-pkg.go
// xarBom creates the actual .pkg format. It's a xar archive with a BOM (Bill of materials).
func xarBom(rootPath string) error {
// Copy payload/scripts
if err := cpio(
filepath.Join(rootPath, "root"),
@@ -351,103 +342,20 @@ func xarBom(opt Options, rootPath string) error {
return fmt.Errorf("cpio Scripts: %w", err)
}
// Make Bill of materials (bom)
var cmdMkbom *exec.Cmd
isDarwin := runtime.GOOS == "darwin"
isLinuxNative := runtime.GOOS == "linux" && opt.NativeTooling
switch {
case isDarwin:
// Using mkbom directly results in permissions listed for the current user and group. We
// transform the output in order to explicitly set root (0) and admin (80).
inBomPath := filepath.Join(rootPath, "inBom")
cmd := exec.Command("mkbom", filepath.Join(rootPath, "root"), inBomPath)
if err := cmd.Run(); err != nil {
return fmt.Errorf("initial mkbom: %w", err)
}
bomContents, err := exec.Command("lsbom", inBomPath).Output()
if err != nil {
return fmt.Errorf("lsbom inBom: %w", err)
}
bomContents = bomReplace(bomContents)
if err := os.WriteFile(inBomPath, bomContents, 0); err != nil {
return fmt.Errorf("write inBom: %w", err)
}
// Use the file list (with transformed permissions) via -i flag
cmdMkbom = exec.Command("mkbom", "-i", "inBom", filepath.Join("flat", "base.pkg", "Bom"))
cmdMkbom.Dir = rootPath
// No need for transformation when using the Linux mkbom because of the -u and -g flags
// available in that command.
case isLinuxNative:
cmdMkbom = exec.Command(
"mkbom", "-u", "0", "-g", "80",
filepath.Join(rootPath, "root"), filepath.Join("flat", "base.pkg", "Bom"),
)
cmdMkbom.Dir = rootPath
default:
// Same as linux native, but modified for running in Docker. This should
// be either Windows, or Linux without the --native-tooling flag.
cmdMkbom = exec.Command(
"docker", "run", "--rm", "-v", rootPath+":/root", "fleetdm/bomutils",
"mkbom", "-u", "0", "-g", "80",
// Use / instead of filepath.Join because these will always be paths within the Docker
// container (so Linux file paths) -- if we use filepath.Join we'll get invalid paths on
// Windows due to use of backslashes.
"/root/root", "/root/flat/base.pkg/Bom",
)
if err := writeBom(
filepath.Join(rootPath, "root"),
filepath.Join(rootPath, "flat", "base.pkg", "Bom"),
); err != nil {
return fmt.Errorf("write bom: %w", err)
}
cmdMkbom.Stdout, cmdMkbom.Stderr = os.Stdout, os.Stderr
if err := cmdMkbom.Run(); err != nil {
return fmt.Errorf("mkbom: %w", err)
}
// List files for xar
var files []string
err := filepath.Walk(
filepath.Join(rootPath, "flat"),
func(path string, info os.FileInfo, _ error) error {
relativePath, err := filepath.Rel(filepath.Join(rootPath, "flat"), path)
if err != nil {
return err
}
files = append(files, relativePath)
return nil
},
)
if err != nil {
return fmt.Errorf("iterate files: %w", err)
}
// Make xar
var cmdXar *exec.Cmd
switch {
case isDarwin, isLinuxNative:
cmdXar = exec.Command("xar", append([]string{"--compression", "none", "-cf", filepath.Join("..", "orbit.pkg")}, files...)...)
cmdXar.Dir = filepath.Join(rootPath, "flat")
default:
cmdXar = exec.Command(
"docker", "run", "--rm", "-v", rootPath+":/root", "-w", "/root/flat", "fleetdm/bomutils",
"xar",
)
cmdXar.Args = append(cmdXar.Args, append([]string{"--compression", "none", "-cf", "/root/orbit.pkg"}, files...)...)
}
cmdXar.Stdout, cmdXar.Stderr = os.Stdout, os.Stderr
if err := cmdXar.Run(); err != nil {
return fmt.Errorf("run xar: %w", err)
if err := writeXar(filepath.Join(rootPath, "flat"), filepath.Join(rootPath, "orbit.pkg")); err != nil {
return fmt.Errorf("write xar: %w", err)
}
return nil
}
// bomReplace replaces the permission strings (typically "501/20") with the appropriate string ("0/80")
func bomReplace(inBom []byte) []byte {
return bomRegexp.ReplaceAll(inBom, []byte("$1\t0/80"))
}
func cpio(srcPath, dstPath string) error {
// This is the compression routine that is expected for pkg files.
dst, err := secure.OpenFile(dstPath, os.O_RDWR|os.O_CREATE, 0o755)
+244
View File
@@ -0,0 +1,244 @@
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(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&apos;",
)
return r.Replace(s)
}
+130
View File
@@ -0,0 +1,130 @@
package packaging
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/stretchr/testify/require"
)
// These tests exercise the pure-Go xar writer (writeXar) by feeding its output
// to Fleet's own xar decoder in pkg/file. A xar that round-trips through the
// decoder proves the encoder produces a well-formed header, a valid zlib TOC,
// and correct heap offsets/lengths (the decoder reads members back by those
// offsets). The writer is platform-independent, so these run everywhere.
const testDistribution = `<?xml version="1.0" encoding="utf-8"?>
<installer-gui-script minSpecVersion="2">
<title>Fleet osquery</title>
<product id="com.fleetdm.orbit" version="1.2.3"/>
<pkg-ref id="com.fleetdm.orbit.base.pkg" version="1.2.3" packageIdentifier="com.fleetdm.orbit"/>
</installer-gui-script>
`
const testPackageInfo = `<pkg-info format-version="2" identifier="com.fleetdm.orbit" version="9.9.9" install-location="/" auth="root"/>
`
// writeXarTree writes the given name->contents map (names may contain "/" to
// create nested directories) into a fresh temp dir and returns its path.
func writeXarTree(t *testing.T, files map[string][]byte) string {
t.Helper()
root := t.TempDir()
for name, data := range files {
p := filepath.Join(root, name)
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
require.NoError(t, os.WriteFile(p, data, 0o644))
}
return root
}
// buildXar runs writeXar over root and returns the archive bytes.
func buildXar(t *testing.T, root string) []byte {
t.Helper()
out := filepath.Join(t.TempDir(), "out.pkg")
require.NoError(t, writeXar(root, out))
b, err := os.ReadFile(out)
require.NoError(t, err)
return b
}
func extractXARMetadata(t *testing.T, xarBytes []byte) *file.InstallerMetadata {
t.Helper()
tfr, err := fleet.NewTempFileReader(bytes.NewReader(xarBytes), t.TempDir)
require.NoError(t, err)
t.Cleanup(func() { _ = tfr.Close() })
meta, err := file.ExtractXARMetadata(tfr)
require.NoError(t, err)
return meta
}
// TestWriteXarReadableByDecoder builds a distribution-style .pkg tree and
// verifies the pure-Go writer's output is a valid xar: parseable header + TOC,
// a discoverable Distribution member, and metadata read back correctly from the
// heap.
func TestWriteXarReadableByDecoder(t *testing.T) {
root := writeXarTree(t, map[string][]byte{
"Distribution": []byte(testDistribution),
"base.pkg/PackageInfo": []byte(testPackageInfo),
"base.pkg/Payload": bytes.Repeat([]byte("orbit-payload-bytes\n"), 1000),
})
xarBytes := buildXar(t, root)
// Valid, unsigned xar: exercises magic-byte check, SHA-1 hash-type mapping,
// zlib TOC decompression, and TOC XML parsing.
require.ErrorIs(t, file.CheckPKGSignature(bytes.NewReader(xarBytes)), file.ErrNotSigned)
// The TOC lists the top-level Distribution file.
hasDist, err := file.XARHasDistribution(bytes.NewReader(xarBytes))
require.NoError(t, err)
require.True(t, hasDist)
// Reading the Distribution member back (via its <offset>/<length> within the
// heap that begins with the 20-byte TOC checksum) yields exactly the bytes we
// wrote; if any offset/length were off the XML parse would fail.
meta := extractXARMetadata(t, xarBytes)
require.Equal(t, "Fleet osquery", meta.Name)
require.Equal(t, "1.2.3", meta.Version)
require.Equal(t, "com.fleetdm.orbit", meta.BundleIdentifier)
require.Contains(t, meta.PackageIDs, "com.fleetdm.orbit")
}
// TestWriteXarPackageInfoFallback verifies a component-style .pkg (top-level
// PackageInfo, no Distribution) also round-trips: the decoder falls back to
// PackageInfo, which again requires the writer's heap offsets to be correct.
func TestWriteXarPackageInfoFallback(t *testing.T) {
root := writeXarTree(t, map[string][]byte{
"PackageInfo": []byte(testPackageInfo),
})
xarBytes := buildXar(t, root)
hasDist, err := file.XARHasDistribution(bytes.NewReader(xarBytes))
require.NoError(t, err)
require.False(t, hasDist)
meta := extractXARMetadata(t, xarBytes)
require.Equal(t, "9.9.9", meta.Version)
require.Equal(t, "com.fleetdm.orbit", meta.BundleIdentifier)
}
// TestWriteXarEmptyAndNestedDirs ensures the writer handles empty files and
// nested directories without corrupting the archive (the decoder still parses
// the header/TOC and finds the Distribution).
func TestWriteXarEmptyAndNestedDirs(t *testing.T) {
root := writeXarTree(t, map[string][]byte{
"Distribution": []byte(testDistribution),
"base.pkg/empty": {},
"base.pkg/nested/deep/Info.plist": []byte("<plist/>"),
})
// An empty directory too (map above only creates dirs with files).
require.NoError(t, os.MkdirAll(filepath.Join(root, "Resources"), 0o755))
xarBytes := buildXar(t, root)
require.ErrorIs(t, file.CheckPKGSignature(bytes.NewReader(xarBytes)), file.ErrNotSigned)
meta := extractXARMetadata(t, xarBytes)
require.Equal(t, "Fleet osquery", meta.Name)
}