diff --git a/docs/PLATFORM_IDENTITY.md b/docs/PLATFORM_IDENTITY.md index 3d132ea..111afa5 100644 --- a/docs/PLATFORM_IDENTITY.md +++ b/docs/PLATFORM_IDENTITY.md @@ -90,7 +90,12 @@ python3 tools/configure-darling-platform.py --prefix /absolute/prefix --provisio 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. +is never called automatically by installation or launch. The one exception is +`scripts/update.sh`: after installing, it runs +`configure-darling-platform.py --replace-known-defaults --write`, which +provisions the prefix only if its saved identity still contains the shared +serial or UUID below. Prefixes without saved identity files, or whose saved +values are all custom, are left byte-for-byte unchanged. Missing serials use 12 characters and missing MLBs use 17 characters, sampled with Python's `secrets` module from `0123456789ABCDEFGHJKLMNPQRSTUVWXYZ` (Base-34). @@ -106,7 +111,8 @@ 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. +`6F3B2A1E-8C4D-4E5F-9A7B-1D2C3E4F5A6B`, along with the latter's derived fields +(including a standalone `system-id` or `provisioning_UDID` derived from it). 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, diff --git a/scripts/update.sh b/scripts/update.sh index f8e997c..62d0c5c 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -7,6 +7,13 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" +# Older setups shipped one serial and UUID shared by every install. Give a prefix that still +# has them its own identity; any other saved values, and prefixes without them, are left alone. +replace_shared_identity() { + python3 build/runtime/tools/stop-darling-runtime.py + python3 tools/configure-darling-platform.py --replace-known-defaults --write || \ + echo 'Could not replace the shared platform identity; run tools/configure-darling-platform.py --provision --write' >&2 +} if [ -n "${DARLING_WORKSPACE:-}" ]; then python3 scripts/nix-idle.py # Initial setup and upgrades use the same supported user-owned install path. @@ -21,6 +28,7 @@ if [ -n "${DARLING_WORKSPACE:-}" ]; then cmake --install build/darling-src/build python3 scripts/nix-state.py stamp-base ./scripts/install-runtime.sh + replace_shared_identity echo 'Rootless update installed. In this shell use scripts/run.sh; otherwise exit this shell and start darling-lol.' exit 0 fi @@ -45,4 +53,5 @@ PY python3 build/runtime/tools/stop-darling-runtime.py sudo cmake --install build/darling-src/build ./scripts/install-runtime.sh +replace_shared_identity echo 'Update installed. Start Darling with: sh scripts/run.sh' diff --git a/tests/test_platform_provisioning.py b/tests/test_platform_provisioning.py index 48bd0c0..ce195b5 100644 --- a/tests/test_platform_provisioning.py +++ b/tests/test_platform_provisioning.py @@ -157,3 +157,43 @@ class ProvisioningChecks(unittest.TestCase): self.assertEqual(platform.provision(supplied)['IOPlatformUUID'], value['IOPlatformUUID']) with self.assertRaises(ValueError): platform.validate({'IOPlatformUUID': value['IOPlatformUUID'], 'provisioning_UDID': str(uuid.uuid4())}) + + def test_replace_known_defaults_only_touches_shared_identities(self): + replace = [sys.executable, str(SCRIPT), '--prefix', str(self.prefix), '--runtime-root', + str(self.runtime), '--replace-known-defaults', '--write'] + # A prefix without identity files is not created or provisioned. + result = subprocess.run(replace, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.prefix.exists()) + # A custom identity stays byte-for-byte unchanged. + custom = {'IOPlatformSerialNumber': 'MY-SERIAL', 'model': 'CustomModel'} + platform.save(self.prefix, custom) + canonical = platform.destination(self.prefix) + before = canonical.read_bytes() + result = subprocess.run(replace, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(canonical.read_bytes(), before) + self.assertFalse((self.prefix / 'private/etc/darling-smbios.conf').exists()) + # The shared values are replaced; custom fields survive and a rerun is stable. + platform.save(self.prefix, {**custom, 'IOPlatformSerialNumber': platform.KNOWN_SERIAL, + 'IOPlatformUUID': platform.KNOWN_UUID}) + result = subprocess.run(replace, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + value = platform.load(canonical) + self.assertNotEqual(value['IOPlatformSerialNumber'], platform.KNOWN_SERIAL) + self.assertNotEqual(value['IOPlatformUUID'], platform.KNOWN_UUID) + self.assertFalse(platform.has_known_defaults(value)) + self.assertEqual(value['model'], 'CustomModel') + self.assertNotIn('board-id', value) + self.assertNotIn('manufacturer', value) + self.assertEqual(platform.load(self.prefix / 'private/etc/darling-smbios.conf'), value) + after = canonical.read_bytes() + subprocess.run(replace, capture_output=True, text=True, check=True) + self.assertEqual(canonical.read_bytes(), after) + + def test_standalone_fields_derived_from_known_uuid_are_replaced(self): + known_id = uuid.UUID(platform.KNOWN_UUID).bytes_le + for supplied in [{'system-id': known_id}, {'provisioning_UDID': platform.KNOWN_UUID}]: + value = platform.provision(supplied) + self.assertNotEqual(value['IOPlatformUUID'], platform.KNOWN_UUID) + self.assertNotEqual(value['system-id'], known_id) diff --git a/tools/configure-darling-platform.py b/tools/configure-darling-platform.py index 7332dca..9b3408a 100644 --- a/tools/configure-darling-platform.py +++ b/tools/configure-darling-platform.py @@ -182,13 +182,23 @@ def save(prefix, properties): return atomic_write(destination(prefix), encoded) +def has_known_defaults(properties): + """True when an identity still carries the shared serial, UUID, or a field derived from that UUID.""" + return (properties.get('IOPlatformSerialNumber') == KNOWN_SERIAL + or str(properties.get('IOPlatformUUID', '')).upper() == KNOWN_UUID + or str(properties.get('provisioning_UDID', '')).upper() == KNOWN_UUID + or properties.get('system-id') == uuid.UUID(KNOWN_UUID).bytes_le) + + 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') + if (result.get('IOPlatformUUID', '').upper() == KNOWN_UUID + or result.get('provisioning_UDID', '').upper() == KNOWN_UUID + or result.get('system-id') == uuid.UUID(KNOWN_UUID).bytes_le): + result.pop('IOPlatformUUID', None) result.pop('system-id', None) result.pop('provisioning_UDID', None) for key, length in [('IOPlatformSerialNumber', 12), ('mlb-serial-number', 17)]: @@ -274,8 +284,14 @@ def main(argv=None): 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('--replace-known-defaults', action='store_true', + help='Provision only if the prefix identity still has the shared serial or UUID; otherwise change nothing') parser.add_argument('--write', action='store_true', help='Write validated overrides to a stopped prefix (otherwise preview only)') args = parser.parse_args(argv) + if args.replace_known_defaults: + if args.source or args.set: + parser.error('--replace-known-defaults cannot be combined with --import or --set') + args.provision = True 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'))) @@ -288,11 +304,19 @@ def main(argv=None): 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') + identity_files = [checked_path(prefix, 'private/etc/darling-smbios.conf'), destination(prefix)] + if args.replace_known_defaults and not any(path.exists() for path in identity_files): + print('No saved platform identity; nothing to replace.') + return 0 with prefix_lock(prefix) if args.write else nullcontext(): path = destination(prefix) properties = {} + if args.replace_known_defaults and not any( + path.exists() and has_known_defaults(load(path)) for path in identity_files): + print('Platform identity has no shared defaults; nothing changed.') + return 0 if args.provision: - legacy = checked_path(prefix, 'private/etc/darling-smbios.conf') + legacy = identity_files[0] if legacy.exists(): properties.update(load(legacy)) if path.exists():