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:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
`"`, """,
|
||||
"'", "'",
|
||||
)
|
||||
return r.Replace(s)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user