Skip to content

Commit 1cdf784

Browse files
chrfalchmeta-codesync[bot]
authored andcommitted
fix(iOS): prevent UUID collision corrupting Pods project in spm_dependency (#57576)
Summary: The first nightly containing the prebuilt/SwiftPM stack (`0.88.0-nightly-20260716`) broke the `react-native-enriched-markdown` job in [react-native-community/nightly-tests](https://github.com/react-native-community/nightly-tests/actions/runs/29476835418/job/87551590732): `pod install` succeeds, but xcodebuild cannot load the generated project: ``` -[XCRemoteSwiftPackageReference _setSavedArchiveVersion:]: unrecognized selector sent to instance Failed to load container at path: .../Pods/Pods.xcodeproj — "The project 'Pods' is damaged and cannot be opened." ``` **Root cause:** `spm_dependency` injects `XCRemoteSwiftPackageReference` / `XCSwiftPackageProductDependency` objects from a `post_install` hook via `project.new(...)`. CocoaPods' `Pod::Project` overrides UUID generation with a deterministic counter scheme (`prefix + %07X counter + trailing 0`) and deliberately skips collision checks, assuming the project is always freshly generated. When the generator counters are out of sync with the loaded object graph, the first generated UUID collides with an existing object — in the observed failure the root `PBXProject`'s own UUID (`46EB2E00000000`). The injected package reference silently overwrites the root object entry in `objects_by_uuid`, so the saved pbxproj's `rootObject` points at the package reference, and Xcode's loader sends `PBXProject`-only messages to it. The bug is latent in `spm_dependency` since its introduction; the recently grown Pods object graph merely exposed it. **Fix:** route object creation through a `new_object` helper that keeps the deterministic counter scheme but probes `generate_uuid` forward past any UUID already present in `objects_by_uuid`. Uniqueness is guaranteed against the live object table regardless of project size/state; `generate_uuid`'s counter is strictly monotonic, so the probe terminates and never repeats within a run. Repeated `pod install`s stay idempotent via the existing find-before-create dedup guards. ## Changelog: [IOS] [FIXED] - Fix "The project 'Pods' is damaged and cannot be opened" when a library uses `spm_dependency` and the generated UUID collides with an existing Pods project object Pull Request resolved: #57576 Test Plan: - New standalone test `scripts/cocoapods/__tests__/spm-test.rb` (runs via `ruby scripts/cocoapods/__tests__/spm-test.rb`, uses the real `Pod::Project`): - fresh-project injection (passes before and after — fresh counters don't collide, isolating the trigger), - injection after counter reset (reproduces the corruption against the previous code as `NoMethodError: undefined method 'product_ref_group' for ... XCRemoteSwiftPackageReference` on reload — the Ruby analog of Xcode's crash; passes with the fix), - global UUID uniqueness + clean `Xcodeproj::Project.open` round-trip. - 3 tests, 10 assertions, 0 failures with the fix. - E2E: to be confirmed with a `spm_dependency` library (e.g. react-native-enriched-markdown) in prebuilt mode. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed By: fabriziocucci Differential Revision: D112320831 Pulled By: cipolleschi fbshipit-source-id: de190f6492abff2cc6dbd94347e5346d11b50a41
1 parent afa2a60 commit 1cdf784

2 files changed

Lines changed: 126 additions & 3 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
#
3+
# This source code is licensed under the MIT license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
require "test/unit"
7+
require "fileutils"
8+
require "cocoapods"
9+
require_relative "../spm.rb"
10+
11+
# These tests exercise the real `Pod::Project` UUID machinery (via the
12+
# `xcodeproj`/`cocoapods` gems) because the bug they guard against is emergent
13+
# from how `Pod::Project` hands out UUIDs, and cannot be observed against a mock.
14+
class SPMTests < Test::Unit::TestCase
15+
PodSpecStub = Struct.new(:name)
16+
InstallerStub = Struct.new(:pods_project)
17+
18+
POD_NAME = "ReactNativeEnrichedMarkdown"
19+
TMP_DIR = File.join(Dir.tmpdir, "rn-spm-test")
20+
21+
def setup
22+
FileUtils.rm_rf(TMP_DIR)
23+
FileUtils.mkdir_p(TMP_DIR)
24+
end
25+
26+
def teardown
27+
FileUtils.rm_rf(TMP_DIR)
28+
end
29+
30+
def build_project(num_pods)
31+
path = File.join(TMP_DIR, "Pods.xcodeproj")
32+
FileUtils.mkdir_p(path)
33+
project = Pod::Project.new(path)
34+
num_pods.times { |i| project.new_target(:static_library, "Pod#{i}", :ios) }
35+
project.new_target(:static_library, POD_NAME, :ios)
36+
project.save
37+
project
38+
end
39+
40+
def inject_spm(project)
41+
manager = SPMManager.new
42+
manager.dependency(
43+
PodSpecStub.new(POD_NAME),
44+
url: "https://github.com/software-mansion-labs/RaTeX.git",
45+
requirement: { kind: "upToNextMajorVersion", minimumVersion: "0.1.0" },
46+
products: ["RaTeX"]
47+
)
48+
manager.apply_on_post_install(InstallerStub.new(project))
49+
project.save
50+
end
51+
52+
# Simulates the state after an on-disk reload / incremental `pod install`:
53+
# existing objects keep their counter-based UUIDs, but the generator counters
54+
# reset to zero. This is what makes `Pod::Project#generate_uuid` hand back a
55+
# UUID (`<prefix>00000000`) that already belongs to the root object.
56+
def simulate_reload(project)
57+
project.instance_variable_set(:@generated_uuids, [])
58+
project.instance_variable_set(:@available_uuids, [])
59+
end
60+
61+
def assert_loadable_project(path)
62+
reopened = nil
63+
assert_nothing_raised("Pods project must reload cleanly after SPM injection") do
64+
reopened = Xcodeproj::Project.open(path)
65+
end
66+
root_uuid = reopened.root_object.uuid
67+
assert(
68+
reopened.objects_by_uuid[root_uuid].is_a?(Xcodeproj::Project::Object::PBXProject),
69+
"rootObject UUID must resolve to a PBXProject"
70+
)
71+
package_uuids = reopened.root_object.package_references.map(&:uuid)
72+
assert(
73+
package_uuids.none? { |uuid| uuid == root_uuid },
74+
"injected package reference must not collide with the root object UUID"
75+
)
76+
reopened
77+
end
78+
79+
def test_spm_injection_on_freshly_generated_project_reloads_cleanly
80+
project = build_project(88)
81+
inject_spm(project)
82+
assert_loadable_project(project.path)
83+
end
84+
85+
def test_spm_injection_after_project_reload_does_not_collide_with_root_object
86+
project = build_project(88)
87+
simulate_reload(project)
88+
inject_spm(project)
89+
assert_loadable_project(project.path)
90+
end
91+
92+
def test_injected_uuids_are_unique_across_all_objects
93+
project = build_project(88)
94+
simulate_reload(project)
95+
inject_spm(project)
96+
reopened = assert_loadable_project(project.path)
97+
uuids = reopened.objects.map(&:uuid)
98+
assert_equal(uuids.length, uuids.uniq.length, "all object UUIDs must be unique")
99+
end
100+
end

packages/react-native/scripts/cocoapods/spm.rb

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,29 @@ def apply_on_post_install(installer)
5353

5454
private
5555

56+
# Creates a new object in the project with a UUID guaranteed not to collide
57+
# with any UUID already present in the project.
58+
#
59+
# `Pod::Project` overrides `generate_available_uuid_list` with a fast,
60+
# counter-based scheme (`<sha prefix><counter>0`) that deliberately skips
61+
# collision checks, on the assumption that the whole Pods project is generated
62+
# in a single pass. That assumption does not hold here: we run in a
63+
# `post_install` hook, and the generator's counter can be out of sync with the
64+
# UUIDs already assigned to existing objects (e.g. when the project has been
65+
# reloaded from disk during an incremental install, the counter restarts at 0
66+
# while the root object still occupies `<prefix>00000000`). Using `project.new`
67+
# directly can therefore hand back a UUID that is already in use and overwrite
68+
# an existing object (notably the root `PBXProject`), producing a Pods project
69+
# Xcode refuses to load. We keep the deterministic scheme but probe forward
70+
# until we find a UUID that is actually free.
71+
def new_object(project, klass)
72+
uuid = project.generate_uuid
73+
uuid = project.generate_uuid while project.objects_by_uuid.key?(uuid)
74+
object = klass.new(project, uuid)
75+
object.initialize_defaults
76+
object
77+
end
78+
5679
def log(msg)
5780
::Pod::UI.puts "[SPM] #{msg}"
5881
end
@@ -76,7 +99,7 @@ def add_spm_to_target(project, target, url, requirement, products)
7699
pkg_class = Xcodeproj::Project::Object::XCLocalSwiftPackageReference
77100
pkg = project.root_object.package_references.find { |p| p.class == pkg_class && p.relative_path == url }
78101
if !pkg
79-
pkg = project.new(pkg_class)
102+
pkg = new_object(project, pkg_class)
80103
pkg.relative_path = url
81104
log(" Adding local package to workspace: #{pkg.inspect}")
82105
project.root_object.package_references << pkg
@@ -85,7 +108,7 @@ def add_spm_to_target(project, target, url, requirement, products)
85108
pkg_class = Xcodeproj::Project::Object::XCRemoteSwiftPackageReference
86109
pkg = project.root_object.package_references.find { |p| p.class == pkg_class && p.repositoryURL == url }
87110
if !pkg
88-
pkg = project.new(pkg_class)
111+
pkg = new_object(project, pkg_class)
89112
pkg.repositoryURL = url
90113
pkg.requirement = requirement
91114
log(" Adding remote package to workspace: #{pkg.inspect}")
@@ -101,7 +124,7 @@ def add_spm_to_target(project, target, url, requirement, products)
101124
next if ref
102125

103126
log(" Adding product dependency #{product_name} to #{target.name}")
104-
ref = project.new(ref_class)
127+
ref = new_object(project, ref_class)
105128
ref.package = pkg
106129
ref.product_name = product_name
107130
target.package_product_dependencies << ref

0 commit comments

Comments
 (0)