diff --git a/docs/PLATFORM_IDENTITY.md b/docs/PLATFORM_IDENTITY.md index c1d4b85..9e623db 100644 --- a/docs/PLATFORM_IDENTITY.md +++ b/docs/PLATFORM_IDENTITY.md @@ -1,9 +1,9 @@ # Explicit platform identity overrides By default, Darling keeps the existing host-derived UUID and `DARLING-` serial. -No override file is generated by installation or launch. This tool imports -values supplied by the user; it does not generate Apple serials or replace OS, -CPU, GPU, or security metadata. +No override file is generated by installation or launch. Import mode uses values supplied by the user. Explicit `--provision` mode can +generate synthetic identifiers; it does not issue authentic Apple identities +or change CPU, GPU, or security metadata. Preview an OpenCore `config.plist`, a flat GenSMBIOS JSON file, or an existing `darling-smbios.conf` without changing the selected prefix: @@ -42,6 +42,7 @@ Supported properties: | Property | Configuration value | IOKit value | |---|---|---| | `IOPlatformSerialNumber` | Printable ASCII string, 1–64 characters | NSString | +| `provisioning_UDID` | UUID string matching `IOPlatformUUID` | NSString | | `IOPlatformUUID` | Nonzero UUID; normalized to uppercase | NSString | | `manufacturer`, `model`, `product-name`, `board-id` | Printable ASCII string, 1–128 characters | NUL-terminated NSData | | `mlb-serial-number` | Printable ASCII string, 1–64 characters | NUL-terminated NSData | @@ -71,3 +72,65 @@ first previous configuration is kept as an older `.conf` never edits that source. Symlinks escaping the prefix are rejected. To restore the original defaults, remove the override file while the prefix is stopped, then restart it. + +## Opt-in provisioning + +Preview a fresh or repaired identity without writing anything: + +```sh +python3 tools/configure-darling-platform.py --prefix /absolute/prefix --provision +``` + +Stop the prefix, then save the identity and prefix metadata: + +```sh +DPREFIX=/absolute/prefix python3 build/runtime/tools/stop-darling-runtime.py +python3 tools/configure-darling-platform.py --prefix /absolute/prefix --provision --write +``` + +Launch Darling normally afterwards. This requires the updated iokitd from this +PR; the config tool alone cannot update an older running daemon. Provisioning +is never called automatically by installation or launch. + +Missing serials use 12 characters and missing MLBs use 17 characters, sampled +with Python's `secrets` module from `0123456789ABCDEFGHJKLMNPQRSTUVWXYZ` (Base-34). +These are synthetic strings, with no Apple model, factory, date or checksum +validation, allocation, or guarantee of acceptance by Apple services. Missing +UUIDs use RFC4122 v4; `system-id` contains the UUID's 16 little-endian bytes. +For this project `provisioning_UDID` is the uppercase platform UUID alias, not +a separate Apple provisioning credential or a claim about hardware UDIDs. + +Missing ROMs default to six random bytes with the local/unicast bits set. You +can explicitly supply a three-byte OUI using `--rom-oui AA:BB:CC` (replace the +example with your chosen unicast OUI). It only affects a missing ROM. An OUI +does not make a generated identity Apple-issued. + +Provisioning replaces the known shared serial `C02ZL0GVMD6N` and UUID +`6F3B2A1E-8C4D-4E5F-9A7B-1D2C3E4F5A6B`, along with the latter's derived fields. +It preserves other existing custom values, including imported serials and UUIDs +that do not follow the generated formats. A standalone custom `system-id` is +preserved by deriving its UUID instead of generating a different one. Model, +board and manufacturer values are only set when supplied by the user. + +The command reads an existing prefix `darling-smbios.conf` for migration, then +applies the canonical plist, an explicit import, and `--set` values in that +order. It writes: + +- `private/etc/darling-platform.plist`: canonical runtime overrides. +- `private/etc/darling-smbios.conf`: matching shell-quoted `SMBIOS_*` values, + including `SMBIOS_PROVISIONING_UDID`; unrelated legacy settings are not exported. +- `System/Library/CoreServices/SystemVersion.plist`: an exact copy of the selected + runtime's metadata, not a fabricated macOS release. The runtime root defaults + to the saved SDK path or `/usr/local/libexec/darling`; override with + `--runtime-root /path/to/runtime`. + +A valid runtime version file is required. The command checks all destinations +before changing identity files, rejects live prefixes and escaping symlinks, +backs up each previous file once with `.before-platform-change`, and replaces +each file atomically. The set of three files is not a single transaction: the +canonical identity is saved first so rerunning after interruption completes +provisioning with the same identity. Concurrent CLI writers serialize through +a per-prefix lock. Repeated writes preserve identifiers and unchanged files. +Previewing a fresh identity generates a sample; only `--write` makes it stable. +To restore host-derived defaults after provisioning, remove both identity files +while stopped, so a later provisioning run cannot reimport the legacy copy. diff --git a/docs/reviews/pr41-deferred-implementation.md b/docs/reviews/pr41-deferred-implementation.md index 9c2347d..ea0f50c 100644 --- a/docs/reviews/pr41-deferred-implementation.md +++ b/docs/reviews/pr41-deferred-implementation.md @@ -53,7 +53,8 @@ above the 32-bit range should use the named 64-bit API. [Platform identity configuration](../PLATFORM_IDENTITY.md) documents the schema, OpenCore/GenSMBIOS/legacy imports, startup snapshot, defaults, and rollback. Neither installation nor launch generates an override file. Existing host-based -identifiers remain stable unless the user explicitly supplies replacements. +identifiers remain stable unless the user explicitly supplies replacements or +requests synthetic per-prefix provisioning. ## Validation @@ -96,3 +97,12 @@ darling shell /Volumes/SystemRoot/ABSOLUTE_PROJECT_PATH/build/riot-cpu-frequency Local validation also ran the fd helper inside Darling and used an isolated kernel override for the frequency probe. The configured production runtime was not replaced, no real identity was changed, and no match capture is required. + + +Opt-in provisioning now fills missing synthetic serial/MLB, UUID, system-id, +provisioning UUID alias and ROM fields. It repairs the two known shared defaults +while retaining custom values, synchronizes the canonical and legacy config, +and copies actual runtime SystemVersion metadata. Disposable-prefix tests cover +concurrent writers, idempotency, migration, backups, live-prefix refusal, and +preflight failures. The registry probe validates all ten allowed properties. +See [platform identity usage](../PLATFORM_IDENTITY.md#opt-in-provisioning). diff --git a/patches/iokitd.patch b/patches/iokitd.patch index c3333e1..5607ad6 100644 --- a/patches/iokitd.patch +++ b/patches/iokitd.patch @@ -3522,10 +3522,10 @@ index 90155ed..e955bd4 100644 static std::unordered_map m_entriesById; diff --git a/src/PlatformIdentity.h b/src/PlatformIdentity.h new file mode 100644 -index 0000000..1f83b65 +index 0000000..6c87959 --- /dev/null +++ b/src/PlatformIdentity.h -@@ -0,0 +1,78 @@ +@@ -0,0 +1,81 @@ +#ifndef DARLING_PLATFORM_IDENTITY_H +#define DARLING_PLATFORM_IDENTITY_H +#import @@ -3553,17 +3553,18 @@ index 0000000..1f83b65 + if (![schema isKindOfClass:[NSNumber class]] || CFGetTypeID((CFTypeRef)schema) == CFBooleanGetTypeID() || + strchr("fd", [schema objCType][0]) || [schema longLongValue] != 1) return nil; + id source = [configuration objectForKey:@"properties"]; -+ if (![source isKindOfClass:[NSDictionary class]] || [source count] > 9) return nil; ++ if (![source isKindOfClass:[NSDictionary class]] || [source count] > 10) return nil; + NSMutableDictionary* result = [NSMutableDictionary dictionary]; + for (id key in source) { + if (![key isKindOfClass:[NSString class]]) return nil; + id value = [source objectForKey:key]; -+ if ([key isEqual:@"IOPlatformUUID"]) { ++ if ([key isEqual:@"IOPlatformUUID"] || [key isEqual:@"provisioning_UDID"]) { + if (!printable(value, 36) || [value length] != 36) return nil; + uuid_t uuid; + if (uuid_parse([value UTF8String], uuid) || uuid_is_null(uuid)) return nil; + uuid_string_t text; uuid_unparse_upper(uuid, text); + result[key] = [NSString stringWithUTF8String:text]; ++ if ([key isEqual:@"provisioning_UDID"]) continue; + unsigned char bytes[16]; memcpy(bytes, uuid, sizeof(bytes)); + for (int i = 0; i < 4; ++i) bytes[i] = uuid[3-i]; + bytes[4] = uuid[5]; bytes[5] = uuid[4]; bytes[6] = uuid[7]; bytes[7] = uuid[6]; @@ -3583,6 +3584,8 @@ index 0000000..1f83b65 + result[key] = [NSData dataWithBytes:text length:strlen(text)+1]; + } else return nil; + } ++ if (result[@"provisioning_UDID"] && result[@"IOPlatformUUID"] && ++ ![result[@"provisioning_UDID"] isEqual:result[@"IOPlatformUUID"]]) return nil; + if (result[@"model"] && !result[@"product-name"]) result[@"product-name"] = result[@"model"]; + return result; +} diff --git a/tests/test_platform_provisioning.py b/tests/test_platform_provisioning.py new file mode 100644 index 0000000..48bd0c0 --- /dev/null +++ b/tests/test_platform_provisioning.py @@ -0,0 +1,159 @@ +"""Provisioning contracts using disposable prefixes and runtime metadata.""" +import importlib.util +import json +import os +from pathlib import Path +import plistlib +import subprocess +import sys +import tempfile +import unittest +import uuid + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / 'tools/configure-darling-platform.py' +spec = importlib.util.spec_from_file_location('provisioning', SCRIPT) +platform = importlib.util.module_from_spec(spec) +spec.loader.exec_module(platform) + + +class ProvisioningChecks(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.prefix = self.root / 'prefix with spaces' + self.runtime = self.root / 'runtime with spaces' + self.version = self.runtime / 'System/Library/CoreServices/SystemVersion.plist' + self.version.parent.mkdir(parents=True) + self.metadata = {'ProductName': 'Darling', 'ProductVersion': '11.7.4', 'ProductBuildVersion': 'Darling-test'} + self.version.write_bytes(plistlib.dumps(self.metadata)) + self.command = [sys.executable, str(SCRIPT), '--prefix', str(self.prefix), + '--provision', '--runtime-root', str(self.runtime)] + + def run_cli(self, *args): + result = subprocess.run([*self.command, *args], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + return result + + def test_generated_shapes_uuid_byte_order_and_prefix_independence(self): + values = [platform.provision({}) for _ in range(20)] + for value in values: + for key, length in [('IOPlatformSerialNumber', 12), ('mlb-serial-number', 17)]: + self.assertEqual(len(value[key]), length) + self.assertTrue(set(value[key]) <= set(platform.BASE34)) + parsed = uuid.UUID(value['IOPlatformUUID']) + self.assertEqual(parsed.version, 4) + self.assertEqual(parsed.variant, uuid.RFC_4122) + self.assertEqual(value['system-id'], parsed.bytes_le) + self.assertEqual(value['provisioning_UDID'], str(parsed).upper()) + self.assertEqual(len(value['ROM']), 6) + self.assertEqual(value['ROM'][0] & 3, 2) + self.assertEqual(len({v['IOPlatformUUID'] for v in values}), len(values)) + + def test_preview_does_not_create_prefix(self): + preview = json.loads(self.run_cli().stdout) + self.assertIn('provisioning_UDID', preview['properties']) + self.assertFalse(self.prefix.exists()) + + def test_write_three_files_idempotent_and_importable(self): + self.run_cli('--write') + canonical = platform.destination(self.prefix) + legacy = self.prefix / 'private/etc/darling-smbios.conf' + version = self.prefix / 'System/Library/CoreServices/SystemVersion.plist' + self.assertEqual(platform.load(canonical), platform.load(legacy)) + self.assertEqual(version.read_bytes(), self.version.read_bytes()) + files = [canonical, legacy, version] + snapshot = [(f.read_bytes(), f.stat().st_mtime_ns) for f in files] + self.assertIn('unchanged', self.run_cli('--write').stdout) + self.assertEqual(snapshot, [(f.read_bytes(), f.stat().st_mtime_ns) for f in files]) + for f in [canonical, legacy]: + self.assertEqual(f.stat().st_mode & 0o777, 0o600) + + def test_known_defaults_replaced_and_custom_values_preserved(self): + legacy = self.prefix / 'private/etc/darling-smbios.conf' + legacy.parent.mkdir(parents=True) + original = (f'SMBIOS_SERIAL_NUMBER={platform.KNOWN_SERIAL}\n' + f'SMBIOS_PLATFORM_UUID={platform.KNOWN_UUID}\n' + 'SMBIOS_MLB=CUSTOM-BOARD\nSMBIOS_MODEL=CustomModel\nSMBIOS_ROM=001122334455\n').encode() + legacy.write_bytes(original) + self.run_cli('--write') + result = platform.load(platform.destination(self.prefix)) + self.assertNotEqual(result['IOPlatformSerialNumber'], platform.KNOWN_SERIAL) + self.assertNotEqual(result['IOPlatformUUID'], platform.KNOWN_UUID) + self.assertEqual(result['mlb-serial-number'], 'CUSTOM-BOARD') + self.assertEqual(result['model'], 'CustomModel') + self.assertEqual(result['ROM'], bytes.fromhex('001122334455')) + self.assertEqual(legacy.with_name(legacy.name+'.before-platform-change').read_bytes(), original) + before = result.copy() + self.run_cli('--write') + self.assertEqual(platform.load(platform.destination(self.prefix)), before) + + def test_custom_canonical_identity_wins_over_legacy_defaults(self): + custom = platform.provision({}) + platform.save(self.prefix, custom) + legacy = self.prefix / 'private/etc/darling-smbios.conf' + legacy.write_text(f'SMBIOS_SERIAL_NUMBER={platform.KNOWN_SERIAL}\nSMBIOS_PLATFORM_UUID={platform.KNOWN_UUID}\n') + self.run_cli('--write') + self.assertEqual(platform.load(platform.destination(self.prefix)), custom) + + def test_explicit_oui_and_existing_rom(self): + value = platform.provision({}, bytes.fromhex('001122')) + self.assertEqual(value['ROM'][:3], bytes.fromhex('001122')) + self.assertEqual(platform.provision(value, bytes.fromhex('AABBCC')), value) + for oui in ['1234', '011122', 'garbage']: + result = subprocess.run([*self.command, '--rom-oui', oui, '--write'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(platform.destination(self.prefix).exists()) + + def test_version_source_required_before_identity_written(self): + for raw in [b'not a plist', plistlib.dumps({'ProductVersion': '99.0'})]: + self.version.write_bytes(raw) + result = subprocess.run([*self.command, '--write'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(platform.destination(self.prefix).exists()) + self.assertFalse((self.prefix / 'private/etc/darling-smbios.conf').exists()) + self.version.unlink() + result = subprocess.run([*self.command, '--write'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn('Traceback', result.stderr) + + def test_system_version_backup_and_symlink_preflight(self): + target = self.prefix / 'System/Library/CoreServices/SystemVersion.plist' + target.parent.mkdir(parents=True) + original = plistlib.dumps({'ProductVersion': 'old'}) + target.write_bytes(original) + self.run_cli('--write') + self.assertEqual(target.with_name(target.name+'.before-platform-change').read_bytes(), original) + self.assertEqual(plistlib.loads(target.read_bytes()), self.metadata) + target.unlink() + target.symlink_to(self.version) + before = platform.destination(self.prefix).read_bytes() + result = subprocess.run([*self.command, '--set', 'model=Changed', '--write'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(platform.destination(self.prefix).read_bytes(), before) + + def test_live_prefix_is_not_changed(self): + self.prefix.mkdir() + (self.prefix / '.init.pid').write_text(str(os.getpid())) + result = subprocess.run([*self.command, '--write'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(platform.destination(self.prefix).exists()) + + def test_concurrent_writers_share_one_identity(self): + workers = [subprocess.Popen([*self.command, '--write'], stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) for _ in range(4)] + for worker in workers: + output, error = worker.communicate(timeout=15) + self.assertEqual(worker.returncode, 0, error) + canonical = platform.destination(self.prefix) + self.assertEqual(platform.load(canonical), platform.load(self.prefix / 'private/etc/darling-smbios.conf')) + self.assertFalse(canonical.with_name(canonical.name+'.before-platform-change').exists()) + + def test_custom_system_id_is_preserved_and_udid_mismatch_rejected(self): + value = platform.provision({}) + self.assertEqual(platform.provision({'provisioning_UDID': value['provisioning_UDID']})['IOPlatformUUID'], value['IOPlatformUUID']) + supplied = {'system-id': value['system-id']} + self.assertEqual(platform.provision(supplied)['IOPlatformUUID'], value['IOPlatformUUID']) + with self.assertRaises(ValueError): + platform.validate({'IOPlatformUUID': value['IOPlatformUUID'], 'provisioning_UDID': str(uuid.uuid4())}) diff --git a/tools/configure-darling-platform.py b/tools/configure-darling-platform.py index 9ba1c87..7332dca 100644 --- a/tools/configure-darling-platform.py +++ b/tools/configure-darling-platform.py @@ -2,6 +2,9 @@ """Preview or explicitly save per-prefix IOKit platform overrides; defaults stay unchanged.""" import argparse import json +import fcntl +import secrets +from contextlib import nullcontext import os from pathlib import Path import plistlib @@ -11,6 +14,9 @@ import uuid from xml.parsers.expat import ExpatError MAX_SIZE = 65536 +BASE34 = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZ' +KNOWN_SERIAL = 'C02ZL0GVMD6N' +KNOWN_UUID = '6F3B2A1E-8C4D-4E5F-9A7B-1D2C3E4F5A6B' STRINGS = {'IOPlatformSerialNumber': 64, 'manufacturer': 128, 'model': 128, 'product-name': 128, 'board-id': 128, 'mlb-serial-number': 64} BYTES = {'ROM': 6, 'system-id': 16} @@ -22,23 +28,24 @@ ALIASES = { 'SystemUUID': 'IOPlatformUUID', 'SmUUID': 'IOPlatformUUID', 'SMBIOS_PLATFORM_UUID': 'IOPlatformUUID', 'MLB': 'mlb-serial-number', 'Board Serial': 'mlb-serial-number', 'SMBIOS_MLB': 'mlb-serial-number', 'SMBIOS_ROM': 'ROM', 'SMBIOS_SYSTEM_ID': 'system-id', + 'SMBIOS_PROVISIONING_UDID': 'provisioning_UDID', } def validate(properties): - if not isinstance(properties, dict) or len(properties) > 9: + if not isinstance(properties, dict) or len(properties) > 10: raise ValueError('properties must be a dictionary of supported identity fields') result = {} for key, value in properties.items(): if key in STRINGS: if not isinstance(value, str) or not 1 <= len(value) <= STRINGS[key] or any(not 32 <= ord(c) <= 126 for c in value): raise ValueError('Invalid printable string for ' + key) - elif key == 'IOPlatformUUID': + elif key in ('IOPlatformUUID', 'provisioning_UDID'): if not isinstance(value, str): - raise ValueError('IOPlatformUUID must be a UUID string') + raise ValueError(key + ' must be a UUID string') parsed = uuid.UUID(value) if not parsed.int: - raise ValueError('IOPlatformUUID must not be zero') + raise ValueError(key + ' must not be zero') value = str(parsed).upper() elif key in BYTES: if isinstance(value, str): @@ -55,6 +62,8 @@ def validate(properties): if 'system-id' in result and result['system-id'] != system_id: raise ValueError('system-id does not match IOPlatformUUID') result['system-id'] = system_id + if result.get('provisioning_UDID', result['IOPlatformUUID']) != result['IOPlatformUUID']: + raise ValueError('provisioning_UDID must match IOPlatformUUID') return result @@ -101,7 +110,7 @@ def load(source): result = {} for key, value in payload.items(): name = ALIASES.get(key, key) - if name not in {*STRINGS, *BYTES, 'IOPlatformUUID'}: + if name not in {*STRINGS, *BYTES, 'IOPlatformUUID', 'provisioning_UDID'}: continue # OpenCore includes unrelated boot, firmware and CPU settings. if name in result and result[name] != value: raise ValueError('Conflicting imports for ' + name) @@ -111,50 +120,49 @@ def load(source): return validate(result) -def destination(prefix): +def checked_path(prefix, relative): prefix = Path(prefix).expanduser() if not prefix.is_absolute(): raise ValueError('Prefix must be absolute') - path = prefix / 'private/etc/darling-platform.plist' + path = prefix / relative if path.is_symlink() or not path.resolve().is_relative_to(prefix.resolve()): raise ValueError('Identity destination must remain inside the selected prefix') return path -def save(prefix, properties): - path = destination(prefix) - prefix = Path(prefix) - # A host write behind Darling's mounted overlay may not be visible. Never - # stop a user's session implicitly or write a second copy behind its back. - marker = prefix / '.init.pid' +def destination(prefix): + return checked_path(prefix, 'private/etc/darling-platform.plist') + + +def ensure_stopped(prefix): + marker = Path(prefix) / '.init.pid' if marker.exists(): + text = marker.read_text().strip() + if not text.isdecimal() or int(text) <= 1: + raise ValueError('Invalid .init.pid; verify the selected prefix is stopped before writing') try: - text = marker.read_text().strip() - if not text.isdecimal() or int(text) <= 1: - raise ValueError('Invalid .init.pid; verify the selected prefix is stopped before writing') - pid = int(text) - os.kill(pid, 0) + os.kill(int(text), 0) except ProcessLookupError: - pass - else: - raise ValueError('Shut down the selected Darling prefix before writing platform identity') - encoded = plistlib.dumps({'schema': 1, 'properties': validate(properties)}, sort_keys=True) - if path.exists() and path.read_bytes() == encoded: + return + raise ValueError('Shut down the selected Darling prefix before writing platform identity') + + +def atomic_write(path, data): + if path.exists() and path.read_bytes() == data: return False path.parent.mkdir(parents=True, exist_ok=True) - # Recheck after creating parents, before opening a temporary file. - path = destination(prefix) if path.exists(): - load(path) # refuse to silently overwrite an unknown schema backup = path.with_name(path.name + '.before-platform-change') if not backup.exists(): fd = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, 'wb') as stream: stream.write(path.read_bytes()) + stream.flush() + os.fsync(stream.fileno()) fd, temporary = tempfile.mkstemp(prefix='.darling-platform-', dir=path.parent) try: with os.fdopen(fd, 'wb') as stream: - stream.write(encoded) + stream.write(data) stream.flush() os.fsync(stream.fileno()) os.replace(temporary, path) @@ -163,38 +171,162 @@ def save(prefix, properties): return True +def save(prefix, properties): + path = destination(prefix) + ensure_stopped(prefix) + encoded = plistlib.dumps({'schema': 1, 'properties': validate(properties)}, sort_keys=True) + checked_path(prefix, 'private/etc/darling-platform.plist.before-platform-change') + if path.exists(): + load(path) # refuse to silently overwrite an unknown schema + path.parent.mkdir(parents=True, exist_ok=True) + return atomic_write(destination(prefix), encoded) + + +def provision(properties, rom_oui=None): + """Fill missing identifiers, replacing only the two known shared defaults.""" + result = validate(properties) + if result.get('IOPlatformSerialNumber') == KNOWN_SERIAL: + result.pop('IOPlatformSerialNumber') + if result.get('IOPlatformUUID', '').upper() == KNOWN_UUID: + result.pop('IOPlatformUUID') + result.pop('system-id', None) + result.pop('provisioning_UDID', None) + for key, length in [('IOPlatformSerialNumber', 12), ('mlb-serial-number', 17)]: + if key not in result: + result[key] = ''.join(secrets.choice(BASE34) for _ in range(length)) + if 'IOPlatformUUID' not in result: + # Preserve a custom standalone EFI system ID during legacy migration. + result['IOPlatformUUID'] = (result['provisioning_UDID'] if 'provisioning_UDID' in result else + str(uuid.UUID(bytes_le=result['system-id']) if 'system-id' in result else uuid.uuid4()).upper()) + result.setdefault('provisioning_UDID', result['IOPlatformUUID']) + if 'ROM' not in result: + if rom_oui is not None: + result['ROM'] = rom_oui + secrets.token_bytes(3) + else: + value = bytearray(secrets.token_bytes(6)) + value[0] = (value[0] | 2) & 0xfe # locally administered, unicast + result['ROM'] = bytes(value) + return validate(result) + + +def provisioning_files(prefix, properties, runtime_root): + """Preflight every file before making any identity changes.""" + version = Path(runtime_root) / 'System/Library/CoreServices/SystemVersion.plist' + with version.open('rb') as stream: + raw = stream.read(MAX_SIZE + 1) + if len(raw) > MAX_SIZE: + raise ValueError('Runtime SystemVersion.plist exceeds 64 KiB') + metadata = plistlib.loads(raw) + if not isinstance(metadata, dict) or any(not isinstance(metadata.get(key), str) or not metadata[key] + for key in ('ProductName', 'ProductVersion', 'ProductBuildVersion')): + raise ValueError('Runtime SystemVersion.plist is missing version metadata') + reverse = {value: key for key, value in ALIASES.items() if key.startswith('SMBIOS_')} + lines = ['# Synthetic per-prefix identity; generated by configure-darling-platform.py.'] + for key, value in sorted(properties.items()): + value = value.hex().upper() if isinstance(value, bytes) else value + lines.append(reverse[key] + '=' + shlex.quote(value)) + files = { + 'private/etc/darling-smbios.conf': ('\n'.join(lines) + '\n').encode(), + 'System/Library/CoreServices/SystemVersion.plist': raw, + 'private/etc/darling-platform.plist': plistlib.dumps({'schema': 1, 'properties': properties}, sort_keys=True), + } + for relative in files: + path = checked_path(prefix, relative) + checked_path(prefix, relative + '.before-platform-change') + if path.exists(): + if relative.endswith('darling-platform.plist'): + load(path) + elif relative.endswith('.conf'): + load(path) + else: + if not isinstance(plistlib.loads(path.read_bytes()), dict): + raise ValueError('Existing SystemVersion.plist is invalid') + return files + + +def save_provisioning(prefix, properties, runtime_root): + ensure_stopped(prefix) + files = provisioning_files(prefix, properties, runtime_root) + # Save the canonical identity first: an interrupted run reuses it on retry. + order = ['private/etc/darling-platform.plist', 'private/etc/darling-smbios.conf', + 'System/Library/CoreServices/SystemVersion.plist'] + changed = False + for relative in order: + changed = atomic_write(checked_path(prefix, relative), files[relative]) or changed + return changed + + +def prefix_lock(prefix): + ensure_stopped(prefix) + path = checked_path(prefix, 'private/etc/.darling-platform.lock') + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + stream = os.fdopen(fd, 'r+') + fcntl.flock(stream, fcntl.LOCK_EX) + return stream + + def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--prefix', type=Path) parser.add_argument('--import', dest='source', type=Path, help='OpenCore plist, GenSMBIOS JSON, legacy .conf, or schema-1 plist/JSON') parser.add_argument('--set', action='append', default=[], metavar='KEY=VALUE') + parser.add_argument('--provision', action='store_true', help='Generate missing synthetic identifiers and provision prefix metadata') + parser.add_argument('--runtime-root', type=Path, help='Runtime supplying SystemVersion.plist (default: saved SDK or /usr/local/libexec/darling)') + parser.add_argument('--rom-oui', help='Explicit three-byte unicast OUI for a missing ROM; default is locally administered') parser.add_argument('--write', action='store_true', help='Write validated overrides to a stopped prefix (otherwise preview only)') args = parser.parse_args(argv) config = Path(__file__).resolve().parents[1] / 'build/config.json' saved = json.loads(config.read_text()) if config.exists() else {} prefix = args.prefix or Path(os.environ.get('DPREFIX') or saved.get('prefix', str(Path.home() / '.darling'))) try: - path = destination(prefix) - properties = load(path) if path.exists() else {} - if args.source: - incoming = load(args.source) - # Replacing a UUID also replaces its previously derived byte form. - if 'IOPlatformUUID' in incoming: - properties.pop('system-id', None) - properties.update(incoming) - for assignment in args.set: - key, sep, value = assignment.partition('=') - if not sep: - raise ValueError('--set requires KEY=VALUE') - if key == 'IOPlatformUUID': properties.pop('system-id', None) - if key == 'model': properties.pop('product-name', None) - properties[key] = value - properties = validate(properties) - if args.write: - changed = save(prefix, properties) - print('Platform identity ' + ('saved' if changed else 'unchanged') + '; restart iokitd to apply.') - else: - print(json.dumps({'schema': 1, 'properties': properties}, default=lambda value: value.hex().upper(), indent=2)) + prefix = prefix.expanduser() + rom_oui = None + if args.rom_oui: + if not args.provision: + raise ValueError('--rom-oui requires --provision') + rom_oui = bytes.fromhex(args.rom_oui.replace(':', '').replace('-', '')) + if len(rom_oui) != 3 or rom_oui[0] & 1: + raise ValueError('--rom-oui requires exactly three unicast bytes') + with prefix_lock(prefix) if args.write else nullcontext(): + path = destination(prefix) + properties = {} + if args.provision: + legacy = checked_path(prefix, 'private/etc/darling-smbios.conf') + if legacy.exists(): + properties.update(load(legacy)) + if path.exists(): + incoming = load(path) + if 'IOPlatformUUID' in incoming: + properties.pop('system-id', None) + properties.pop('provisioning_UDID', None) + properties.update(incoming) + if args.source: + incoming = load(args.source) + if 'IOPlatformUUID' in incoming: + properties.pop('system-id', None) + properties.pop('provisioning_UDID', None) + properties.update(incoming) + for assignment in args.set: + key, sep, value = assignment.partition('=') + if not sep: + raise ValueError('--set requires KEY=VALUE') + if key == 'IOPlatformUUID': + properties.pop('system-id', None) + properties.pop('provisioning_UDID', None) + if key == 'model': properties.pop('product-name', None) + properties[key] = value + runtime_root = args.runtime_root or Path(saved.get('sdk', '/usr/local/libexec/darling')) + if args.provision: + properties = provision(properties, rom_oui) + provisioning_files(prefix, properties, runtime_root) + else: + properties = validate(properties) + if args.write: + changed = (save_provisioning(prefix, properties, runtime_root) if args.provision else save(prefix, properties)) + print('Platform identity ' + ('saved' if changed else 'unchanged') + '; restart iokitd to apply.') + else: + print(json.dumps({'schema': 1, 'properties': properties}, default=lambda value: value.hex().upper(), indent=2)) except (OSError, ValueError, TypeError, plistlib.InvalidFileException, ExpatError) as error: parser.exit(1, str(error) + '\n') return 0 diff --git a/tools/riot-registry-compatibility-check.mm b/tools/riot-registry-compatibility-check.mm index ee8d157..8103281 100644 --- a/tools/riot-registry-compatibility-check.mm +++ b/tools/riot-registry-compatibility-check.mm @@ -64,12 +64,23 @@ static void checkProperties(Fixture& fixture) { } static void checkIdentity() { NSString* uuid = @"00112233-4455-4677-8899-AABBCCDDEEFF"; - auto* valid = DarlingPlatformIdentity::properties(@{@"schema": @1, @"properties": @{@"model": @"ExampleModel1,1", @"IOPlatformUUID": uuid, @"IOPlatformSerialNumber": @"SUPPLIED"}}); + auto* valid = DarlingPlatformIdentity::properties(@{@"schema": @1, @"properties": @{@"model": @"ExampleModel1,1", @"provisioning_UDID": uuid, @"IOPlatformUUID": uuid, @"IOPlatformSerialNumber": @"SUPPLIED"}}); assert(valid && [valid[@"IOPlatformUUID"] isEqual:uuid]); + assert([valid[@"provisioning_UDID"] isEqual:uuid]); + NSMutableDictionary* complete = [@{@"IOPlatformSerialNumber": @"SYNTHETIC123", @"IOPlatformUUID": uuid, + @"provisioning_UDID": uuid, @"model": @"ExampleModel", @"product-name": @"ExampleModel", + @"manufacturer": @"Example", @"board-id": @"Board", @"mlb-serial-number": @"SYNTHETIC-BOARD", + @"ROM": [NSData dataWithBytes:"abcdef" length:6]} mutableCopy]; + complete[@"system-id"] = valid[@"system-id"]; + assert([complete count] == 10); + assert(DarlingPlatformIdentity::properties(@{@"schema": @1, @"properties": complete})); + [complete release]; assert([valid[@"model"] isKindOfClass:[NSData class]] && [valid[@"model"] isEqual:valid[@"product-name"]]); const unsigned char bytes[] = {0x33,0x22,0x11,0,0x55,0x44,0x77,0x46,0x88,0x99,0xaa,0xbb,0xcc,0xdd,0xee,0xff}; assert([valid[@"system-id"] isEqual:[NSData dataWithBytes:bytes length:16]]); for (NSDictionary* invalid in @[ + @{@"schema": @1, @"properties": @{@"provisioning_UDID": @"bad"}}, + @{@"schema": @1, @"properties": @{@"IOPlatformUUID": uuid, @"provisioning_UDID": @"11112233-4455-4677-8899-AABBCCDDEEFF"}}, @{@"schema": @2, @"properties": @{}}, @{@"schema": @YES, @"properties": @{}}, @{@"schema": @1, @"properties": @{@"unknown": @"value"}}, @{@"schema": @1, @"properties": @{@"ROM": [NSData data]}}, diff --git a/tools/run-riot-native-check.py b/tools/run-riot-native-check.py index 2eafd83..964ecc6 100644 --- a/tools/run-riot-native-check.py +++ b/tools/run-riot-native-check.py @@ -9,6 +9,7 @@ import tempfile root = Path(__file__).resolve().parent.parent checks = { + "riot-registry-compatibility-check": "REGISTRY_COMPAT", "riot-cfhost-cancel-check": "CFHOST_CANCEL_CHECK", "riot-launchservices-null-error-check": "LAUNCHSERVICES_NULL_ERROR", "m327-cgl-resolver-check": "GL_RESOLVER",