Skip to content

Commit 5bd8bab

Browse files
tonyfettesclaude
andcommitted
fix(cli): refuse to stage a Mach-O the kernel won't spawn
Staging the wrong Mach-O as a bundle's CFBundleExecutable produces an app that passes every check and then dies at launch with "Launchd job spawn failed". The dSYM skip one commit earlier stops the known way in, but nothing downstream would notice another. Nothing else can: a dSYM companion is a legitimate Mach-O that signs and verifies exactly like the real binary, so `codesign` is blind to it, and the executable bit is no signal either since this packager sets it on whatever it staged. The two differ only in the header's `filetype` — 0x2 MH_EXECUTE against 0xa MH_DSYM — with the first twelve bytes identical. So read those four bytes after staging the app executable and each CEF helper, and fail the package when they say the file cannot be spawned. The reader judges only thin little-endian Mach-O, where the 64- and 32-bit layouts agree up to `filetype`. Universal binaries and non-Mach-O files read as unknown and pass: rejecting only what can be proven wrong keeps a format this reader does not understand from becoming a build failure. Covering fat archives means walking the arch table, noted where it would go. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 238d45a commit 5bd8bab

3 files changed

Lines changed: 113 additions & 0 deletions

File tree

cli/package/error.mbt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub(all) suberror PackagePlanError {
2222
UnsupportedResourceGlob(path~ : String)
2323
MissingInput(path~ : String)
2424
MissingExecutable(path~ : String)
25+
NotLaunchableMachO(path~ : String, filetype~ : Int)
2526
InputOutsideProject(path~ : String)
2627
MissingIcon(path~ : String)
2728
} derive(Debug, Eq)
@@ -113,6 +114,12 @@ pub fn PackagePlanError::message(self : PackagePlanError) -> String {
113114
"package executable not found under " +
114115
path +
115116
"; run proton package without --no-build first"
117+
NotLaunchableMachO(path~, filetype~) =>
118+
"staged bundle executable " +
119+
path +
120+
" is a Mach-O of type " +
121+
macho_filetype_name(filetype) +
122+
", not MH_EXECUTE; the kernel refuses to spawn it"
116123
InputOutsideProject(path~) =>
117124
"package input must stay inside the project: " + path
118125
MissingIcon(path~) => "package icon is missing: " + path

cli/package/package.mbt

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,72 @@ async fn stage_macos_app(
838838
}
839839
}
840840

841+
///|
842+
/// Mach-O `filetype` values this packager can name. Only `MH_EXECUTE` is
843+
/// spawnable; `MH_DSYM` is the one that actually shows up here, because a
844+
/// debug build mirrors the executable's basename inside
845+
/// `<name>.dSYM/Contents/Resources/DWARF/`.
846+
const MachoExecute : Int = 0x2
847+
848+
///|
849+
fn macho_filetype_name(filetype : Int) -> String {
850+
match filetype {
851+
0x1 => "MH_OBJECT"
852+
0x2 => "MH_EXECUTE"
853+
0x6 => "MH_DYLIB"
854+
0x8 => "MH_BUNDLE"
855+
0xa => "MH_DSYM"
856+
_ => "0x" + filetype.to_string()
857+
}
858+
}
859+
860+
///|
861+
fn macho_u32_le(data : Bytes, offset : Int) -> Int {
862+
data[offset].to_int() |
863+
(data[offset + 1].to_int() << 8) |
864+
(data[offset + 2].to_int() << 16) |
865+
(data[offset + 3].to_int() << 24)
866+
}
867+
868+
///|
869+
/// The `filetype` of a thin little-endian Mach-O, or `None` for anything this
870+
/// reader will not judge.
871+
///
872+
/// 64- and 32-bit thin headers agree up to `filetype`, so one offset serves
873+
/// both. Universal binaries (`FAT_MAGIC`) and non-Mach-O files return `None`
874+
/// on purpose: the caller rejects only what it can prove wrong, so a format
875+
/// this function does not understand is never turned into a build failure.
876+
/// Extending to fat archives means walking the arch table and judging each
877+
/// contained header.
878+
fn macho_thin_filetype(data : Bytes) -> Int? {
879+
guard data.length() >= 16 else { return None }
880+
let magic = macho_u32_le(data, 0)
881+
guard magic == 0xfeedfacf || magic == 0xfeedface else { return None }
882+
Some(macho_u32_le(data, 12))
883+
}
884+
885+
///|
886+
/// Refuse to ship a bundle executable the kernel will not spawn.
887+
///
888+
/// `codesign` cannot stand in for this: a dSYM companion is a legitimate
889+
/// Mach-O and signs and verifies exactly like the real binary, differing
890+
/// only in the header's `filetype`. Nor can the executable bit, which this
891+
/// packager sets on whatever it staged. So a bundle carrying symbols where
892+
/// its binary belongs passes every other check and then fails at launch with
893+
/// `Launchd job spawn failed` — this is the only place that catches it.
894+
async fn require_launchable_macho(path : String) -> Unit {
895+
let data = @async_fs.read_file(path) catch {
896+
error =>
897+
raise PackageFileSystemError::Read(
898+
path~,
899+
detail=@debug.render(Repr(error)),
900+
)
901+
}
902+
guard macho_thin_filetype(data.binary()) is Some(filetype) else { return }
903+
guard filetype != MachoExecute else { return }
904+
raise PackagePlanError::NotLaunchableMachO(path~, filetype~)
905+
}
906+
841907
///|
842908
async fn stage_macos_app_impl(
843909
plan : PackagePlan,
@@ -868,6 +934,7 @@ async fn stage_macos_app_impl(
868934
@debug.render(Repr(error)),
869935
)
870936
}
937+
require_launchable_macho(staged_executable)
871938
copy_tree(runtime, @fsutil.resolve_path(resources, "proton"))
872939
stage_release_project_config(
873940
plan.config_path,
@@ -901,6 +968,7 @@ async fn stage_macos_app_impl(
901968
@debug.render(Repr(error)),
902969
)
903970
}
971+
require_launchable_macho(helper_executable)
904972
write_text(
905973
@fsutil.resolve_path(helper_app, "Contents/Info.plist"),
906974
helper_info_plist(plan, helper),

cli/package/package_wbtest.mbt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,3 +1204,41 @@ async test "Windows portable staging flattens runtime bin beside app executable"
12041204
@mbfs.path_exists(@fsutil.resolve_path(portable, "bin/proton.dll")),
12051205
)
12061206
}
1207+
1208+
///|
1209+
test "a thin Mach-O header yields its filetype" {
1210+
// An arm64 executable and its dSYM mirror: identical for twelve bytes, then
1211+
// MH_EXECUTE (2) against MH_DSYM (10). Nothing cheaper tells them apart —
1212+
// not the magic, not the size, not codesign.
1213+
let executable = b"\xcf\xfa\xed\xfe\x0c\x00\x00\x01\x00\x00\x00\x00\x02\x00\x00\x00"
1214+
let dsym = b"\xcf\xfa\xed\xfe\x0c\x00\x00\x01\x00\x00\x00\x00\x0a\x00\x00\x00"
1215+
assert_eq(macho_thin_filetype(executable), Some(0x2))
1216+
assert_eq(macho_thin_filetype(dsym), Some(0xa))
1217+
// 32-bit thin headers agree up to `filetype`, so one offset reads both.
1218+
let thin32 = b"\xce\xfa\xed\xfe\x0c\x00\x00\x00\x00\x00\x00\x00\x06\x00\x00\x00"
1219+
assert_eq(macho_thin_filetype(thin32), Some(0x6))
1220+
}
1221+
1222+
///|
1223+
test "unjudgeable inputs are left alone rather than failed" {
1224+
// The caller rejects only what it can prove wrong, so a universal binary, a
1225+
// script, and a truncated file all read as None. A format this reader does
1226+
// not understand must never become a packaging failure.
1227+
let fat = b"\xca\xfe\xba\xbe\x00\x00\x00\x02\x01\x00\x00\x0c\x00\x00\x00\x00"
1228+
assert_eq(macho_thin_filetype(fat), None)
1229+
assert_eq(macho_thin_filetype(b"#!/bin/sh\necho hi\n"), None)
1230+
assert_eq(macho_thin_filetype(b"\xcf\xfa\xed\xfe"), None)
1231+
}
1232+
1233+
///|
1234+
test "the rejection names the filetype it found" {
1235+
inspect(macho_filetype_name(0x2), content="MH_EXECUTE")
1236+
inspect(macho_filetype_name(0xa), content="MH_DSYM")
1237+
inspect(
1238+
PackagePlanError::NotLaunchableMachO(
1239+
path="SeekMoon.app/Contents/MacOS/seekmoon",
1240+
filetype=0xa,
1241+
).message(),
1242+
content="staged bundle executable SeekMoon.app/Contents/MacOS/seekmoon is a Mach-O of type MH_DSYM, not MH_EXECUTE; the kernel refuses to spawn it",
1243+
)
1244+
}

0 commit comments

Comments
 (0)