diff --git a/Makefile b/Makefile index 9f93bb1..2a56ced 100644 --- a/Makefile +++ b/Makefile @@ -1,2 +1 @@ -mac2ios: mac2ios.c - $(CC) -o mac2ios mac2ios.c +.DEFAULT_GOAL := help @@ -3,0 +3,22 @@ mac2ios: mac2ios.c +CC ?= cc ## C compiler. +CFLAGS ?= -O2 ## Extra compiler flags; required C11 warning flags are always added. +PYTHON ?= python3 ## Python interpreter for the metadata-only tests. +WARN_CFLAGS = -std=c11 -Werror=vla -pedantic -Wall -Wextra + +.PHONY: help all check clean +help: ## Show available targets and configuration variables. + @printf 'Usage: make TARGET [NAME=value ...]\n\nTargets:\n' + @awk -F ':.*##[[:space:]]*' '/^[a-zA-Z0-9_.-]+:.*##/ { printf " %-12s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + @printf '\nVariables (override with NAME=value):\n' + @awk -F '##[[:space:]]*' '/^[A-Z_]+[[:space:]]+\?=.*##/ { split($$1, v, /[[:space:]]*\?=[[:space:]]*/); sub(/[[:space:]]+$$/, "", v[2]); printf " %-12s %s (default: %s)\n", v[1], $$2, v[2] }' $(MAKEFILE_LIST) + +all: mac2ios ## Build the host metadata converter. + +mac2ios: mac2ios.c ar.h macho.h ## Build the converter binary. + $(CC) $(CFLAGS) $(WARN_CFLAGS) -o $@ mac2ios.c + +check: mac2ios ## Check object conversion, version normalization and archive/payload preservation. + $(PYTHON) tests/test_mac2ios.py ./mac2ios + +clean: ## Remove the local tool binary. + rm -f mac2ios diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff02e67 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# mac2ios + +`mac2ios FILE` rewrites platform version metadata in a standalone thin +little-endian Mach-O object or an archive of such objects. Input content selects +the format; the filename extension does not matter. + +```sh +make # List targets and configuration variables. +make all # Build the converter explicitly. +./mac2ios library.a # Convert an archive in place. +./mac2ios object.o # Convert one object in place. +./mac2ios -s object.o # Select the modern iOS simulator platform. +make check # Run metadata-only regression tests. +make all CC=clang CFLAGS='-O0 -g' +``` + +The default modern platform is iOS (`2`); `-s` selects the iOS simulator (`7`). +Legacy-only macOS minimum-version commands retain the existing conversion to +`LC_VERSION_MIN_IPHONEOS`. + +When an object contains both a modern build-version command and legacy minimum +commands, the converter retains the modern command and removes the legacy ones. +It compacts only the load-command table, updates its header counts, and zeros the +freed padding. Object/archive lengths and section, relocation, and symbol offsets +remain unchanged. Malformed headers or command bounds fail before that object +is written; earlier converted archive members are not rolled back if a later +member fails. Other archive metadata is left unchanged. Fat/big-endian standalone +inputs are unsupported. The original 64 KiB header/command buffer remains the +limit; tables that do not fit are rejected before writes. + +`make` and `make help` generate the target/variable list from the Makefile's `##` +descriptions. `CC`, `CFLAGS`, and `PYTHON` can be overridden on the command line. +The Nix package explicitly builds `all`, so its behavior does not depend on the +interactive default target. + +Tests cover x86_64 and ARM64 objects, both platform selections, direct files and +archives, mixed command orders, layout and payload preservation, malformed input, +and idempotence. On macOS they also use Apple `nm` and `otool` to read converted +data. No Mach-O fixture is linked or executed. Set +`MAC2IOS_REAL_OBJECT=/path/to/member.o` to include a real-member regression. diff --git a/mac2ios.c b/mac2ios.c index 7ba5338..7741489 100644 --- a/mac2ios.c +++ b/mac2ios.c @@ -5,0 +6 @@ +#include @@ -24,2 +25,9 @@ uint8_t buffer[64*1024]; -void patch_object(FILE *fp, size_t len, int platform) { - int pos = ftell(fp); +/* Byte access keeps archive members safe even when they are unaligned. */ +static uint32_t read_u32(const uint8_t *p) { + return (uint32_t)p[0] | (uint32_t)p[1] << 8 | + (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24; +} + +static void write_u32(uint8_t *p, uint32_t value) { + for (unsigned i = 0; i < 4; ++i) p[i] = (uint8_t)(value >> (8 * i)); +} @@ -27 +35,10 @@ void patch_object(FILE *fp, size_t len, int platform) { - fread(buffer,sizeof(buffer),1,fp); +static int legacy_version(uint32_t cmd) { + return cmd == LC_VERSION_MIN_MACOSX || cmd == LC_VERSION_MIN_IPHONEOS || + cmd == LC_VERSION_MIN_TVOS || cmd == LC_VERSION_MIN_WATCHOS; +} + +int patch_object(FILE *fp, size_t len, int platform) { + long pos = ftell(fp); + size_t amount = len < sizeof(buffer) ? len : sizeof(buffer); + if(pos < 0 || fread(buffer, 1, amount, fp) != amount) return 1; + if(len < 4) return fseek(fp, pos, SEEK_SET); @@ -29 +46,3 @@ void patch_object(FILE *fp, size_t len, int platform) { - mach_header_64 *hdr = (mach_header_64*)buffer; + mach_header_64 header = {0}; + mach_header_64 *hdr = &header; + hdr->magic = read_u32(buffer); @@ -32,2 +51 @@ void patch_object(FILE *fp, size_t len, int platform) { - || hdr->magic == 0xfeedfacf // 64-bit - || hdr->magic == 0xcafebabe )) { // Universal + || hdr->magic == 0xfeedfacf )) { // 64-bit @@ -35,2 +53 @@ void patch_object(FILE *fp, size_t len, int platform) { - fseek(fp, pos, SEEK_SET); - return; + return fseek(fp, pos, SEEK_SET); @@ -39,4 +56,42 @@ void patch_object(FILE *fp, size_t len, int platform) { - size_t offset = sizeof(mach_header_64); - load_command *cmd; - while(offset < sizeof(mach_header_64) + hdr->sizeofcmds) { - cmd = (load_command*)(buffer+offset); + size_t header_size = hdr->magic == 0xfeedfacf ? 32 : 28; + if(amount < header_size) goto malformed; + hdr->ncmds = read_u32(buffer+16); + hdr->sizeofcmds = read_u32(buffer+20); + if(hdr->sizeofcmds > amount-header_size || hdr->ncmds > hdr->sizeofcmds/8) + goto malformed; + size_t end = header_size + hdr->sizeofcmds; + size_t offset = header_size; + load_command command; + load_command *cmd = &command; + uint32_t builds = 0, removed = 0; + /* Validate every command before writing any part of this member. */ + for(uint32_t i = 0; i < hdr->ncmds; ++i) { + if(end-offset < 8) goto malformed; + cmd->cmd = read_u32(buffer+offset); + cmd->cmdsize = read_u32(buffer+offset+4); + if(cmd->cmdsize < 8 || cmd->cmdsize % (header_size == 32 ? 8 : 4) || + cmd->cmdsize > end-offset) goto malformed; + if(legacy_version(cmd->cmd) && cmd->cmdsize != sizeof(struct version_min_command)) + goto malformed; + if(cmd->cmd == LC_BUILD_VERSION) { + if(cmd->cmdsize < sizeof(struct build_version_command) || + read_u32(buffer+offset+20) != + (cmd->cmdsize-sizeof(struct build_version_command))/sizeof(struct build_tool_version)) + goto malformed; + ++builds; + } + offset += cmd->cmdsize; + } + if(offset != end || builds > 1) goto malformed; + + offset = header_size; + size_t output = header_size; + while(offset < end) { + cmd->cmd = read_u32(buffer+offset); + cmd->cmdsize = read_u32(buffer+offset+4); + if(builds && legacy_version(cmd->cmd)) { + ++removed; + offset += cmd->cmdsize; + continue; + } + memmove(buffer+output, buffer+offset, cmd->cmdsize); @@ -48,2 +103 @@ void patch_object(FILE *fp, size_t len, int platform) { - fseek(fp, pos+offset, SEEK_SET); - fwrite(buffer+offset, sizeof(struct build_version_command), 1, fp); + write_u32(buffer+output, cmd->cmd); @@ -54,5 +108,2 @@ void patch_object(FILE *fp, size_t len, int platform) { - struct build_version_command* bvc = (struct build_version_command*)(buffer+offset); - printf("Chaning platform from: %d -> %d\n", bvc->platform, platform); - bvc->platform = platform; - fseek(fp, pos+offset, SEEK_SET); - fwrite(buffer+offset, sizeof(struct build_version_command), 1, fp); + printf("Chaning platform from: %d -> %d\n", read_u32(buffer+output+8), platform); + write_u32(buffer+output+8, (uint32_t)platform); @@ -63,0 +115 @@ void patch_object(FILE *fp, size_t len, int platform) { + output += cmd->cmdsize; @@ -65 +117,9 @@ void patch_object(FILE *fp, size_t len, int platform) { - fseek(fp, pos, SEEK_SET); + /* Compact commands only: payload, symbol offsets and member size stay put. */ + write_u32(buffer+16, hdr->ncmds-removed); + write_u32(buffer+20, (uint32_t)(output-header_size)); + memset(buffer+output, 0, end-output); + if(fseek(fp, pos, SEEK_SET) || fwrite(buffer, 1, end, fp) != end) return 1; + return fseek(fp, pos, SEEK_SET); +malformed: + fprintf(stderr, "Malformed Mach-O load commands\n"); + return 1; @@ -71,0 +132 @@ uint32_t parse_ar_size(char *s, size_t maxlen) { + if(ret > (UINT32_MAX - (uint32_t)(s[i]-48))/10) return UINT32_MAX; @@ -78 +139,3 @@ int parse_archive(FILE *fp, int platform) { - int pos = ftell(fp); + long pos = ftell(fp), archive_end; + if(pos < 0 || fseek(fp, 0, SEEK_END) || (archive_end = ftell(fp)) < 0 || + fseek(fp, pos, SEEK_SET)) return 1; @@ -81,2 +144,2 @@ int parse_archive(FILE *fp, int platform) { - fread(&magic,sizeof(magic), 1, fp); - if(0 != strncmp(magic, "!\n",8)) { + if(fread(&magic,sizeof(magic), 1, fp) != 1 || + 0 != strncmp(magic, "!\n",8)) { @@ -89 +152,3 @@ int parse_archive(FILE *fp, int platform) { - while(fread(&entry, sizeof(ar_header), 1, fp)) { + size_t nread; + while((nread = fread(&entry, 1, sizeof(ar_header), fp)) != 0) { + if(nread != sizeof(ar_header)) return 1; @@ -90,0 +156,6 @@ int parse_archive(FILE *fp, int platform) { + long data_pos = ftell(fp); + size_t padding = payload % 2; + if(memcmp(entry.end, "`\n", 2) || + payload == UINT32_MAX || data_pos < 0 || data_pos > archive_end || + payload > (size_t)(archive_end-data_pos) || + padding > (size_t)(archive_end-data_pos)-payload) return 1; @@ -93,2 +164,5 @@ int parse_archive(FILE *fp, int platform) { - int ident_len = parse_ar_size(entry.ident+3, 16-3); - fread(&ident, ident_len, 1, fp); + size_t ident_len = parse_ar_size(entry.ident+3, 16-3); + size_t shown = ident_len < sizeof(ident)-1 ? ident_len : sizeof(ident)-1; + if(ident_len > payload || fread(ident, 1, shown, fp) != shown || + fseek(fp, data_pos+(long)ident_len, SEEK_SET)) return 1; + ident[shown] = '\0'; @@ -97 +171,2 @@ int parse_archive(FILE *fp, int platform) { - strncpy(ident, entry.ident, 16); + memcpy(ident, entry.ident, 16); + ident[16] = '\0'; @@ -101,3 +176,3 @@ int parse_archive(FILE *fp, int platform) { - if(0 != strncmp(ident, "__.SYMDEF", 9)) - patch_object(fp, payload, platform); - fseek(fp, payload, SEEK_CUR); + if(0 != strncmp(ident, "__.SYMDEF", 9) && patch_object(fp, payload, platform)) + return 1; + if(fseek(fp, (long)(payload+padding), SEEK_CUR)) return 1; @@ -105,2 +180,17 @@ int parse_archive(FILE *fp, int platform) { - fseek(fp, pos, SEEK_SET); - return 0; + if(ferror(fp)) return 1; + return fseek(fp, pos, SEEK_SET); +} + +int patch_file(FILE *fp, int platform) { + uint8_t magic[8]; + long len; + if(fseek(fp, 0, SEEK_END) || (len = ftell(fp)) < 0 || + (uintmax_t)len > SIZE_MAX || fseek(fp, 0, SEEK_SET)) return 1; + size_t nread = fread(magic, 1, sizeof(magic), fp); + if(ferror(fp) || fseek(fp, 0, SEEK_SET)) return 1; + if(nread == 8 && memcmp(magic, "!\n", 8) == 0) + return parse_archive(fp, platform); + if(nread >= 4 && (read_u32(magic) == 0xfeedface || read_u32(magic) == 0xfeedfacf)) + return patch_object(fp, (size_t)len, platform); + fprintf(stderr, "Expected an ar archive or a thin little-endian Mach-O file\n"); + return 1; @@ -120 +210 @@ int main(int argc, char ** argv) { - printf("Usage: mac2ios FILE\n"); + printf("Usage: mac2ios [-s] FILE.a|FILE.o (detected by content)\n"); @@ -130 +220 @@ int main(int argc, char ** argv) { - break; + return EXIT_FAILURE; @@ -136 +226 @@ int main(int argc, char ** argv) { - if(optind == argc) { + if(optind + 1 != argc) { @@ -147,2 +237,2 @@ int main(int argc, char ** argv) { - int ret = parse_archive(ptr, platform); - fclose(ptr); + int ret = patch_file(ptr, platform); + if(fclose(ptr)) ret = 1; diff --git a/tests/test_mac2ios.py b/tests/test_mac2ios.py new file mode 100644 index 0000000..504e23a --- /dev/null +++ b/tests/test_mac2ios.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Metadata-only regression tests: no Mach-O fixture is linked or executed.""" +import os +from pathlib import Path +import shutil +import struct +import subprocess +import sys +import tempfile +import unittest + +TOOL = Path(sys.argv.pop(1)).resolve() +REAL_OBJECT = os.environ.get("MAC2IOS_REAL_OBJECT") +CPUS = (0x01000007, 0x0100000C) +LEGACY = {0x24, 0x25, 0x2F, 0x30} +BUILD = 0x32 + + +def u32(data, offset): + return struct.unpack_from("= 8 and offset + length <= 32 + size + result.append((command, offset, data[offset:offset + length])) + offset += length + assert offset == 32 + size + return result + + +def archive(members): + result = bytearray(b"!\n") + offsets = [] + for name, data in members: + # BSD extended names include NUL padding, as actual Darwin ar emits. + encoded = name.encode() + b"\0" + encoded += b"\0" * (-len(encoded) % 8) + body = encoded + data + header = (f"#1/{len(encoded)}".ljust(16) + "0".ljust(12) + + "0".ljust(6) + "0".ljust(6) + "644".ljust(8) + + str(len(body)).ljust(10) + "`\n").encode() + assert len(header) == 60 + offsets.append((len(result) + 60 + len(encoded), len(data))) + result += header + body + (b"\n" if len(body) % 2 else b"") + return bytes(result), offsets + + +class ConverterTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + + def convert(self, original, simulator=False, success=True, filename="fixture.a"): + path = self.root / filename + path.write_bytes(original) + result = subprocess.run([str(TOOL), *(["-s"] if simulator else []), str(path)], + capture_output=True, timeout=10) + self.assertEqual(result.returncode == 0, success, result.stderr.decode()) + return path.read_bytes() + + def check_object(self, before, after, platform): + self.assertEqual(len(before), len(after)) + old_commands, new_commands = load_commands(before), load_commands(after) + old_size, new_size = u32(before, 20), u32(after, 20) + builds = [c for c in new_commands if c[0] == BUILD] + old_builds = [c for c in old_commands if c[0] == BUILD] + legacies = [c for c in new_commands if c[0] in LEGACY] + if old_builds: + self.assertEqual(len(builds), 1) + self.assertEqual(legacies, []) + self.assertEqual(u32(builds[0][2], 8), platform) + self.assertEqual(builds[0][2][12:], old_builds[0][2][12:]) + else: + self.assertEqual(builds, []) + self.assertTrue(all(c[0] == 0x25 for c in legacies)) + # All unrelated load commands and every byte outside the command table + # retain their original offsets/content, including section/symtab fields. + self.assertEqual([c[2] for c in new_commands if c[0] not in LEGACY | {BUILD}], + [c[2] for c in old_commands if c[0] not in LEGACY | {BUILD}]) + self.assertEqual(after[32 + old_size:], before[32 + old_size:]) + self.assertEqual(after[32 + new_size:32 + old_size], b"\0" * (old_size - new_size)) + self.assertEqual(before[:16], after[:16]) + self.assertEqual(before[24:32], after[24:32]) + + def apple_accepts(self, data): + if sys.platform != "darwin" or not shutil.which("xcrun"): + return + path = self.root / "data-only.o" + path.write_bytes(data) + for tool, args in (("nm", ["-g"]), ("otool", ["-l"])): + result = subprocess.run(["xcrun", tool, *args, str(path)], + capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertNotIn(b"malformed", result.stderr) + + def test_both_cpus_and_version_orders(self): + for cpu in CPUS: + for versions in ([legacy()], [modern()], [legacy(), modern()], + [modern(), legacy()], [legacy(), modern(False)], + [modern(False), legacy()]): + for simulator in (False, True): + with self.subTest(cpu=cpu, versions=len(versions), simulator=simulator): + obj = object_bytes(cpu, versions) + original, ((offset, length),) = archive([("fixture.o", obj)]) + changed = self.convert(original, simulator) + self.assertEqual(changed[:offset], original[:offset]) + self.assertEqual(changed[offset + length:], original[offset + length:]) + output = changed[offset:offset + length] + self.check_object(obj, output, 7 if simulator else 2) + self.apple_accepts(output) + self.apple_accepts(changed) + self.assertEqual(self.convert(changed, simulator), changed) + + def test_direct_objects_both_cpus_and_version_orders(self): + for cpu in CPUS: + for versions in ([legacy()], [modern()], [legacy(), modern()], + [modern(), legacy()], [legacy(), modern(False)], + [modern(False), legacy()]): + for simulator in (False, True): + with self.subTest(cpu=cpu, versions=len(versions), simulator=simulator): + original = object_bytes(cpu, versions) + changed = self.convert(original, simulator, filename="direct.o") + self.check_object(original, changed, 7 if simulator else 2) + self.apple_accepts(changed) + self.assertEqual(self.convert(changed, simulator, filename="direct.o"), changed) + + def test_dispatch_uses_content_not_filename(self): + obj = object_bytes(CPUS[0], [legacy(), modern()]) + original, _ = archive([("fixture.o", obj)]) + for filename in ("direct.a", "direct.bin"): + self.check_object(obj, self.convert(obj, filename=filename), 2) + for filename in ("archive.o", "archive.bin"): + self.apple_accepts(self.convert(original, filename=filename)) + for cpu in CPUS: + obj = object_bytes(cpu, []) + self.assertEqual(self.convert(obj, filename="no-version.o"), obj) + + def test_invalid_option_does_not_mutate_input(self): + obj = object_bytes(CPUS[0], [legacy(), modern()]) + wrapped, _ = archive([("fixture.o", obj)]) + for original in (obj, wrapped): + for option in ("-S", "-x", "-z", "-sx"): + with self.subTest(archive=original.startswith(b"!"), option=option): + path = self.root / "invalid-option.bin" + path.write_bytes(original) + result = subprocess.run([str(TOOL), option, str(path)], + capture_output=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(path.read_bytes(), original) + + def test_invalid_direct_input_is_unchanged(self): + invalid = [b"", b"x", b"not a Mach-O object", b"!", + bytes.fromhex("feedfacf") + bytes(32), # Big-endian magic. + bytes.fromhex("cafebabe") + bytes(32)] # Fat input is unsupported. + for cpu in CPUS: + obj = object_bytes(cpu, [legacy(), modern()]) + invalid.extend((obj[:3], obj[:4], obj[:24], obj[:32 + u32(obj, 20) - 1])) + for offset, value in ((20, len(obj)), (16, 1000), (36, 0), + (32 + 152 + 16 + 20, 100)): + broken = bytearray(obj) + struct.pack_into("