Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ add_library(flapi-lib STATIC
src/endpoint_config_parser.cpp
src/extended_yaml_parser.cpp
src/heartbeat_worker.cpp
src/macho_bundle.cpp
src/open_api_doc_generator.cpp
src/pack.cpp
src/password_hasher.cpp
Expand Down Expand Up @@ -344,6 +345,36 @@ target_link_libraries(flapi PRIVATE flapi-lib)
target_compile_definitions(flapi PRIVATE FLAPI_VERSION="${CMAKE_PROJECT_VERSION}")
set_target_properties(flapi PROPERTIES ENABLE_EXPORTS TRUE)

# macOS reserved-segment for self-packaging (#48). Allocates a
# placeholder __FLAPI/__bundle section at link time that `flapi pack`
# overwrites with the user's config tree, then re-signs. Required for
# notarised distribution -- appending after __LINKEDIT invalidates
# the signature.
if(APPLE)
set(FLAPI_RESERVED_BUNDLE_MIB "16" CACHE STRING
"Size in MiB of the reserved __FLAPI/__bundle Mach-O segment.")
set(FLAPI_BUNDLE_PLACEHOLDER
"${CMAKE_BINARY_DIR}/_flapi_bundle_placeholder.bin")
# Use dd directly via execve (no shell). The previous `bash -c
# "... >/dev/null 2>&1"` wrapping broke under ninja-on-macos because
# the redirect operators were parsed by the outer shell rather than
# the bash -c subshell, producing "/dev/null 2: Permission denied".
# dd prints a 2-3 line summary on stderr; that's fine in CI logs.
add_custom_command(
OUTPUT "${FLAPI_BUNDLE_PLACEHOLDER}"
COMMAND dd
if=/dev/zero
"of=${FLAPI_BUNDLE_PLACEHOLDER}"
bs=1m
count=${FLAPI_RESERVED_BUNDLE_MIB}
COMMENT "flapi: generating ${FLAPI_RESERVED_BUNDLE_MIB}-MiB __FLAPI/__bundle placeholder")
add_custom_target(flapi_bundle_placeholder
DEPENDS "${FLAPI_BUNDLE_PLACEHOLDER}")
target_link_options(flapi PRIVATE
"-Wl,-sectcreate,__FLAPI,__bundle,${FLAPI_BUNDLE_PLACEHOLDER}")
add_dependencies(flapi flapi_bundle_placeholder)
endif()

# Add Windows-specific libraries
if(WIN32)
target_link_libraries(flapi PRIVATE Dbghelp.lib)
Expand Down
175 changes: 113 additions & 62 deletions src/bundle_locator.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include "bundle_locator.hpp"
#include "macho_bundle.hpp"
#include "selfpath.hpp"

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

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

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

} // namespace

std::optional<BundleLocation> LocateBundle(const std::filesystem::path& path) {
std::error_code ec;
const auto file_size = std::filesystem::file_size(path, ec);
if (ec || file_size < kEocdRecordSize) {
return std::nullopt;
}

std::ifstream in(path, std::ios::binary);
if (!in.is_open()) {
return std::nullopt;
}

const std::size_t tail_bytes =
static_cast<std::size_t>(std::min<std::uint64_t>(file_size, kScanBudget));
const std::uint64_t tail_start = file_size - tail_bytes;

std::vector<std::uint8_t> tail(tail_bytes);
in.seekg(static_cast<std::streamoff>(tail_start), std::ios::beg);
in.read(reinterpret_cast<char*>(tail.data()),
static_cast<std::streamsize>(tail_bytes));
if (!in) {
return std::nullopt;
}
if (tail.size() < kEocdRecordSize) {
// Scan a buffer for the most-recent valid EOCD record. `buf_start_in_file`
// is the absolute file offset of buf[0]; used to translate the result.
// `logical_eof_in_buf` is the buf-relative position to treat as EOF for
// padding-tolerance purposes (== buf.size() in the common case).
std::optional<BundleLocation> ScanBufferForEocd(
const std::vector<std::uint8_t>& buf,
std::uint64_t buf_start_in_file,
std::size_t logical_eof_in_buf) {
if (logical_eof_in_buf > buf.size() || logical_eof_in_buf < kEocdRecordSize) {
return std::nullopt;
}

// Reverse-scan from the latest valid signature position. The latest
// (largest-offset) EOCD wins, since any earlier signature byte
// sequence in random leading data is a false positive.
const std::size_t max_start = tail.size() - kEocdRecordSize;
const std::size_t max_start = logical_eof_in_buf - kEocdRecordSize;
for (std::size_t i = max_start + 1; i-- > 0; ) {
if (tail[i] != 0x50 ||
tail[i + 1] != 0x4b ||
tail[i + 2] != 0x05 ||
tail[i + 3] != 0x06) {
if (buf[i] != 0x50 ||
buf[i + 1] != 0x4b ||
buf[i + 2] != 0x05 ||
buf[i + 3] != 0x06) {
continue;
}
const std::uint8_t* p = buf.data() + i;
const std::uint16_t this_disk = ReadU16(p + 4);
const std::uint16_t cd_start_disk = ReadU16(p + 6);
const std::uint16_t entries_this = ReadU16(p + 8);
const std::uint16_t entries_total = ReadU16(p + 10);
const std::uint32_t cd_size = ReadU32(p + 12);
const std::uint32_t cd_offset_arch = ReadU32(p + 16);
const std::uint16_t comment_len = ReadU16(p + 20);

const std::uint8_t* p = tail.data() + i;
const std::uint16_t this_disk = ReadU16(p + 4);
const std::uint16_t cd_start_disk = ReadU16(p + 6);
const std::uint16_t entries_this = ReadU16(p + 8);
const std::uint16_t entries_total = ReadU16(p + 10);
const std::uint32_t cd_size = ReadU32(p + 12);
const std::uint32_t cd_offset_arch = ReadU32(p + 16);
const std::uint16_t comment_len = ReadU16(p + 20);

// Multi-disk archives are not supported.
if (this_disk != 0 || cd_start_disk != 0) {
continue;
}
if (entries_this != entries_total) {
continue;
}

// The comment must fit in the tail.
const std::size_t comment_end = i + kEocdRecordSize + comment_len;
if (comment_end > tail.size()) {
if (comment_end > logical_eof_in_buf) {
continue;
}

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

const std::uint64_t eocd_file_offset = tail_start + i;

// The central directory sits immediately before the EOCD.
const std::uint64_t eocd_file_offset = buf_start_in_file + i;
if (cd_size > eocd_file_offset) {
continue;
}
const std::uint64_t cd_file_offset = eocd_file_offset - cd_size;

if (cd_offset_arch > cd_file_offset) {
continue;
}
const std::uint64_t bundle_start = cd_file_offset - cd_offset_arch;

const std::uint64_t bundle_end = eocd_file_offset + kEocdRecordSize + comment_len;
const std::uint64_t bundle_end =
eocd_file_offset + kEocdRecordSize + comment_len;
if (bundle_end < bundle_start) {
continue; // overflow paranoia
continue;
}

BundleLocation loc;
loc.offset = bundle_start;
loc.size = bundle_end - bundle_start;
return loc;
}

return std::nullopt;
}

} // namespace

std::optional<BundleLocation> LocateBundle(const std::filesystem::path& path) {
std::error_code ec;
const auto file_size = std::filesystem::file_size(path, ec);
if (ec || file_size < kEocdRecordSize) {
return std::nullopt;
}

std::ifstream in(path, std::ios::binary);
if (!in.is_open()) {
return std::nullopt;
}

const std::size_t tail_bytes =
static_cast<std::size_t>(std::min<std::uint64_t>(file_size, kScanBudget));
const std::uint64_t tail_start = file_size - tail_bytes;

std::vector<std::uint8_t> tail(tail_bytes);
in.seekg(static_cast<std::streamoff>(tail_start), std::ios::beg);
in.read(reinterpret_cast<char*>(tail.data()),
static_cast<std::streamsize>(tail_bytes));
if (!in) {
return std::nullopt;
}
return ScanBufferForEocd(tail, tail_start, tail.size());
}

std::optional<BundleLocation> LocateBundleInRange(
const std::filesystem::path& path,
std::uint64_t range_offset,
std::uint64_t range_size) {
if (range_size < kEocdRecordSize) {
return std::nullopt;
}
std::error_code ec;
const auto file_size = std::filesystem::file_size(path, ec);
if (ec || range_offset + range_size > file_size) {
return std::nullopt;
}
// Cap range_size at something sensible -- a runaway section_size
// value from a malformed Mach-O could otherwise allocate gigabytes.
constexpr std::uint64_t kMaxRangeBytes = 64ull * 1024ull * 1024ull;
const std::size_t to_read =
static_cast<std::size_t>(std::min<std::uint64_t>(range_size, kMaxRangeBytes));

std::ifstream in(path, std::ios::binary);
if (!in.is_open()) {
return std::nullopt;
}
std::vector<std::uint8_t> buf(to_read);
in.seekg(static_cast<std::streamoff>(range_offset), std::ios::beg);
in.read(reinterpret_cast<char*>(buf.data()),
static_cast<std::streamsize>(to_read));
if (!in) {
return std::nullopt;
}
return ScanBufferForEocd(buf, range_offset, buf.size());
}

std::optional<BundleLocation> LocateBundleInSelf() {
std::filesystem::path self_path;
try {
self_path = GetSelfPath();
} catch (...) {
return std::nullopt;
}

// Prefer the reserved Mach-O section if present (#48). Linux/Windows
// binaries lack the section, so this returns nullopt and we fall
// through to the EOF-tail scan unchanged.
if (auto sect = LocateFlapiSection(self_path); sect.has_value()) {
if (auto loc = LocateBundleInRange(self_path, sect->file_offset, sect->size);
loc.has_value()) {
return loc;
}
// Section is present but empty / unpopulated (e.g., un-packed
// build): fall through to the EOF-tail scan.
}

try {
return LocateBundle(GetSelfPath());
return LocateBundle(self_path);
} catch (...) {
return std::nullopt;
}
Expand Down
16 changes: 14 additions & 2 deletions src/include/bundle_locator.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,20 @@ struct BundleLocation {
// rounding pushing the EOCD off file-EOF.
std::optional<BundleLocation> LocateBundle(const std::filesystem::path& path);

// Convenience: scan the currently running executable. Returns nullopt
// if either the self-path lookup or the EOCD scan fails.
// Scans a specific byte range of `path` for a ZIP EOCD. Used by the
// macOS section-mode locator (#48) where the bundle lives inside a
// reserved Mach-O segment, not at file EOF. The returned
// BundleLocation.offset is the absolute file offset; padding-tolerance
// runs against `range_size` (treat range end as logical EOF).
std::optional<BundleLocation> LocateBundleInRange(
const std::filesystem::path& path,
std::uint64_t range_offset,
std::uint64_t range_size);

// Convenience: scan the currently running executable. On macOS, first
// looks at the reserved __FLAPI/__bundle Mach-O section; if absent or
// unpopulated, falls back to a reverse-EOF scan. Returns nullopt if
// either fails.
std::optional<BundleLocation> LocateBundleInSelf();

} // namespace flapi
72 changes: 72 additions & 0 deletions src/include/macho_bundle.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#pragma once

#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
#include <vector>

namespace flapi {

// The reserved Mach-O segment+section we use to host the bundled
// ZIP on macOS. Allocated at link time with a placeholder of
// FLAPI_RESERVED_BUNDLE_MIB MiB and overwritten by `flapi pack`
// at packaging time so the binary signature stays valid after a
// re-`codesign`.
constexpr const char* kFlapiSegName = "__FLAPI";
constexpr const char* kFlapiSectName = "__bundle";

struct MachOSection {
// File offset of the section's first byte, suitable for seek().
std::uint64_t file_offset = 0;
// Allocated section size in bytes (== reserved capacity).
std::uint64_t size = 0;
};

// Returns true if the bytes at `magic_bytes` (read from the head of
// a file) are a Mach-O magic value we recognise. Cheap pre-check.
bool IsMachOMagic(const std::uint8_t magic_bytes[4]);

// Locate the __FLAPI/__bundle section in a Mach-O file on disk.
// Returns nullopt if:
// - the file isn't a thin (non-fat) Mach-O,
// - the file is malformed,
// - the section doesn't exist (e.g., on a non-macOS build).
//
// Fat / universal binaries are currently not supported -- a follow-up
// can iterate slices. macOS releases produced by this repo are thin
// per-architecture, so the gap is acceptable for now.
std::optional<MachOSection> LocateFlapiSection(const std::filesystem::path& path);

// Overload that scans a buffer instead of opening a file. Used by
// unit tests against synthetic Mach-O fixtures.
std::optional<MachOSection> LocateFlapiSectionInBuffer(
const std::vector<std::uint8_t>& buffer);

// Overwrite the reserved section at `binary` with `payload`. The
// trailing capacity beyond payload.size() is zero-padded so that the
// EOCD of the embedded ZIP still reverse-scans cleanly from segment
// EOF. Throws ArchiveIOError when the payload is bigger than the
// reserved capacity (see PackOptions::host_binary_override for the
// override knob).
//
// Caller is responsible for re-signing the binary afterwards
// (CodesignBinary), since the signature covers the section bytes.
void OverwriteFlapiSection(const std::filesystem::path& binary,
const MachOSection& section,
const std::vector<std::uint8_t>& payload);

// Result of a codesign attempt.
struct CodesignResult {
int exit_code = 0;
std::string identity; // "-" for ad-hoc; otherwise the CODESIGN_IDENTITY value
std::string stderr_tail; // last few KB of codesign stderr, for diagnostics
};

// Invoke `codesign --force --sign <identity> <binary>` where identity
// is taken from the CODESIGN_IDENTITY env var, defaulting to "-"
// (ad-hoc). On non-Darwin builds this is a no-op that returns
// exit_code = 0 -- the caller doesn't need a platform guard.
CodesignResult CodesignBinary(const std::filesystem::path& binary);

} // namespace flapi
Loading
Loading