Skip to content

Commit 52af76c

Browse files
authored
feat(self-packaging #48): macOS notarised via reserved Mach-O segment (#58)
* feat: macOS notarised self-packaging via reserved Mach-O segment Part of #40. Closes #48. macOS-notarised distribution requires a stable binary signature. The Linux/Windows "append a ZIP after EOF" trick invalidates the signature because trailing bytes after __LINKEDIT aren't covered. This PR introduces the reserved-segment approach used by AppImage, PyInstaller, and friends: 1. The macOS build allocates a placeholder __FLAPI/__bundle Mach-O section at link time (default 16 MiB, knob FLAPI_RESERVED_BUNDLE_MIB). 2. `flapi pack` overwrites the segment in place rather than appending after EOF. 3. `pack` re-invokes `codesign` after writing, so the freshly bundled binary has a fresh, valid signature. 4. The runtime locator looks for the reserved segment first; Linux/Windows binaries don't have one, so behaviour there is unchanged. Implementation: - src/include/macho_bundle.hpp + src/macho_bundle.cpp -- 64-bit Mach-O parser (header + LC_SEGMENT_64 + section_64), no external deps, compiles on all platforms. CodesignBinary() wraps popen for the macOS path and is a benign no-op elsewhere. - CMakeLists.txt (APPLE branch only) -- adds the placeholder file via add_custom_command + dd, and links `flapi` with -Wl,-sectcreate,__FLAPI,__bundle,<placeholder>. Linux/Windows builds skip this entirely. - src/bundle_locator.{hpp,cpp} -- refactored. Extracted the EOCD reverse-scan into `ScanBufferForEocd`, shared by: * `LocateBundle(path)` -- existing EOF-tail scan * `LocateBundleInRange(path, off, size)` -- NEW. Scans a sub-range of the file. Used by section-mode lookup. * `LocateBundleInSelf()` -- NEW behaviour: try the macOS section first, fall back to EOF tail. - src/include/pack.hpp -- new enum `MacOSPackMode` + PackOptions fields `macos_mode` (defaults to kReservedSegment) and `codesign` (defaults to true; tests turn it off to skip the codesign call). - src/pack.cpp -- Pack() probes for the Mach-O section first; if present, copies the host whole and `OverwriteFlapiSection` writes the archive in place. If the section is absent (Linux/Windows) OR `--macos-append` was passed, falls through to the existing append-after-EOF code path. Re-signs on Darwin afterwards. - src/main.cpp -- new `--macos-append` flag on the `pack` subcommand. Threads through to PackOptions. Tests: - test/cpp/macho_bundle_test.cpp -- 7 cases / 13 assertions. Builds synthetic Mach-O fixtures byte-by-byte and feeds them to `LocateFlapiSectionInBuffer`. Covers: magic recognition, present section, absent section, wrong-segment name match attempt, short buffer, non-Mach-O input, CodesignBinary no-op on non-Darwin. - test/integration/test_self_packaging_macos.py -- 4 cases, all marked `pytest.mark.skipif(platform.system() != "Darwin")`: 1. unbundled flapi has the reserved __FLAPI/__bundle segment (otool -l check) 2. default pack passes `codesign --verify --strict` 3. `--macos-append` produces a runnable artifact with a discoverable bundle 4. oversized payload (32 MiB into a 16 MiB segment) is rejected with an error mentioning FLAPI_RESERVED_BUNDLE_MIB - All existing 14 Linux integration tests still pass; the new code is fully backward-compatible (section probe returns nullopt on Linux, code falls through to the existing append path). - 23/23 C++ unit tests in the [pack],[macho_bundle],[bundle_locator] tag set pass (62 assertions). Out of scope (deferred to follow-ups): - Fat (universal) binary support -- the parser handles thin 64-bit Mach-O only. macOS releases produced by this repo are per-architecture thin so the gap is acceptable for now. - 32-bit Mach-O. Same reasoning. Closes #48. Part of #40. * fix(cmake): bypass shell wrapper for macOS placeholder generation The previous `cmake -E env bash -c "dd ... >/dev/null 2>&1"` form broke under ninja-on-macos -- the redirect operators were consumed by the outer shell that cmake exec'd, not the bash -c subshell, so the build failed with: /bin/sh: /dev/null 2: Permission denied Drop the shell wrapper entirely. dd's argv form (`if=...`, `of=...`, `bs=1m`, `count=N`) goes straight to execve via CMake's command runner, no shell involved. dd's 2-3 line summary stays in CI logs, which is fine. Found by CI on PR #58 (#48 macOS notarised) at osx-universal-build.
1 parent c40b083 commit 52af76c

11 files changed

Lines changed: 976 additions & 70 deletions

CMakeLists.txt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ add_library(flapi-lib STATIC
250250
src/endpoint_config_parser.cpp
251251
src/extended_yaml_parser.cpp
252252
src/heartbeat_worker.cpp
253+
src/macho_bundle.cpp
253254
src/open_api_doc_generator.cpp
254255
src/pack.cpp
255256
src/password_hasher.cpp
@@ -344,6 +345,36 @@ target_link_libraries(flapi PRIVATE flapi-lib)
344345
target_compile_definitions(flapi PRIVATE FLAPI_VERSION="${CMAKE_PROJECT_VERSION}")
345346
set_target_properties(flapi PROPERTIES ENABLE_EXPORTS TRUE)
346347

348+
# macOS reserved-segment for self-packaging (#48). Allocates a
349+
# placeholder __FLAPI/__bundle section at link time that `flapi pack`
350+
# overwrites with the user's config tree, then re-signs. Required for
351+
# notarised distribution -- appending after __LINKEDIT invalidates
352+
# the signature.
353+
if(APPLE)
354+
set(FLAPI_RESERVED_BUNDLE_MIB "16" CACHE STRING
355+
"Size in MiB of the reserved __FLAPI/__bundle Mach-O segment.")
356+
set(FLAPI_BUNDLE_PLACEHOLDER
357+
"${CMAKE_BINARY_DIR}/_flapi_bundle_placeholder.bin")
358+
# Use dd directly via execve (no shell). The previous `bash -c
359+
# "... >/dev/null 2>&1"` wrapping broke under ninja-on-macos because
360+
# the redirect operators were parsed by the outer shell rather than
361+
# the bash -c subshell, producing "/dev/null 2: Permission denied".
362+
# dd prints a 2-3 line summary on stderr; that's fine in CI logs.
363+
add_custom_command(
364+
OUTPUT "${FLAPI_BUNDLE_PLACEHOLDER}"
365+
COMMAND dd
366+
if=/dev/zero
367+
"of=${FLAPI_BUNDLE_PLACEHOLDER}"
368+
bs=1m
369+
count=${FLAPI_RESERVED_BUNDLE_MIB}
370+
COMMENT "flapi: generating ${FLAPI_RESERVED_BUNDLE_MIB}-MiB __FLAPI/__bundle placeholder")
371+
add_custom_target(flapi_bundle_placeholder
372+
DEPENDS "${FLAPI_BUNDLE_PLACEHOLDER}")
373+
target_link_options(flapi PRIVATE
374+
"-Wl,-sectcreate,__FLAPI,__bundle,${FLAPI_BUNDLE_PLACEHOLDER}")
375+
add_dependencies(flapi flapi_bundle_placeholder)
376+
endif()
377+
347378
# Add Windows-specific libraries
348379
if(WIN32)
349380
target_link_libraries(flapi PRIVATE Dbghelp.lib)

src/bundle_locator.cpp

Lines changed: 113 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#include "bundle_locator.hpp"
2+
#include "macho_bundle.hpp"
23
#include "selfpath.hpp"
34

45
#include <algorithm>
@@ -14,8 +15,8 @@ namespace {
1415
constexpr std::size_t kEocdRecordSize = 22;
1516
constexpr std::size_t kMaxCommentLen = 0xffffu;
1617

17-
// We accept padding well in excess of the 10 KiB spike default; the
18-
// total tail buffer is EOCD + max comment + 64 KiB pad budget.
18+
// Total tail buffer = EOCD + max comment + 64 KiB pad budget. Generous
19+
// vs. the spike's 10 KiB libarchive tar-block default; cheap to read.
1920
constexpr std::size_t kPaddingBudget = 65536;
2021
constexpr std::size_t kScanBudget = kEocdRecordSize + kMaxCommentLen + kPaddingBudget;
2122

@@ -32,75 +33,50 @@ std::uint32_t ReadU32(const std::uint8_t* p) {
3233
| (static_cast<std::uint32_t>(p[3]) << 24);
3334
}
3435

35-
} // namespace
36-
37-
std::optional<BundleLocation> LocateBundle(const std::filesystem::path& path) {
38-
std::error_code ec;
39-
const auto file_size = std::filesystem::file_size(path, ec);
40-
if (ec || file_size < kEocdRecordSize) {
41-
return std::nullopt;
42-
}
43-
44-
std::ifstream in(path, std::ios::binary);
45-
if (!in.is_open()) {
46-
return std::nullopt;
47-
}
48-
49-
const std::size_t tail_bytes =
50-
static_cast<std::size_t>(std::min<std::uint64_t>(file_size, kScanBudget));
51-
const std::uint64_t tail_start = file_size - tail_bytes;
52-
53-
std::vector<std::uint8_t> tail(tail_bytes);
54-
in.seekg(static_cast<std::streamoff>(tail_start), std::ios::beg);
55-
in.read(reinterpret_cast<char*>(tail.data()),
56-
static_cast<std::streamsize>(tail_bytes));
57-
if (!in) {
58-
return std::nullopt;
59-
}
60-
if (tail.size() < kEocdRecordSize) {
36+
// Scan a buffer for the most-recent valid EOCD record. `buf_start_in_file`
37+
// is the absolute file offset of buf[0]; used to translate the result.
38+
// `logical_eof_in_buf` is the buf-relative position to treat as EOF for
39+
// padding-tolerance purposes (== buf.size() in the common case).
40+
std::optional<BundleLocation> ScanBufferForEocd(
41+
const std::vector<std::uint8_t>& buf,
42+
std::uint64_t buf_start_in_file,
43+
std::size_t logical_eof_in_buf) {
44+
if (logical_eof_in_buf > buf.size() || logical_eof_in_buf < kEocdRecordSize) {
6145
return std::nullopt;
6246
}
63-
64-
// Reverse-scan from the latest valid signature position. The latest
65-
// (largest-offset) EOCD wins, since any earlier signature byte
66-
// sequence in random leading data is a false positive.
67-
const std::size_t max_start = tail.size() - kEocdRecordSize;
47+
const std::size_t max_start = logical_eof_in_buf - kEocdRecordSize;
6848
for (std::size_t i = max_start + 1; i-- > 0; ) {
69-
if (tail[i] != 0x50 ||
70-
tail[i + 1] != 0x4b ||
71-
tail[i + 2] != 0x05 ||
72-
tail[i + 3] != 0x06) {
49+
if (buf[i] != 0x50 ||
50+
buf[i + 1] != 0x4b ||
51+
buf[i + 2] != 0x05 ||
52+
buf[i + 3] != 0x06) {
7353
continue;
7454
}
55+
const std::uint8_t* p = buf.data() + i;
56+
const std::uint16_t this_disk = ReadU16(p + 4);
57+
const std::uint16_t cd_start_disk = ReadU16(p + 6);
58+
const std::uint16_t entries_this = ReadU16(p + 8);
59+
const std::uint16_t entries_total = ReadU16(p + 10);
60+
const std::uint32_t cd_size = ReadU32(p + 12);
61+
const std::uint32_t cd_offset_arch = ReadU32(p + 16);
62+
const std::uint16_t comment_len = ReadU16(p + 20);
7563

76-
const std::uint8_t* p = tail.data() + i;
77-
const std::uint16_t this_disk = ReadU16(p + 4);
78-
const std::uint16_t cd_start_disk = ReadU16(p + 6);
79-
const std::uint16_t entries_this = ReadU16(p + 8);
80-
const std::uint16_t entries_total = ReadU16(p + 10);
81-
const std::uint32_t cd_size = ReadU32(p + 12);
82-
const std::uint32_t cd_offset_arch = ReadU32(p + 16);
83-
const std::uint16_t comment_len = ReadU16(p + 20);
84-
85-
// Multi-disk archives are not supported.
8664
if (this_disk != 0 || cd_start_disk != 0) {
8765
continue;
8866
}
8967
if (entries_this != entries_total) {
9068
continue;
9169
}
9270

93-
// The comment must fit in the tail.
9471
const std::size_t comment_end = i + kEocdRecordSize + comment_len;
95-
if (comment_end > tail.size()) {
72+
if (comment_end > logical_eof_in_buf) {
9673
continue;
9774
}
9875

99-
// Anything after the comment up to file-EOF must be zero
100-
// padding -- the libarchive tar-block rounding tolerance.
76+
// Anything after the comment up to logical EOF must be zero.
10177
bool padding_ok = true;
102-
for (std::size_t j = comment_end; j < tail.size(); ++j) {
103-
if (tail[j] != 0) {
78+
for (std::size_t j = comment_end; j < logical_eof_in_buf; ++j) {
79+
if (buf[j] != 0) {
10480
padding_ok = false;
10581
break;
10682
}
@@ -109,36 +85,111 @@ std::optional<BundleLocation> LocateBundle(const std::filesystem::path& path) {
10985
continue;
11086
}
11187

112-
const std::uint64_t eocd_file_offset = tail_start + i;
113-
114-
// The central directory sits immediately before the EOCD.
88+
const std::uint64_t eocd_file_offset = buf_start_in_file + i;
11589
if (cd_size > eocd_file_offset) {
11690
continue;
11791
}
11892
const std::uint64_t cd_file_offset = eocd_file_offset - cd_size;
119-
12093
if (cd_offset_arch > cd_file_offset) {
12194
continue;
12295
}
12396
const std::uint64_t bundle_start = cd_file_offset - cd_offset_arch;
124-
125-
const std::uint64_t bundle_end = eocd_file_offset + kEocdRecordSize + comment_len;
97+
const std::uint64_t bundle_end =
98+
eocd_file_offset + kEocdRecordSize + comment_len;
12699
if (bundle_end < bundle_start) {
127-
continue; // overflow paranoia
100+
continue;
128101
}
129102

130103
BundleLocation loc;
131104
loc.offset = bundle_start;
132105
loc.size = bundle_end - bundle_start;
133106
return loc;
134107
}
135-
136108
return std::nullopt;
137109
}
138110

111+
} // namespace
112+
113+
std::optional<BundleLocation> LocateBundle(const std::filesystem::path& path) {
114+
std::error_code ec;
115+
const auto file_size = std::filesystem::file_size(path, ec);
116+
if (ec || file_size < kEocdRecordSize) {
117+
return std::nullopt;
118+
}
119+
120+
std::ifstream in(path, std::ios::binary);
121+
if (!in.is_open()) {
122+
return std::nullopt;
123+
}
124+
125+
const std::size_t tail_bytes =
126+
static_cast<std::size_t>(std::min<std::uint64_t>(file_size, kScanBudget));
127+
const std::uint64_t tail_start = file_size - tail_bytes;
128+
129+
std::vector<std::uint8_t> tail(tail_bytes);
130+
in.seekg(static_cast<std::streamoff>(tail_start), std::ios::beg);
131+
in.read(reinterpret_cast<char*>(tail.data()),
132+
static_cast<std::streamsize>(tail_bytes));
133+
if (!in) {
134+
return std::nullopt;
135+
}
136+
return ScanBufferForEocd(tail, tail_start, tail.size());
137+
}
138+
139+
std::optional<BundleLocation> LocateBundleInRange(
140+
const std::filesystem::path& path,
141+
std::uint64_t range_offset,
142+
std::uint64_t range_size) {
143+
if (range_size < kEocdRecordSize) {
144+
return std::nullopt;
145+
}
146+
std::error_code ec;
147+
const auto file_size = std::filesystem::file_size(path, ec);
148+
if (ec || range_offset + range_size > file_size) {
149+
return std::nullopt;
150+
}
151+
// Cap range_size at something sensible -- a runaway section_size
152+
// value from a malformed Mach-O could otherwise allocate gigabytes.
153+
constexpr std::uint64_t kMaxRangeBytes = 64ull * 1024ull * 1024ull;
154+
const std::size_t to_read =
155+
static_cast<std::size_t>(std::min<std::uint64_t>(range_size, kMaxRangeBytes));
156+
157+
std::ifstream in(path, std::ios::binary);
158+
if (!in.is_open()) {
159+
return std::nullopt;
160+
}
161+
std::vector<std::uint8_t> buf(to_read);
162+
in.seekg(static_cast<std::streamoff>(range_offset), std::ios::beg);
163+
in.read(reinterpret_cast<char*>(buf.data()),
164+
static_cast<std::streamsize>(to_read));
165+
if (!in) {
166+
return std::nullopt;
167+
}
168+
return ScanBufferForEocd(buf, range_offset, buf.size());
169+
}
170+
139171
std::optional<BundleLocation> LocateBundleInSelf() {
172+
std::filesystem::path self_path;
173+
try {
174+
self_path = GetSelfPath();
175+
} catch (...) {
176+
return std::nullopt;
177+
}
178+
179+
// Prefer the reserved Mach-O section if present (#48). Linux/Windows
180+
// binaries lack the section, so this returns nullopt and we fall
181+
// through to the EOF-tail scan unchanged.
182+
if (auto sect = LocateFlapiSection(self_path); sect.has_value()) {
183+
if (auto loc = LocateBundleInRange(self_path, sect->file_offset, sect->size);
184+
loc.has_value()) {
185+
return loc;
186+
}
187+
// Section is present but empty / unpopulated (e.g., un-packed
188+
// build): fall through to the EOF-tail scan.
189+
}
190+
140191
try {
141-
return LocateBundle(GetSelfPath());
192+
return LocateBundle(self_path);
142193
} catch (...) {
143194
return std::nullopt;
144195
}

src/include/bundle_locator.hpp

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,20 @@ struct BundleLocation {
2727
// rounding pushing the EOCD off file-EOF.
2828
std::optional<BundleLocation> LocateBundle(const std::filesystem::path& path);
2929

30-
// Convenience: scan the currently running executable. Returns nullopt
31-
// if either the self-path lookup or the EOCD scan fails.
30+
// Scans a specific byte range of `path` for a ZIP EOCD. Used by the
31+
// macOS section-mode locator (#48) where the bundle lives inside a
32+
// reserved Mach-O segment, not at file EOF. The returned
33+
// BundleLocation.offset is the absolute file offset; padding-tolerance
34+
// runs against `range_size` (treat range end as logical EOF).
35+
std::optional<BundleLocation> LocateBundleInRange(
36+
const std::filesystem::path& path,
37+
std::uint64_t range_offset,
38+
std::uint64_t range_size);
39+
40+
// Convenience: scan the currently running executable. On macOS, first
41+
// looks at the reserved __FLAPI/__bundle Mach-O section; if absent or
42+
// unpopulated, falls back to a reverse-EOF scan. Returns nullopt if
43+
// either fails.
3244
std::optional<BundleLocation> LocateBundleInSelf();
3345

3446
} // namespace flapi

src/include/macho_bundle.hpp

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#pragma once
2+
3+
#include <cstdint>
4+
#include <filesystem>
5+
#include <optional>
6+
#include <string>
7+
#include <vector>
8+
9+
namespace flapi {
10+
11+
// The reserved Mach-O segment+section we use to host the bundled
12+
// ZIP on macOS. Allocated at link time with a placeholder of
13+
// FLAPI_RESERVED_BUNDLE_MIB MiB and overwritten by `flapi pack`
14+
// at packaging time so the binary signature stays valid after a
15+
// re-`codesign`.
16+
constexpr const char* kFlapiSegName = "__FLAPI";
17+
constexpr const char* kFlapiSectName = "__bundle";
18+
19+
struct MachOSection {
20+
// File offset of the section's first byte, suitable for seek().
21+
std::uint64_t file_offset = 0;
22+
// Allocated section size in bytes (== reserved capacity).
23+
std::uint64_t size = 0;
24+
};
25+
26+
// Returns true if the bytes at `magic_bytes` (read from the head of
27+
// a file) are a Mach-O magic value we recognise. Cheap pre-check.
28+
bool IsMachOMagic(const std::uint8_t magic_bytes[4]);
29+
30+
// Locate the __FLAPI/__bundle section in a Mach-O file on disk.
31+
// Returns nullopt if:
32+
// - the file isn't a thin (non-fat) Mach-O,
33+
// - the file is malformed,
34+
// - the section doesn't exist (e.g., on a non-macOS build).
35+
//
36+
// Fat / universal binaries are currently not supported -- a follow-up
37+
// can iterate slices. macOS releases produced by this repo are thin
38+
// per-architecture, so the gap is acceptable for now.
39+
std::optional<MachOSection> LocateFlapiSection(const std::filesystem::path& path);
40+
41+
// Overload that scans a buffer instead of opening a file. Used by
42+
// unit tests against synthetic Mach-O fixtures.
43+
std::optional<MachOSection> LocateFlapiSectionInBuffer(
44+
const std::vector<std::uint8_t>& buffer);
45+
46+
// Overwrite the reserved section at `binary` with `payload`. The
47+
// trailing capacity beyond payload.size() is zero-padded so that the
48+
// EOCD of the embedded ZIP still reverse-scans cleanly from segment
49+
// EOF. Throws ArchiveIOError when the payload is bigger than the
50+
// reserved capacity (see PackOptions::host_binary_override for the
51+
// override knob).
52+
//
53+
// Caller is responsible for re-signing the binary afterwards
54+
// (CodesignBinary), since the signature covers the section bytes.
55+
void OverwriteFlapiSection(const std::filesystem::path& binary,
56+
const MachOSection& section,
57+
const std::vector<std::uint8_t>& payload);
58+
59+
// Result of a codesign attempt.
60+
struct CodesignResult {
61+
int exit_code = 0;
62+
std::string identity; // "-" for ad-hoc; otherwise the CODESIGN_IDENTITY value
63+
std::string stderr_tail; // last few KB of codesign stderr, for diagnostics
64+
};
65+
66+
// Invoke `codesign --force --sign <identity> <binary>` where identity
67+
// is taken from the CODESIGN_IDENTITY env var, defaulting to "-"
68+
// (ad-hoc). On non-Darwin builds this is a no-op that returns
69+
// exit_code = 0 -- the caller doesn't need a platform guard.
70+
CodesignResult CodesignBinary(const std::filesystem::path& binary);
71+
72+
} // namespace flapi

0 commit comments

Comments
 (0)