diff --git a/.github/workflows/deploying.yml b/.github/workflows/deploying.yml index dd21e7c6..f5c55943 100644 --- a/.github/workflows/deploying.yml +++ b/.github/workflows/deploying.yml @@ -30,6 +30,11 @@ jobs: with: path: go key: ${{ runner.os }}-gopenpgp-${{ secrets.CACHE_VERSION }}-${{ hashFiles('**/gopenpgp_build.sh') }} + - uses: actions/cache@v4 + id: libgit2-cache + with: + path: libgit2/dist + key: ${{ runner.os }}-libgit2-${{ secrets.CACHE_VERSION }}-${{ hashFiles('**/libgit2_build.sh') }} - name: Bundle Install run: | bundle config path vendor/bundle @@ -39,8 +44,18 @@ jobs: run: | export PATH="/usr/local/opt/go/bin:$PATH" ./scripts/gopenpgp_build.sh + - name: libgit2 + if: ${{ steps.libgit2-cache.outputs.cache-hit == false }} + run: ./scripts/libgit2_build.sh + - name: Start git servers + run: | + ./scripts/git_servers.sh start + cat .git-servers/env >> "$GITHUB_ENV" - name: Test run: bundle exec fastlane test + - name: Stop git servers + if: always() + run: ./scripts/git_servers.sh stop - name: Deploy run: bundle exec fastlane ${{ matrix.channel }} env: diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 9582c471..06579903 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -23,6 +23,11 @@ jobs: with: path: go key: ${{ runner.os }}-gopenpgp-${{ secrets.CACHE_VERSION }}-${{ hashFiles('**/gopenpgp_build.sh') }} + - uses: actions/cache@v4 + id: libgit2-cache + with: + path: libgit2/dist + key: ${{ runner.os }}-libgit2-${{ secrets.CACHE_VERSION }}-${{ hashFiles('**/libgit2_build.sh') }} - name: Bundle Install run: | bundle config path vendor/bundle @@ -30,5 +35,15 @@ jobs: - name: GopenPGP if: ${{ steps.gopenpgp-cache.outputs.cache-hit == false }} run: ./scripts/gopenpgp_build.sh + - name: libgit2 + if: ${{ steps.libgit2-cache.outputs.cache-hit == false }} + run: ./scripts/libgit2_build.sh + - name: Start git servers + run: | + ./scripts/git_servers.sh start + cat .git-servers/env >> "$GITHUB_ENV" - name: Testing run: bundle exec fastlane test + - name: Stop git servers + if: always() + run: ./scripts/git_servers.sh stop diff --git a/.gitignore b/.gitignore index 19942320..c1c28068 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ Podfile.lock # Go Mobile Build results and dependency sources go/ +libgit2/ # fastlane # @@ -67,3 +68,6 @@ fastlane/test_output # # The Continuous Integration environment will create this file. It avoids specific "Run Script" phases while building. .ci-env + +# Local git servers for the transport tests +.git-servers/ diff --git a/README.md b/README.md index 3161a219..0e3e410e 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,25 @@ For more, please read the [wiki page](https://github.com/mssun/passforios/wiki). ## Building Pass for iOS -1. Install Go: `brew install go`. +1. Install Go and CMake: `brew install go cmake`. 1. Run `./scripts/gopenpgp_build.sh` to build GopenPGP. +1. Run `./scripts/libgit2_build.sh` to build libgit2. This also builds libssh2 + and OpenSSL and takes a while, but only has to be done once. 1. Open the `pass.xcodeproj` file in Xcode. 1. Build & Run. +## Running the tests + +The tests of the SSH and HTTPS transports need local git servers. Without them +those tests are skipped and everything else still runs. + +```sh +./scripts/git_servers.sh start +set -a; source .git-servers/env; set +a +bundle exec fastlane test +./scripts/git_servers.sh stop +``` + ## License MIT diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 1cf2ba13..1c786e07 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -3,6 +3,7 @@ default_platform :ios lane :prepare do carthage(cache_builds: true, platform: "iOS") sh "./scripts/gopenpgp_build.sh" + sh "./scripts/libgit2_build.sh" end lane :reset_build_number do @@ -63,7 +64,15 @@ end platform :ios do desc "Runs all tests" lane :test do - run_tests(scheme: "pass") + # scripts/git_servers.sh trusts its certificate authority on one simulator + # and records which, because a device name can match several runtimes and + # the tests would otherwise run on one that never saw the certificate. + udid = ENV["GIT_SERVERS_DEVICE_UDID"] + if udid + run_tests(scheme: "pass", destination: "platform=iOS Simulator,id=#{udid}") + else + run_tests(scheme: "pass") + end end desc "Submit a new Beta Build to Apple TestFlight" diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index 97a24bf8..d36f54f4 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -99,7 +99,6 @@ 30CCA91A232591320048CA51 /* ObjectivePGPInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30CCA919232591320048CA51 /* ObjectivePGPInterface.swift */; }; 30DAFD4A240985A7002456E7 /* Array+Slices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30DAFD49240985A7002456E7 /* Array+Slices.swift */; }; 30DAFD4C240985E3002456E7 /* Array+SlicesTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30DAFD4B240985E3002456E7 /* Array+SlicesTest.swift */; }; - 30ED1777276F8842009BA876 /* ObjectiveGit in Frameworks */ = {isa = PBXBuildFile; productRef = 30ED1776276F8842009BA876 /* ObjectiveGit */; }; 30EE3A14241AE6EC009FBB61 /* SyncRepositoryIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A69968240EF52E00B7D967 /* SyncRepositoryIntentHandler.swift */; }; 30EE3A16241E98C1009FBB61 /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 30EE3A19241E98C1009FBB61 /* Intents.intentdefinition */; settings = {ATTRIBUTES = (no_codegen, ); }; }; 30EE3A17241E98C1009FBB61 /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 30EE3A19241E98C1009FBB61 /* Intents.intentdefinition */; settings = {ATTRIBUTES = (codegen, ); }; }; @@ -137,8 +136,14 @@ 9A58665125AADB76006719C2 /* CredentialProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A58665025AADB76006719C2 /* CredentialProvider.swift */; }; 9A5C6EF42786CA5F0003F340 /* AlertPresenting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5C6EEF2786C8710003F340 /* AlertPresenting.swift */; }; 9A5C6EFF2787F0980003F340 /* Gopenpgp.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9ADAB21C26DDA52400900F10 /* Gopenpgp.xcframework */; }; + DC64747D2D45B23A004B4BC3 /* libgit2.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC64747A2D45B23A004B4BC0 /* libgit2.xcframework */; }; + DC64748B2D45B23A004B4BD1 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = DC64748A2D45B23A004B4BD0 /* libiconv.tbd */; }; + DC64748F2D45B23A004B4BD5 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = DC64748E2D45B23A004B4BD4 /* libz.tbd */; }; 9A5C6F022787F09A0003F340 /* passKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A26075781EEC6F34005DB03E /* passKit.framework */; }; 9A5C6F042787F09D0003F340 /* Gopenpgp.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9ADAB21C26DDA52400900F10 /* Gopenpgp.xcframework */; }; + DC64747C2D45B23A004B4BC2 /* libgit2.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC64747A2D45B23A004B4BC0 /* libgit2.xcframework */; }; + DC64748C2D45B23A004B4BD2 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = DC64748A2D45B23A004B4BD0 /* libiconv.tbd */; }; + DC6474902D45B23A004B4BD6 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = DC64748E2D45B23A004B4BD4 /* libz.tbd */; }; 9A5C6F082787F0C20003F340 /* SwiftyUserDefaults in Frameworks */ = {isa = PBXBuildFile; productRef = 9A5C6F072787F0C20003F340 /* SwiftyUserDefaults */; }; 9A5D06EE25A56F0800FA59D4 /* PasswordTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A8F9EE1259EDD520027CE15 /* PasswordTableViewCell.swift */; }; 9A5D06F525A56F0E00FA59D4 /* PasswordTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A8F9EE1259EDD520027CE15 /* PasswordTableViewCell.swift */; }; @@ -157,6 +162,9 @@ 9A996C6B26DEB97600A4485D /* passExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A26700241EEC466A00176B8A /* passExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 9A996C6E26DEB99200A4485D /* passKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A26075781EEC6F34005DB03E /* passKit.framework */; }; 9ADAB21D26DDA52400900F10 /* Gopenpgp.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9ADAB21C26DDA52400900F10 /* Gopenpgp.xcframework */; }; + DC64747B2D45B23A004B4BC1 /* libgit2.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC64747A2D45B23A004B4BC0 /* libgit2.xcframework */; }; + DC64748D2D45B23A004B4BD3 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = DC64748A2D45B23A004B4BD0 /* libiconv.tbd */; }; + DC6474912D45B23A004B4BD7 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = DC64748E2D45B23A004B4BD4 /* libz.tbd */; }; 9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ADC954024418A5F0005402E /* PasswordStoreTest.swift */; }; 9AFC87D325B39FF3008D6060 /* PasswordNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AFC87D225B39FF2008D6060 /* PasswordNavigationViewController.swift */; }; 9AFC87E225B3B5C6008D6060 /* PasswordNavigationDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AFC87E125B3B5C6008D6060 /* PasswordNavigationDataSource.swift */; }; @@ -205,7 +213,10 @@ DC64745C2D29BE9B004B4BBC /* PasswordEntityTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */; }; DC64745D2D29BEA9004B4BBC /* CoreDataTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */; }; DC64745F2D45B240004B4BBC /* GitRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC64745E2D45B23A004B4BBC /* GitRepository.swift */; }; + DC64746D2D45B240004B4BBE /* Libgit2.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC64746C2D45B23A004B4BBE /* Libgit2.swift */; }; + DC64746B2D45B240004B4BBD /* GitTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC64746A2D45B23A004B4BBD /* GitTypes.swift */; }; DC6474612D46A8F8004B4BBC /* GitRepositoryTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474602D46A8F2004B4BBC /* GitRepositoryTest.swift */; }; + DC6474E12D45B23A004B4C21 /* GitTransportTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474E02D45B23A004B4C20 /* GitTransportTest.swift */; }; DC7CBBBD2D0FA3F2003BB4D2 /* YubiKit in Frameworks */ = {isa = PBXBuildFile; productRef = DC7CBBBC2D0FA3F2003BB4D2 /* YubiKit */; }; DC7CBBC22D0FA3F2003BB4D3 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = DC7CBBC12D0FA3F2003BB4D3 /* DequeModule */; }; DC7CBBBF2D0FAC92003BB4D2 /* YKFSmartCardInterfaceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC7CBBBE2D0FAC8E003BB4D2 /* YKFSmartCardInterfaceExtension.swift */; }; @@ -431,6 +442,8 @@ 30F6C1B327664C7200BE5AB2 /* SVProgressHUD.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SVProgressHUD.xcframework; path = Carthage/Build/SVProgressHUD.xcframework; sourceTree = ""; }; 30FD2F77214D9E0E005E0A92 /* ParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParserTest.swift; sourceTree = ""; }; 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoTokenKit.framework; path = System/Library/Frameworks/CryptoTokenKit.framework; sourceTree = SDKROOT; }; + DC64748A2D45B23A004B4BD0 /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = usr/lib/libiconv.tbd; sourceTree = SDKROOT; }; + DC64748E2D45B23A004B4BD4 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeychainTest.swift; sourceTree = ""; }; 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerTest.swift; sourceTree = ""; }; 8A4756A22F6374B700131B6D /* PersistenceController+UnitTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistenceController+UnitTestHelpers.swift"; sourceTree = ""; }; @@ -452,6 +465,7 @@ 9A8F9EE1259EDD520027CE15 /* PasswordTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordTableViewCell.swift; sourceTree = ""; }; 9A8F9F3F25A1A91F0027CE15 /* CredentialProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialProvider.swift; sourceTree = ""; }; 9ADAB21C26DDA52400900F10 /* Gopenpgp.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Gopenpgp.xcframework; path = go/dist/Gopenpgp.xcframework; sourceTree = ""; }; + DC64747A2D45B23A004B4BC0 /* libgit2.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = libgit2.xcframework; path = libgit2/dist/libgit2.xcframework; sourceTree = ""; }; 9ADC954024418A5F0005402E /* PasswordStoreTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordStoreTest.swift; sourceTree = ""; }; 9AFC87D225B39FF2008D6060 /* PasswordNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordNavigationViewController.swift; sourceTree = ""; }; 9AFC87E125B3B5C6008D6060 /* PasswordNavigationDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordNavigationDataSource.swift; sourceTree = ""; }; @@ -515,7 +529,10 @@ DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataTestCase.swift; sourceTree = ""; }; DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordEntityTest.swift; sourceTree = ""; }; DC64745E2D45B23A004B4BBC /* GitRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRepository.swift; sourceTree = ""; }; + DC64746C2D45B23A004B4BBE /* Libgit2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Libgit2.swift; sourceTree = ""; }; + DC64746A2D45B23A004B4BBD /* GitTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitTypes.swift; sourceTree = ""; }; DC6474602D46A8F2004B4BBC /* GitRepositoryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRepositoryTest.swift; sourceTree = ""; }; + DC6474E02D45B23A004B4C20 /* GitTransportTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitTransportTest.swift; sourceTree = ""; }; DC7CBBBE2D0FAC8E003BB4D2 /* YKFSmartCardInterfaceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YKFSmartCardInterfaceExtension.swift; sourceTree = ""; }; DC8963BF1E38EEB900828B09 /* SSHKeyURLImportTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSHKeyURLImportTableViewController.swift; sourceTree = ""; }; DC917BD31E2E8231000FDF54 /* Pass.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Pass.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -558,6 +575,9 @@ 9A996C6E26DEB99200A4485D /* passKit.framework in Frameworks */, 30A3001A26DA697C002A734E /* SwiftyUserDefaults in Frameworks */, 9A5C6F042787F09D0003F340 /* Gopenpgp.xcframework in Frameworks */, + DC64747C2D45B23A004B4BC2 /* libgit2.xcframework in Frameworks */, + DC64748C2D45B23A004B4BD2 /* libiconv.tbd in Frameworks */, + DC6474902D45B23A004B4BD6 /* libz.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -568,6 +588,9 @@ DC7CBBC22D0FA3F2003BB4D3 /* DequeModule in Frameworks */, DC7CBBBD2D0FA3F2003BB4D2 /* YubiKit in Frameworks */, 9ADAB21D26DDA52400900F10 /* Gopenpgp.xcframework in Frameworks */, + DC64747B2D45B23A004B4BC1 /* libgit2.xcframework in Frameworks */, + DC64748D2D45B23A004B4BD3 /* libiconv.tbd in Frameworks */, + DC6474912D45B23A004B4BD7 /* libz.tbd in Frameworks */, 30A3001426DA6692002A734E /* KeychainAccess in Frameworks */, 9A996C5726DDF65900A4485D /* Base32 in Frameworks */, 9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */, @@ -595,6 +618,9 @@ 5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */, 9A5C6F022787F09A0003F340 /* passKit.framework in Frameworks */, 9A5C6EFF2787F0980003F340 /* Gopenpgp.xcframework in Frameworks */, + DC64747D2D45B23A004B4BC3 /* libgit2.xcframework in Frameworks */, + DC64748B2D45B23A004B4BD1 /* libiconv.tbd in Frameworks */, + DC64748F2D45B23A004B4BD5 /* libz.tbd in Frameworks */, 9A5C6F082787F0C20003F340 /* SwiftyUserDefaults in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -619,7 +645,6 @@ 3032DA5426DAF4C200A7728C /* ObjectivePGP in Frameworks */, 3010CB6626DA500F008964D2 /* KeychainAccess in Frameworks */, 9A996C5826DEB0D100A4485D /* passKit.framework in Frameworks */, - 30ED1777276F8842009BA876 /* ObjectiveGit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -757,6 +782,7 @@ isa = PBXGroup; children = ( DC6474602D46A8F2004B4BBC /* GitRepositoryTest.swift */, + DC6474E02D45B23A004B4C20 /* GitTransportTest.swift */, 30695E2424FAEF2600C9D46E /* GitCredentialTest.swift */, 9ADC954024418A5F0005402E /* PasswordStoreTest.swift */, A2699ACE24027D9500F36323 /* PasswordTableEntryTest.swift */, @@ -831,6 +857,9 @@ 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */, 30F6C1B327664C7200BE5AB2 /* SVProgressHUD.xcframework */, 9ADAB21C26DDA52400900F10 /* Gopenpgp.xcframework */, + DC64747A2D45B23A004B4BC0 /* libgit2.xcframework */, + DC64748A2D45B23A004B4BD0 /* libiconv.tbd */, + DC64748E2D45B23A004B4BD4 /* libz.tbd */, ); name = Frameworks; sourceTree = ""; @@ -956,6 +985,8 @@ isa = PBXGroup; children = ( DC64745E2D45B23A004B4BBC /* GitRepository.swift */, + DC64746C2D45B23A004B4BBE /* Libgit2.swift */, + DC64746A2D45B23A004B4BBD /* GitTypes.swift */, 30697C4121F63CAB0064FCAC /* GitCredential.swift */, 30697C4221F63CAB0064FCAC /* PasscodeLock.swift */, 30697C4021F63CAB0064FCAC /* Password.swift */, @@ -1311,7 +1342,6 @@ 3032DA5326DAF4C200A7728C /* ObjectivePGP */, 9A996C5226DDF61F00A4485D /* Base32 */, 9A1F47F926E5CF4B000C0E01 /* OneTimePassword */, - 30ED1776276F8842009BA876 /* ObjectiveGit */, 30333B2A2CF924DC008A2EA2 /* SVProgressHUD */, ); productName = pass; @@ -1416,7 +1446,6 @@ 30A3000C26DA62F4002A734E /* XCRemoteSwiftPackageReference "Base32" */, 3032DA5226DAF4C200A7728C /* XCRemoteSwiftPackageReference "ObjectivePGP" */, 9A1F47F826E5CF4B000C0E01 /* XCRemoteSwiftPackageReference "OneTimePassword" */, - 30ED1775276F8842009BA876 /* XCRemoteSwiftPackageReference "objective-git-swift-package" */, 307CA2322CF348260099F6DE /* XCRemoteSwiftPackageReference "SwiftFormat" */, 307CB7522CF9219100D0931F /* XCRemoteSwiftPackageReference "SVProgressHUD" */, 30333B292CF922D9008A2EA2 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */, @@ -1647,6 +1676,8 @@ 30CCA91623258C380048CA51 /* PGPInterface.swift in Sources */, 30DAFD4A240985A7002456E7 /* Array+Slices.swift in Sources */, DC64745F2D45B240004B4BBC /* GitRepository.swift in Sources */, + DC64746D2D45B240004B4BBE /* Libgit2.swift in Sources */, + DC64746B2D45B240004B4BBD /* GitTypes.swift in Sources */, 9A74D2E0277D2F8C00F7BC44 /* UIAlertControllerExtension.swift in Sources */, 30697C4721F63CAB0064FCAC /* PasscodeLock.swift in Sources */, A2699ACD2402631400F36323 /* PasswordTableEntry.swift in Sources */, @@ -1678,6 +1709,7 @@ 9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */, 30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */, DC6474612D46A8F8004B4BBC /* GitRepositoryTest.swift in Sources */, + DC6474E12D45B23A004B4C21 /* GitTransportTest.swift in Sources */, A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */, 30FD2F78214D9E0E005E0A92 /* ParserTest.swift in Sources */, A2AA934622DE3A8000D79A00 /* PGPAgentTest.swift in Sources */, @@ -2971,14 +3003,6 @@ minimumVersion = 1.2.0; }; }; - 30ED1775276F8842009BA876 /* XCRemoteSwiftPackageReference "objective-git-swift-package" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/SimplyDanny/objective-git-swift-package"; - requirement = { - branch = "0.18-passforios"; - kind = branch; - }; - }; 9A1F47F826E5CF4B000C0E01 /* XCRemoteSwiftPackageReference "OneTimePassword" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mssun/OneTimePassword"; @@ -3071,11 +3095,6 @@ package = 30A3000C26DA62F4002A734E /* XCRemoteSwiftPackageReference "Base32" */; productName = Base32; }; - 30ED1776276F8842009BA876 /* ObjectiveGit */ = { - isa = XCSwiftPackageProductDependency; - package = 30ED1775276F8842009BA876 /* XCRemoteSwiftPackageReference "objective-git-swift-package" */; - productName = ObjectiveGit; - }; 9A1D1CE426E5D1CE0052028E /* OneTimePassword */ = { isa = XCSwiftPackageProductDependency; package = 9A1F47F826E5CF4B000C0E01 /* XCRemoteSwiftPackageReference "OneTimePassword" */; diff --git a/pass.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/pass.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 92998604..6b2bfba1 100644 --- a/pass.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/pass.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -28,15 +28,6 @@ "version" : "4.2.2" } }, - { - "identity" : "objective-git-swift-package", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SimplyDanny/objective-git-swift-package", - "state" : { - "branch" : "0.18-passforios", - "revision" : "752ea5689a1c0c0f9a8442df039e842a452cfcf0" - } - }, { "identity" : "objectivepgp", "kind" : "remoteSourceControl", diff --git a/pass/Controllers/AdvancedSettingsTableViewController.swift b/pass/Controllers/AdvancedSettingsTableViewController.swift index 246c6160..89d532a0 100644 --- a/pass/Controllers/AdvancedSettingsTableViewController.swift +++ b/pass/Controllers/AdvancedSettingsTableViewController.swift @@ -36,8 +36,8 @@ class AdvancedSettingsTableViewController: UITableViewController { } private func setGitSignatureText() { - let gitSignatureName = passwordStore.gitSignatureForNow?.name ?? "" - let gitSignatureEmail = passwordStore.gitSignatureForNow?.email ?? "" + let gitSignatureName = passwordStore.gitSignatureForNow.name + let gitSignatureEmail = passwordStore.gitSignatureForNow.email gitSignatureTableViewCell.detailTextLabel?.font = UIFont.preferredFont(forTextStyle: .footnote) gitSignatureTableViewCell.detailTextLabel?.text = "\(gitSignatureName) <\(gitSignatureEmail)>" if Defaults.gitSignatureName == nil, Defaults.gitSignatureEmail == nil { diff --git a/pass/Controllers/CommitLogsTableViewController.swift b/pass/Controllers/CommitLogsTableViewController.swift index f68bb120..57f985ad 100644 --- a/pass/Controllers/CommitLogsTableViewController.swift +++ b/pass/Controllers/CommitLogsTableViewController.swift @@ -6,12 +6,11 @@ // Copyright © 2017 Bob Sun. All rights reserved. // -import ObjectiveGit import passKit import UIKit class CommitLogsTableViewController: UITableViewController { - var commits: [GTCommit] = [] + var commits: [GitCommit] = [] let passwordStore = PasswordStore.shared override func viewDidLoad() { @@ -31,7 +30,7 @@ class CommitLogsTableViewController: UITableViewController { let formatter = DateFormatter() formatter.dateStyle = DateFormatter.Style.medium formatter.timeStyle = .medium - let dateString = formatter.string(from: commits[indexPath.row].commitDate) + let dateString = formatter.string(from: commits[indexPath.row].date) let author = cell.contentView.viewWithTag(200) as? UILabel let dateLabel = cell.contentView.viewWithTag(201) as? UILabel @@ -48,7 +47,7 @@ class CommitLogsTableViewController: UITableViewController { tableView.reloadData() } - private func getCommitLogs() -> [GTCommit] { + private func getCommitLogs() -> [GitCommit] { do { return try passwordStore.getRecentCommits(count: 20) } catch { diff --git a/pass/Controllers/GitConfigSettingsTableViewController.swift b/pass/Controllers/GitConfigSettingsTableViewController.swift index 9a490815..2399f0b4 100644 --- a/pass/Controllers/GitConfigSettingsTableViewController.swift +++ b/pass/Controllers/GitConfigSettingsTableViewController.swift @@ -20,8 +20,8 @@ class GitConfigSettingsTableViewController: UITableViewController { tableView.rowHeight = UITableView.automaticDimension let signature = passwordStore.gitSignatureForNow - nameTextField.placeholder = signature?.name ?? "" - emailTextField.placeholder = signature?.email ?? "" + nameTextField.placeholder = signature.name + emailTextField.placeholder = signature.email nameTextField.text = Defaults.gitSignatureName emailTextField.text = Defaults.gitSignatureEmail } @@ -29,8 +29,8 @@ class GitConfigSettingsTableViewController: UITableViewController { override func shouldPerformSegue(withIdentifier identifier: String, sender _: Any?) -> Bool { if identifier == "saveGitConfigSettingSegue" { let name = nameTextField.text!.isEmpty ? Globals.gitSignatureDefaultName : nameTextField.text! - let email = emailTextField.text!.isEmpty ? Globals.gitSignatureDefaultEmail : nameTextField.text! - guard GTSignature(name: name, email: email, time: nil) != nil else { + let email = emailTextField.text!.isEmpty ? Globals.gitSignatureDefaultEmail : emailTextField.text! + guard GitSignature(name: name, email: email).isValid else { Utils.alert(title: "Error".localize(), message: "InvalidNameOrEmail".localize(), controller: self, completion: nil) return false } diff --git a/pass/Controllers/GitRepositorySettingsTableViewController.swift b/pass/Controllers/GitRepositorySettingsTableViewController.swift index 25411165..89bc36ba 100644 --- a/pass/Controllers/GitRepositorySettingsTableViewController.swift +++ b/pass/Controllers/GitRepositorySettingsTableViewController.swift @@ -175,15 +175,12 @@ class GitRepositorySettingsTableViewController: UITableViewController, PasswordA // swiftlint:disable:next closure_body_length DispatchQueue.global(qos: .userInitiated).async { do { - let transferProgressBlock: (UnsafePointer, UnsafeMutablePointer) -> Void = { git_transfer_progress, _ in - let gitTransferProgress = git_transfer_progress.pointee - let progress = Float(gitTransferProgress.received_objects) / Float(gitTransferProgress.total_objects) - SVProgressHUD.showProgress(progress, status: "Cloning Remote Repository") + let transferProgressBlock: TransferProgressHandler = { progress, _ in + SVProgressHUD.showProgress(progress.fractionCompleted, status: "Cloning Remote Repository") } - let checkoutProgressBlock: (String, UInt, UInt) -> Void = { _, completedSteps, totalSteps in - let progress = Float(completedSteps) / Float(totalSteps) - SVProgressHUD.showProgress(progress, status: "CheckingOutBranch".localize(self.gitBranchName)) + let checkoutProgressBlock: CheckoutProgressHandler = { progress in + SVProgressHUD.showProgress(progress.fractionCompleted, status: "CheckingOutBranch".localize(self.gitBranchName)) } let options = self.gitCredential.getCredentialOptions(passwordProvider: self.present) diff --git a/pass/Controllers/OpenSourceComponentsTableViewController.swift b/pass/Controllers/OpenSourceComponentsTableViewController.swift index 572e1a94..ace73873 100644 --- a/pass/Controllers/OpenSourceComponentsTableViewController.swift +++ b/pass/Controllers/OpenSourceComponentsTableViewController.swift @@ -27,9 +27,14 @@ class OpenSourceComponentsTableViewController: BasicStaticTableViewController { "https://github.com/kishikawakatsumi/KeychainAccess/blob/master/LICENSE", ], [ - "ObjectiveGit", - "https://github.com/libgit2/objective-git", - "https://github.com/libgit2/objective-git/blob/master/LICENSE", + "libgit2", + "https://libgit2.org", + "https://github.com/libgit2/libgit2/blob/main/COPYING", + ], + [ + "libssh2", + "https://libssh2.org", + "https://github.com/libssh2/libssh2/blob/master/COPYING", ], [ "ObjectivePGP", @@ -41,6 +46,11 @@ class OpenSourceComponentsTableViewController: BasicStaticTableViewController { "https://github.com/mattrubin/OneTimePassword", "https://github.com/mattrubin/OneTimePassword/blob/develop/LICENSE.md", ], + [ + "OpenSSL", + "https://www.openssl.org", + "https://github.com/openssl/openssl/blob/master/LICENSE.txt", + ], [ "SVProgressHUD", "https://github.com/SVProgressHUD/SVProgressHUD", diff --git a/pass/Controllers/PasswordNavigationViewController.swift b/pass/Controllers/PasswordNavigationViewController.swift index addc46cc..c3815770 100644 --- a/pass/Controllers/PasswordNavigationViewController.swift +++ b/pass/Controllers/PasswordNavigationViewController.swift @@ -510,16 +510,16 @@ extension PasswordNavigationViewController: PasswordAlertPresenter { DispatchQueue.global(qos: .userInitiated).async { [unowned self] in do { let pullOptions = gitCredential.getCredentialOptions(passwordProvider: present) - try PasswordStore.shared.pullRepository(options: pullOptions) { git_transfer_progress, _ in + try PasswordStore.shared.pullRepository(options: pullOptions) { progress, _ in DispatchQueue.main.async { - SVProgressHUD.showProgress(Float(git_transfer_progress.pointee.received_objects) / Float(git_transfer_progress.pointee.total_objects), status: "PullingFromRemoteRepository".localize()) + SVProgressHUD.showProgress(progress.fractionCompleted, status: "PullingFromRemoteRepository".localize()) } } if PasswordStore.shared.numberOfLocalCommits > 0 { let pushOptions = gitCredential.getCredentialOptions(passwordProvider: present) - try PasswordStore.shared.pushRepository(options: pushOptions) { current, total, _, _ in + try PasswordStore.shared.pushRepository(options: pushOptions) { progress, _ in DispatchQueue.main.async { - SVProgressHUD.showProgress(Float(current) / Float(total), status: "PushingToRemoteRepository".localize()) + SVProgressHUD.showProgress(progress.fractionCompleted, status: "PushingToRemoteRepository".localize()) } } } @@ -528,20 +528,20 @@ extension PasswordNavigationViewController: PasswordAlertPresenter { SVProgressHUD.showSuccess(withStatus: "Done".localize()) SVProgressHUD.dismiss(withDelay: 1) } - } catch let error as NSError { - gitCredential.delete() + } catch { + // Only forget the stored password when it might be the reason for + // the failure. A refused push or a conflicting merge happens long + // after the remote has accepted the credential. + if error.mightBeAuthenticationFailure { + gitCredential.delete() + } DispatchQueue.main.async { SVProgressHUD.dismiss() + // libgit2 reports the message of the underlying library, so a + // wrong SSH passphrase is recognised by what libssh2 wrote. var message = error.localizedDescription - if let underlyingError = error.userInfo[NSUnderlyingErrorKey] as? NSError { - message = message | "UnderlyingError".localize(underlyingError.localizedDescription) - if underlyingError.localizedDescription.contains("WrongPassphrase".localize()) { - message = message | "RecoverySuggestion.".localize() - } - } - if let mergeConflictFiles = error.userInfo[GTPullMergeConflictedFiles] as? NSArray { - let mergeConflictFilesString = mergeConflictFiles.componentsJoined(by: ", ") - message = message | "MergeConflictError".localize(mergeConflictFilesString) + if message.contains("WrongPassphrase".localize()) { + message = message | "RecoverySuggestion.".localize() } DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(800)) { Utils.alert(title: "Error".localize(), message: message, controller: self, completion: nil) diff --git a/pass/Helpers/Objective-CBridgingHeader.h b/pass/Helpers/Objective-CBridgingHeader.h index ff807e53..3be2e1d6 100644 --- a/pass/Helpers/Objective-CBridgingHeader.h +++ b/pass/Helpers/Objective-CBridgingHeader.h @@ -9,6 +9,4 @@ #ifndef Objective_CBridgingHeader_h #define Objective_CBridgingHeader_h -@import ObjectiveGit; - #endif /* Objective_CBridgingHeader_h */ diff --git a/pass/de.lproj/Localizable.strings b/pass/de.lproj/Localizable.strings index 23f60964..47f6057a 100644 --- a/pass/de.lproj/Localizable.strings +++ b/pass/de.lproj/Localizable.strings @@ -108,6 +108,8 @@ "SpecifyBranchName." = "Der Name des zu verwendenden Branches muss angegeben werden."; // SSH +"AuthenticationRequired." = "Das entfernte Repository erfordert eine Authentifizierung."; +"AuthenticationCancelled." = "Die Authentifizierung wurde abgebrochen."; "FillInSshKeyPassphrase." = "Bitte gib das Passwort des SSH-Schlüssels ein."; "CannotSelectSshKey" = "SSH-Schlüssel kann nicht selektiert werden"; "PleaseSetupSshKeyFirst." = "Bitte richte erst den SSH-Schlüssel ein."; diff --git a/pass/en.lproj/Localizable.strings b/pass/en.lproj/Localizable.strings index caed4645..ad4df9aa 100644 --- a/pass/en.lproj/Localizable.strings +++ b/pass/en.lproj/Localizable.strings @@ -109,6 +109,8 @@ "SpecifyBranchName." = "Please specify the name of the branch to be used."; // SSH +"AuthenticationRequired." = "The remote repository requires authentication."; +"AuthenticationCancelled." = "Authentication was cancelled."; "FillInSshKeyPassphrase." = "Please fill in the passphrase of your SSH key."; "CannotSelectSshKey" = "Cannot Select SSH Key"; "PleaseSetupSshKeyFirst." = "Please setup SSH key first."; diff --git a/pass/it.lproj/Localizable.strings b/pass/it.lproj/Localizable.strings index d9a2492b..8bb2fb51 100644 --- a/pass/it.lproj/Localizable.strings +++ b/pass/it.lproj/Localizable.strings @@ -107,6 +107,8 @@ "SpecifyBranchName." = "Specificare il nome del branch da usare."; // SSH +"AuthenticationRequired." = "Il repository remoto richiede l'autenticazione."; +"AuthenticationCancelled." = "Autenticazione annullata."; "FillInSshKeyPassphrase." = "Inserire la password della chiave SSH."; "CannotSelectSshKey" = "Impossibile selezionare la chiave SSH"; "PleaseSetupSshKeyFirst." = "Impostare la chiave SSH."; diff --git a/passKit/Models/GitCredential.swift b/passKit/Models/GitCredential.swift index 9534d187..8819d3b7 100644 --- a/passKit/Models/GitCredential.swift +++ b/passKit/Models/GitCredential.swift @@ -6,8 +6,51 @@ // Copyright © 2017 Bob Sun. All rights reserved. // -import ObjectiveGit -import SVProgressHUD +import Foundation + +/// What to authenticate with, described independently of the git backend. +public enum GitCredentialSpec { + case userPassPlaintext(userName: String, password: String) + case sshKeyMemory(userName: String, publicKey: String?, privateKey: String, passphrase: String) +} + +/// Answers the credential requests of a single remote operation. libgit2 asks +/// repeatedly until it is authenticated or the provider gives up, which is what +/// makes retrying with a re-entered password possible. +public final class GitCredentialProvider { + public let userName: String + private let provideCredential: () -> GitCredentialSpec? + + init(userName: String, provideCredential: @escaping () -> GitCredentialSpec?) { + self.userName = userName + self.provideCredential = provideCredential + } + + /// The credential for the next attempt, or `nil` to stop trying. + public func nextCredential() -> GitCredentialSpec? { + provideCredential() + } +} + +/// Credentials handed to a remote operation. +public struct GitCredentialOptions { + let credentialProvider: GitCredentialProvider? + + #if DEBUG + /// A certificate to accept besides those the system trusts. Only the + /// transport tests set it, and it is compiled out of a release build. + var pinnedCertificate: Data? + #endif + + /// Options without any credentials, for remotes that do not require authentication. + public init() { + self.credentialProvider = nil + } + + init(credentialProvider: GitCredentialProvider) { + self.credentialProvider = credentialProvider + } +} public struct GitCredential { public typealias PasswordProvider = (String, String?) -> String? @@ -19,6 +62,13 @@ public struct GitCredential { case http(userName: String) case ssh(userName: String, privateKey: String) + var userName: String { + switch self { + case let .http(userName), let .ssh(userName, _): + return userName + } + } + var requestPassphraseMessage: String { switch self { case .http: @@ -46,12 +96,12 @@ public struct GitCredential { } } - func createGTCredential(password: String) throws -> GTCredential { + func createCredential(password: String) -> GitCredentialSpec { switch self { case let .http(userName): - return try GTCredential(userName: userName, password: password) + return .userPassPlaintext(userName: userName, password: password) case let .ssh(userName, privateKey): - return try GTCredential(userName: userName, publicKeyString: nil, privateKeyString: privateKey, passphrase: password) + return .sshKeyMemory(userName: userName, publicKey: nil, privateKey: privateKey, passphrase: password) } } } @@ -66,17 +116,13 @@ public struct GitCredential { } } - public func getCredentialOptions(passwordProvider: @escaping PasswordProvider = { _, _ in nil }) -> [String: Any] { - let credentialProvider = createCredentialProvider(passwordProvider) - return [ - GTRepositoryCloneOptionsCredentialProvider: credentialProvider, - GTRepositoryRemoteOptionsCredentialProvider: credentialProvider, - ] + public func getCredentialOptions(passwordProvider: @escaping PasswordProvider = { _, _ in nil }) -> GitCredentialOptions { + GitCredentialOptions(credentialProvider: createCredentialProvider(passwordProvider)) } - private func createCredentialProvider(_ passwordProvider: @escaping PasswordProvider) -> GTCredentialProvider { + func createCredentialProvider(_ passwordProvider: @escaping PasswordProvider) -> GitCredentialProvider { var attempts = 1 - return GTCredentialProvider { _, _, _ -> GTCredential? in + return GitCredentialProvider(userName: credentialType.userName) { if attempts > credentialType.allowedAttempts { return nil } @@ -84,7 +130,7 @@ public struct GitCredential { return nil } attempts += 1 - return try? credentialType.createGTCredential(password: password) + return credentialType.createCredential(password: password) } } diff --git a/passKit/Models/GitRepository.swift b/passKit/Models/GitRepository.swift index c34fbecd..b32455d8 100644 --- a/passKit/Models/GitRepository.swift +++ b/passKit/Models/GitRepository.swift @@ -5,175 +5,634 @@ // Created by Mingshen Sun on 1/25/25. // Copyright © 2025 Bob Sun. All rights reserved. // -import ObjectiveGit -public typealias TransferProgressHandler = (UnsafePointer, UnsafeMutablePointer) -> Void -public typealias CheckoutProgressHandler = (String, UInt, UInt) -> Void -public typealias PushProgressHandler = (UInt32, UInt32, Int, UnsafeMutablePointer) -> Void -public typealias CloneOptions = [AnyHashable: Any] -public typealias PullOptions = [AnyHashable: Any] -public typealias PushOptions = [String: Any] +import Foundation +import Libgit2 + +// See Libgit2.swift for how the C API is reached. Everything crossing the +// public boundary of this class is a value type from GitTypes.swift. public class GitRepository { - let repository: GTRepository + private let repository: OpaquePointer var branchName: String = "master" + deinit { + git_repository_free(repository) + } + public init(with localDir: URL) throws { guard FileManager.default.fileExists(atPath: localDir.path) else { throw AppError.repositoryNotSet } - try self.repository = GTRepository(url: localDir) - if let currentBranchName = try? repository.currentBranch().name { + initializeLibgit2() + var repository: OpaquePointer? + try gitTry(git_repository_open(&repository, localDir.path)) + guard let repository else { + throw AppError.repositoryNotSet + } + self.repository = repository + if let currentBranchName = try? Self.currentBranchName(in: repository) { self.branchName = currentBranchName } } - public init(from remoteURL: URL, to workingDir: URL, branchName: String, options: CloneOptions, transferProgressBlock: @escaping TransferProgressHandler, checkoutProgressBlock: @escaping CheckoutProgressHandler) throws { - self.repository = try GTRepository.clone( - from: remoteURL, - toWorkingDirectory: workingDir, - options: options, - transferProgressBlock: transferProgressBlock + public init( + from remoteURL: URL, + to workingDir: URL, + branchName: String, + options: GitCredentialOptions = GitCredentialOptions(), + transferProgressBlock: @escaping TransferProgressHandler, + checkoutProgressBlock: @escaping CheckoutProgressHandler + ) throws { + initializeLibgit2() + let context = GitCallbackContext( + credentialProvider: options.credentialProvider, + transferProgress: transferProgressBlock ) + context.applyPin(from: options) + var cloneOptions = git_clone_options() + try gitTry(git_clone_options_init(&cloneOptions, UInt32(GIT_CLONE_OPTIONS_VERSION))) + cloneOptions.fetch_opts = try gitFetchOptions(context: context) + cloneOptions.checkout_opts = try gitCheckoutOptions(strategy: GIT_CHECKOUT_SAFE) + + var repository: OpaquePointer? + try withExtendedLifetime(context) { + try gitTry(git_clone(&repository, remoteURL.absoluteString, workingDir.path, &cloneOptions)) + } + guard let repository else { + throw AppError.repositoryNotSet + } + self.repository = repository self.branchName = branchName - guard !repository.isHEADUnborn else { + + guard git_repository_head_unborn(repository) != 1 else { return } - if (try repository.currentBranch().name) != branchName { + if (try? Self.currentBranchName(in: repository)) != branchName { try checkoutAndChangeBranch(branchName: branchName, progressBlock: checkoutProgressBlock) } } + private static func currentBranchName(in repository: OpaquePointer) throws -> String { + var head: OpaquePointer? + try gitTry(git_repository_head(&head, repository)) + defer { git_reference_free(head) } + guard let name = gitString(git_reference_shorthand(head)) else { + throw AppError.repositoryNotSet + } + return name + } + public func checkoutAndChangeBranch(branchName: String, progressBlock: @escaping CheckoutProgressHandler) throws { + let context = GitCallbackContext(checkoutProgress: progressBlock) + + var localBranch: OpaquePointer? + if git_branch_lookup(&localBranch, repository, branchName, GIT_BRANCH_LOCAL) != 0 { + localBranch = nil + try createLocalBranch(named: branchName, into: &localBranch) + } + guard let localBranch else { + throw AppError.repositoryBranchNotFound(branchName: branchName) + } + defer { git_reference_free(localBranch) } + + try checkout(reference: localBranch, strategy: GIT_CHECKOUT_FORCE, context: context) + try gitTry(git_repository_set_head(repository, git_reference_name(localBranch))) + // Only once the branch is known to exist and to be checked out, so that + // a failure does not leave the repository pointing at a missing branch. self.branchName = branchName - if let localBranch = try? repository.lookUpBranch(withName: branchName, type: .local, success: nil) { - let checkoutOptions = GTCheckoutOptions(strategy: .force, progressBlock: progressBlock) - try repository.checkoutReference(localBranch.reference, options: checkoutOptions) - try repository.moveHEAD(to: localBranch.reference) - } else { - let remoteBranchName = "origin/\(branchName)" - let remoteBranch = try repository.lookUpBranch(withName: remoteBranchName, type: .remote, success: nil) - guard let remoteBranchOid = remoteBranch.oid else { - throw AppError.repositoryRemoteBranchNotFound(branchName: remoteBranchName) - } - let localBranch = try repository.createBranchNamed(branchName, from: remoteBranchOid, message: nil) - try localBranch.updateTrackingBranch(remoteBranch) - let checkoutOptions = GTCheckoutOptions(strategy: .force, progressBlock: progressBlock) - try repository.checkoutReference(localBranch.reference, options: checkoutOptions) - try repository.moveHEAD(to: localBranch.reference) + } + + /// Branches off the matching remote branch and tracks it. + private func createLocalBranch(named branchName: String, into localBranch: inout OpaquePointer?) throws { + let remoteBranchName = "origin/\(branchName)" + var remoteBranch: OpaquePointer? + guard git_branch_lookup(&remoteBranch, repository, remoteBranchName, GIT_BRANCH_REMOTE) == 0, + let remoteBranch, + let remoteTarget = git_reference_target(remoteBranch) else { + git_reference_free(remoteBranch) + throw AppError.repositoryRemoteBranchNotFound(branchName: remoteBranchName) + } + defer { git_reference_free(remoteBranch) } + + var remoteCommit: OpaquePointer? + try gitTry(git_commit_lookup(&remoteCommit, repository, remoteTarget)) + defer { git_commit_free(remoteCommit) } + + try gitTry(git_branch_create(&localBranch, repository, branchName, remoteCommit, 0)) + try gitTry(git_branch_set_upstream(localBranch, remoteBranchName)) + } + + private func checkout(reference: OpaquePointer, strategy: git_checkout_strategy_t, context: GitCallbackContext?) throws { + var target: OpaquePointer? + try gitTry(git_reference_peel(&target, reference, GIT_OBJECT_COMMIT)) + defer { git_object_free(target) } + var options = try gitCheckoutOptions(strategy: strategy, context: context) + try withExtendedLifetime(context) { + try gitTry(git_checkout_tree(repository, target, &options)) } } + // MARK: - Remote operations + public func pull( - options: PullOptions, + options: GitCredentialOptions, + signature: GitSignature? = nil, transferProgressBlock: @escaping TransferProgressHandler ) throws { - let remote = try GTRemote(name: "origin", in: repository) - try repository.pull(repository.currentBranch(), from: remote, withOptions: options, progress: transferProgressBlock) + let context = GitCallbackContext( + credentialProvider: options.credentialProvider, + transferProgress: transferProgressBlock + ) + context.applyPin(from: options) + var remote: OpaquePointer? + try gitTry(git_remote_lookup(&remote, repository, "origin")) + defer { git_remote_free(remote) } + + var fetchOptions = try gitFetchOptions(context: context) + try withExtendedLifetime(context) { + try gitTry(git_remote_fetch(remote, nil, &fetchOptions, nil)) + } + + try mergeUpstream(signature: signature) + } + + /// libgit2 has no pull, so the fetched upstream is merged by hand: nothing to + /// do when already up to date, a reference update when the merge is a + /// fast-forward, and a real merge commit otherwise. + private func mergeUpstream(signature: GitSignature?) throws { + var head: OpaquePointer? + try gitTry(git_repository_head(&head, repository)) + defer { git_reference_free(head) } + + var upstream: OpaquePointer? + try gitTry(git_branch_upstream(&upstream, head)) + defer { git_reference_free(upstream) } + + var annotatedCommit: OpaquePointer? + try gitTry(git_annotated_commit_from_ref(&annotatedCommit, repository, upstream)) + defer { git_annotated_commit_free(annotatedCommit) } + + var analysis = git_merge_analysis_t(0) + var preference = git_merge_preference_t(0) + var heads: [OpaquePointer?] = [annotatedCommit] + try heads.withUnsafeMutableBufferPointer { buffer in + try gitTry(git_merge_analysis(&analysis, &preference, repository, buffer.baseAddress, 1)) + } + + if analysis.rawValue & GIT_MERGE_ANALYSIS_UP_TO_DATE.rawValue != 0 { + return + } + if analysis.rawValue & GIT_MERGE_ANALYSIS_FASTFORWARD.rawValue != 0 { + try fastForward(head: head!, to: annotatedCommit!) + return + } + try merge(heads: &heads, upstream: upstream!, signature: signature) + } + + private func fastForward(head: OpaquePointer, to annotatedCommit: OpaquePointer) throws { + guard let targetOid = git_annotated_commit_id(annotatedCommit) else { + throw AppError.gitCommit + } + var target: OpaquePointer? + try gitTry(git_object_lookup(&target, repository, targetOid, GIT_OBJECT_COMMIT)) + defer { git_object_free(target) } + + var options = try gitCheckoutOptions(strategy: GIT_CHECKOUT_SAFE) + try gitTry(git_checkout_tree(repository, target, &options)) + + var updatedHead: OpaquePointer? + try gitTry(git_reference_set_target(&updatedHead, head, targetOid, "pull: Fast-forward")) + git_reference_free(updatedHead) + } + + private func merge(heads: inout [OpaquePointer?], upstream: OpaquePointer, signature: GitSignature?) throws { + var mergeOptions = git_merge_options() + try gitTry(git_merge_options_init(&mergeOptions, UInt32(GIT_MERGE_OPTIONS_VERSION))) + var checkoutOptions = try gitCheckoutOptions(strategy: GIT_CHECKOUT_SAFE) + try heads.withUnsafeMutableBufferPointer { buffer in + try gitTry(git_merge(repository, buffer.baseAddress, 1, &mergeOptions, &checkoutOptions)) + } + + // A merge that is not carried through to a commit leaves conflicts in the + // index and a half-merged work tree, which makes every later commit fail + // in git_index_write_tree. Undo it unless the commit is created. + var committed = false + defer { + if !committed { + abortMerge() + } + git_repository_state_cleanup(repository) + } + + var index: OpaquePointer? + try gitTry(git_repository_index(&index, repository)) + defer { git_index_free(index) } + + if git_index_has_conflicts(index) != 0 { + throw GitMergeConflictError(paths: conflictedPaths(in: index)) + } + + var upstreamCommit: OpaquePointer? + try gitTry(git_reference_peel(&upstreamCommit, upstream, GIT_OBJECT_COMMIT)) + defer { git_commit_free(upstreamCommit) } + + let mergeSignature = try signature ?? defaultSignature() + _ = try createCommit( + message: "Merge branch '\(branchName)' of origin", + signature: mergeSignature, + additionalParents: [upstreamCommit] + ) + committed = true } - public func getRecentCommits(count: Int) throws -> [GTCommit] { - var commits = [GTCommit]() - let enumerator = try GTEnumerator(repository: repository) - if let targetOID = try repository.headReference().targetOID { - try enumerator.pushSHA(targetOID.sha) + /// Returns the index and the work tree to HEAD, discarding a merge that was + /// started but not committed. + private func abortMerge() { + var head: OpaquePointer? + guard git_repository_head(&head, repository) == 0 else { + return } - for _ in 0 ..< count { - if let commit = try? enumerator.nextObject(withSuccess: nil) { - commits.append(commit) + defer { git_reference_free(head) } + + var headCommit: OpaquePointer? + guard git_reference_peel(&headCommit, head, GIT_OBJECT_COMMIT) == 0 else { + return + } + defer { git_commit_free(headCommit) } + + guard var options = try? gitCheckoutOptions(strategy: GIT_CHECKOUT_FORCE) else { + return + } + git_reset(repository, headCommit, GIT_RESET_HARD, &options) + } + + private func conflictedPaths(in index: OpaquePointer?) -> [String] { + var iterator: OpaquePointer? + guard git_index_conflict_iterator_new(&iterator, index) == 0 else { + return [] + } + defer { git_index_conflict_iterator_free(iterator) } + + var paths: [String] = [] + while true { + var ancestor: UnsafePointer? + var our: UnsafePointer? + var their: UnsafePointer? + guard git_index_conflict_next(&ancestor, &our, &their, iterator) == 0 else { + break + } + let entry = our ?? their ?? ancestor + if let path = gitString(entry?.pointee.path) { + paths.append(path) } } - return commits + return paths + } + + public func push( + options: GitCredentialOptions, + transferProgressBlock: @escaping PushProgressHandler + ) throws { + let context = GitCallbackContext( + credentialProvider: options.credentialProvider, + pushProgress: transferProgressBlock + ) + context.applyPin(from: options) + var remote: OpaquePointer? + try gitTry(git_remote_lookup(&remote, repository, "origin")) + defer { git_remote_free(remote) } + + var pushOptions = try gitPushOptions(context: context) + let currentBranch = try Self.currentBranchName(in: repository) + let refspec = "refs/heads/\(currentBranch):refs/heads/\(currentBranch)" + + try withExtendedLifetime(context) { + try refspec.withCString { refspec in + var refspecs: [UnsafeMutablePointer?] = [UnsafeMutablePointer(mutating: refspec)] + try refspecs.withUnsafeMutableBufferPointer { buffer in + var array = git_strarray(strings: buffer.baseAddress, count: 1) + try gitTry(git_remote_push(remote, &array, &pushOptions)) + } + } + } + + guard context.rejectedReferences.isEmpty else { + throw AppError.gitPushNotSuccessful + } + } + + // MARK: - Working tree + + private var workingDirectory: URL? { + gitString(git_repository_workdir(repository)).map { URL(fileURLWithPath: $0) } } public func add(path: String) throws { - try repository.index().addFile(path) - try repository.index().write() + var index: OpaquePointer? + try gitTry(git_repository_index(&index, repository)) + defer { git_index_free(index) } + try gitTry(git_index_add_bypath(index, path)) + try gitTry(git_index_write(index)) } public func rm(path: String) throws { - guard let repoURL = repository.fileURL else { + guard let workingDirectory else { throw AppError.repositoryNotSet } - - let url = repoURL.appendingPathComponent(path) + let url = workingDirectory.appendingPathComponent(path) if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) } - try repository.index().removeFile(path) - try repository.index().write() + var index: OpaquePointer? + try gitTry(git_repository_index(&index, repository)) + defer { git_index_free(index) } + try gitTry(git_index_remove_bypath(index, path)) + try gitTry(git_index_write(index)) } public func mv(from: String, to: String) throws { - guard let repoURL = repository.fileURL else { + guard let workingDirectory else { throw AppError.repositoryNotSet } + try FileManager.default.moveItem( + at: workingDirectory.appendingPathComponent(from), + to: workingDirectory.appendingPathComponent(to) + ) + // Both sides of the rename go through one index, which is written once. + // The file is already gone from its old path, so there is nothing on + // disk left to remove. + var index: OpaquePointer? + try gitTry(git_repository_index(&index, repository)) + defer { git_index_free(index) } + try gitTry(git_index_add_bypath(index, to)) + try gitTry(git_index_remove_bypath(index, from)) + try gitTry(git_index_write(index)) + } + + // MARK: - Commits + + public func commit(name: String, email: String, message: String) throws -> GitCommit { + try commit(signature: GitSignature(name: name, email: email), message: message) + } + + public func commit(signature: GitSignature, message: String) throws -> GitCommit { + try createCommit(message: message, signature: signature, additionalParents: []) + } + + private func createCommit(message: String, signature: GitSignature, additionalParents: [OpaquePointer?]) throws -> GitCommit { + var index: OpaquePointer? + try gitTry(git_repository_index(&index, repository)) + defer { git_index_free(index) } + + var treeOid = git_oid() + try gitTry(git_index_write_tree(&treeOid, index)) + var tree: OpaquePointer? + try gitTry(git_tree_lookup(&tree, repository, &treeOid)) + defer { git_tree_free(tree) } + + var head: OpaquePointer? + var headCommit: OpaquePointer? + defer { + git_reference_free(head) + git_commit_free(headCommit) + } + + var referenceName = "HEAD" + var parents: [OpaquePointer?] = [] + if git_repository_head_unborn(repository) != 1 { + try gitTry(git_repository_head(&head, repository)) + referenceName = gitString(git_reference_name(head)) ?? "HEAD" + try gitTry(git_reference_peel(&headCommit, head, GIT_OBJECT_COMMIT)) + parents.append(headCommit) + } + parents.append(contentsOf: additionalParents) - let fromURL = repoURL.appendingPathComponent(from) - let toURL = repoURL.appendingPathComponent(to) - try FileManager.default.moveItem(at: fromURL, to: toURL) - try add(path: to) - try rm(path: from) + var commitOid = git_oid() + try signature.withBackendSignature { signature in + try parents.withUnsafeMutableBufferPointer { parents in + try gitTry(git_commit_create( + &commitOid, + repository, + referenceName, + signature, + signature, + nil, + message, + tree, + parents.count, + parents.baseAddress + )) + } + } + + var commit: OpaquePointer? + try gitTry(git_commit_lookup(&commit, repository, &commitOid)) + defer { git_commit_free(commit) } + guard let commit else { + throw AppError.gitCommit + } + return GitCommit(commit) } - public func commit(name: String, email: String, message: String) throws -> GTCommit { - guard let signature = GTSignature(name: name, email: email, time: Date()) else { + private func defaultSignature() throws -> GitSignature { + var signature: UnsafeMutablePointer? + try gitTry(git_signature_default(&signature, repository)) + defer { git_signature_free(signature) } + guard let signature = GitSignature(signature) else { throw AppError.gitCreateSignature } - return try commit(signature: signature, message: message) + return signature + } + + public func getRecentCommits(count: Int) throws -> [GitCommit] { + try commits(of: try walkOids(limit: count)) } - public func commit(signature: GTSignature, message: String) throws -> GTCommit { - let newTree = try repository.index().writeTree() - if repository.isHEADUnborn { - return try repository.createCommit(with: newTree, message: message, author: signature, committer: signature, parents: nil, updatingReferenceNamed: "HEAD") + public func getLocalCommits() throws -> [GitCommit] { + try commits(of: try localCommitOids()) + } + + public func numberOfCommits() -> Int { + (try? countCommits(hiding: nil)) ?? 0 + } + + /// The number of commits ahead of the tracked remote branch. Counted rather + /// than collected: the callers only want the number, and this runs on the + /// main thread every time a screen appears. + public func numberOfLocalCommits() throws -> Int { + try countCommits(hiding: try remoteBranchTarget()) + } + + private func countCommits(hiding hidden: git_oid?) throws -> Int { + var walker: OpaquePointer? + try gitTry(git_revwalk_new(&walker, repository)) + defer { git_revwalk_free(walker) } + try gitTry(git_revwalk_push_head(walker)) + if var hidden { + try gitTry(git_revwalk_hide(walker, &hidden)) } - let headReference = try repository.headReference() - let commitEnum = try GTEnumerator(repository: repository) - try commitEnum.pushSHA(headReference.targetOID!.sha) - guard let parent = commitEnum.nextObject() as? GTCommit else { - throw AppError.gitCommit + + var count = 0 + var oid = git_oid() + while git_revwalk_next(&oid, walker) == 0 { + count += 1 } - return try repository.createCommit(with: newTree, message: message, author: signature, committer: signature, parents: [parent], updatingReferenceNamed: headReference.name) + return count } - public func push( - options: [String: Any], - transferProgressBlock: @escaping PushProgressHandler - ) throws { - let branch = try repository.currentBranch() - let remote = try GTRemote(name: "origin", in: repository) - try repository.push(branch, to: remote, withOptions: options, progress: transferProgressBlock) + /// Walks back from HEAD, optionally stopping after `limit` commits. + private func walkOids(limit: Int?) throws -> [git_oid] { + var walker: OpaquePointer? + try gitTry(git_revwalk_new(&walker, repository)) + defer { git_revwalk_free(walker) } + try gitTry(git_revwalk_push_head(walker)) + + var oids: [git_oid] = [] + var oid = git_oid() + while limit.map({ oids.count < $0 }) ?? true, git_revwalk_next(&oid, walker) == 0 { + oids.append(oid) + } + return oids } - public func getLocalCommits() throws -> [GTCommit] { + /// The commit the tracked remote branch points at, copied out by value so + /// that it stays valid once the reference is freed. + private func remoteBranchTarget() throws -> git_oid { let remoteBranchName = "origin/\(branchName)" - let remoteBranch = try repository.lookUpBranch(withName: remoteBranchName, type: .remote, success: nil) - return try repository.localCommitsRelative(toRemoteBranch: remoteBranch) + var remoteBranch: OpaquePointer? + guard git_branch_lookup(&remoteBranch, repository, remoteBranchName, GIT_BRANCH_REMOTE) == 0, + let remoteBranch else { + git_reference_free(remoteBranch) + throw AppError.repositoryRemoteBranchNotFound(branchName: remoteBranchName) + } + defer { git_reference_free(remoteBranch) } + + guard let target = git_reference_target(remoteBranch) else { + throw AppError.repositoryRemoteBranchNotFound(branchName: remoteBranchName) + } + return target.pointee } - public func numberOfCommits() -> Int { - Int(repository.numberOfCommits(inCurrentBranch: nil)) + /// Commits reachable from HEAD but not from the tracked remote branch, newest first. + private func localCommitOids() throws -> [git_oid] { + var remoteTarget = try remoteBranchTarget() + + var walker: OpaquePointer? + try gitTry(git_revwalk_new(&walker, repository)) + defer { git_revwalk_free(walker) } + try gitTry(git_revwalk_push_head(walker)) + try gitTry(git_revwalk_hide(walker, &remoteTarget)) + + var oids: [git_oid] = [] + var oid = git_oid() + while git_revwalk_next(&oid, walker) == 0 { + oids.append(oid) + } + return oids } - public func reset() throws { - let localCommits = try getLocalCommits() - if localCommits.isEmpty { - return + private func commits(of oids: [git_oid]) throws -> [GitCommit] { + try oids.map { oid in + var oid = oid + var commit: OpaquePointer? + try gitTry(git_commit_lookup(&commit, repository, &oid)) + defer { git_commit_free(commit) } + guard let commit else { + throw AppError.gitCommit + } + return GitCommit(commit) } - guard let firstLocalCommit = localCommits.last, - firstLocalCommit.parents.count == 1, - let newHead = firstLocalCommit.parents.first else { + } + + /// Discards the commits that are not on the remote yet and returns how many + /// there were, so that the caller does not have to walk them itself. + @discardableResult + public func reset() throws -> Int { + let localCommits = try localCommitOids() + guard var oldestLocalCommitOid = localCommits.last else { + return 0 + } + var oldestLocalCommit: OpaquePointer? + try gitTry(git_commit_lookup(&oldestLocalCommit, repository, &oldestLocalCommitOid)) + defer { git_commit_free(oldestLocalCommit) } + + guard git_commit_parentcount(oldestLocalCommit) == 1 else { throw AppError.gitReset } - try repository.reset(to: newHead, resetType: .hard) + var newHead: OpaquePointer? + try gitTry(git_commit_parent(&newHead, oldestLocalCommit, 0)) + defer { git_commit_free(newHead) } + + var options = try gitCheckoutOptions(strategy: GIT_CHECKOUT_FORCE) + try gitTry(git_reset(repository, newHead, GIT_RESET_HARD, &options)) + return localCommits.count } + /// When the file at `path` last changed. + /// + /// Walks back from HEAD to the first commit whose content at that path + /// differs from its parent's, which is what `git log -1 -- ` reports. + /// Blame would answer the same question by reconstructing the authorship of + /// every line of the whole history, and this runs while a password is on + /// screen. public func lastCommitDate(path: String) throws -> Date { - let blameHunks = try repository.blame(withFile: path, options: nil).hunks - guard let latestCommitTime = blameHunks.map({ $0.finalSignature?.time?.timeIntervalSince1970 ?? 0 }).max() else { - return Date(timeIntervalSince1970: 0) + var walker: OpaquePointer? + try gitTry(git_revwalk_new(&walker, repository)) + defer { git_revwalk_free(walker) } + try gitTry(git_revwalk_push_head(walker)) + + var oid = git_oid() + while git_revwalk_next(&oid, walker) == 0 { + var commit: OpaquePointer? + guard git_commit_lookup(&commit, repository, &oid) == 0, let commit else { + continue + } + defer { git_commit_free(commit) } + + let blob = blobID(at: path, in: commit) + // A commit changed the path when no parent has the same content + // there, which covers additions, edits, and a root commit. + var changed = true + for index in 0 ..< git_commit_parentcount(commit) { + var parent: OpaquePointer? + guard git_commit_parent(&parent, commit, index) == 0, let parent else { + continue + } + defer { git_commit_free(parent) } + if sameObject(blobID(at: path, in: parent), blob) { + changed = false + break + } + } + if changed, blob != nil { + return Date(timeIntervalSince1970: TimeInterval(git_commit_time(commit))) + } + } + return Date(timeIntervalSince1970: 0) + } + + /// git_oid is a C struct, so it carries no equality of its own. + private func sameObject(_ lhs: git_oid?, _ rhs: git_oid?) -> Bool { + guard var lhs, var rhs else { + return lhs == nil && rhs == nil + } + return git_oid_cmp(&lhs, &rhs) == 0 + } + + /// The object the given commit holds at `path`, or nil when it holds nothing there. + private func blobID(at path: String, in commit: OpaquePointer) -> git_oid? { + var tree: OpaquePointer? + guard git_commit_tree(&tree, commit) == 0, let tree else { + return nil + } + defer { git_tree_free(tree) } + + var entry: OpaquePointer? + guard git_tree_entry_bypath(&entry, tree, path) == 0, let entry else { + return nil } - return Date(timeIntervalSince1970: latestCommitTime) + defer { git_tree_entry_free(entry) } + return git_tree_entry_id(entry)?.pointee } } diff --git a/passKit/Models/GitTypes.swift b/passKit/Models/GitTypes.swift new file mode 100644 index 00000000..77cc8ed1 --- /dev/null +++ b/passKit/Models/GitTypes.swift @@ -0,0 +1,104 @@ +// +// GitTypes.swift +// passKit +// +// Created by Mingshen Sun on 8/2/26. +// Copyright © 2026 Bob Sun. All rights reserved. +// + +import Foundation + +// Backend-agnostic value types forming the public surface of `GitRepository`. +// +// Nothing here may refer to a git backend (ObjectiveGit, libgit2, ...). Keeping +// this file free of such imports is what allows the backend to be replaced by +// rewriting `GitRepository.swift` and `GitCredential.swift` alone. + +/// Author or committer of a commit. +public struct GitSignature: Equatable { + public let name: String + public let email: String + public let time: Date + + public init(name: String, email: String, time: Date = Date()) { + self.name = name + self.email = email + self.time = time + } +} + +/// A single commit, detached from the repository it was read from. +public struct GitCommit: Equatable { + public let sha: String + public let message: String? + public let date: Date + public let author: GitSignature? + + public init(sha: String, message: String?, date: Date, author: GitSignature?) { + self.sha = sha + self.message = message + self.date = date + self.author = author + } +} + +/// Progress of a fetch or clone, reported while objects are received. +public struct GitTransferProgress: Equatable { + public let receivedObjects: UInt32 + public let indexedObjects: UInt32 + public let totalObjects: UInt32 + public let receivedBytes: Int + + public init(receivedObjects: UInt32, indexedObjects: UInt32, totalObjects: UInt32, receivedBytes: Int) { + self.receivedObjects = receivedObjects + self.indexedObjects = indexedObjects + self.totalObjects = totalObjects + self.receivedBytes = receivedBytes + } + + /// Fraction of objects received, or `0` while the total is still unknown. + public var fractionCompleted: Float { + totalObjects > 0 ? Float(receivedObjects) / Float(totalObjects) : 0 + } +} + +/// Progress of a push, reported while objects are sent. +public struct GitPushProgress: Equatable { + public let current: UInt32 + public let total: UInt32 + public let bytes: Int + + public init(current: UInt32, total: UInt32, bytes: Int) { + self.current = current + self.total = total + self.bytes = bytes + } + + /// Fraction of objects pushed, or `0` while the total is still unknown. + public var fractionCompleted: Float { + total > 0 ? Float(current) / Float(total) : 0 + } +} + +/// Progress of a checkout, reported per checked out path. +public struct GitCheckoutProgress: Equatable { + public let path: String? + public let completedSteps: UInt + public let totalSteps: UInt + + public init(path: String?, completedSteps: UInt, totalSteps: UInt) { + self.path = path + self.completedSteps = completedSteps + self.totalSteps = totalSteps + } + + /// Fraction of steps completed, or `0` while the total is still unknown. + public var fractionCompleted: Float { + totalSteps > 0 ? Float(completedSteps) / Float(totalSteps) : 0 + } +} + +/// Set `stop` to `true` to abort the running operation. +public typealias TransferProgressHandler = (GitTransferProgress, inout Bool) -> Void +public typealias PushProgressHandler = (GitPushProgress, inout Bool) -> Void +public typealias CheckoutProgressHandler = (GitCheckoutProgress) -> Void diff --git a/passKit/Models/Libgit2.swift b/passKit/Models/Libgit2.swift new file mode 100644 index 00000000..fa7e7c29 --- /dev/null +++ b/passKit/Models/Libgit2.swift @@ -0,0 +1,324 @@ +// +// Libgit2.swift +// passKit +// +// Created by Mingshen Sun on 8/2/26. +// Copyright © 2026 Bob Sun. All rights reserved. +// + +import Foundation + +// libgit2 is built by scripts/libgit2_build.sh into an xcframework holding it, +// libssh2 and the OpenSSL libcrypto libssh2 needs. +import Libgit2 + +// MARK: - Library lifecycle + +private let libgit2IsInitialized: Bool = { + git_libgit2_init() + return true +}() + +/// Initializes libgit2 once per process. Every entry point into the library +/// has to call this first. +func initializeLibgit2() { + _ = libgit2IsInitialized +} + +// MARK: - Errors + +/// An error reported by libgit2. The message is the one libgit2 produced, which +/// for network and authentication failures is the message of the underlying +/// library, for instance libssh2. +public struct Libgit2Error: LocalizedError { + public let code: Int32 + public let klass: Int32 + public let message: String + + public var errorDescription: String? { message } + + static func last(code: Int32) -> Self { + guard let error = git_error_last(), let message = gitString(error.pointee.message) else { + return Self(code: code, klass: 0, message: "Git error \(code)") + } + return Self(code: code, klass: error.pointee.klass, message: message) + } +} + +/// A merge that could not be completed automatically. +public struct GitMergeConflictError: LocalizedError { + public let paths: [String] + + public var errorDescription: String? { + "MergeConflictError".localize(paths.joined(separator: ", ")) + } +} + +public extension Error { + /// Whether the stored git credential could plausibly be at fault. Failures + /// that can only happen once the remote has already accepted it must not + /// cause it to be thrown away. + var mightBeAuthenticationFailure: Bool { + switch self { + case is GitMergeConflictError: + return false + case let error as AppError where error == .gitPushNotSuccessful: + return false + default: + return true + } + } +} + +/// Turns a libgit2 return code into a Swift error. Negative codes are failures, +/// everything else is passed through, since some functions report counts. +@discardableResult +func gitTry(_ result: Int32) throws -> Int32 { + guard result >= 0 else { + throw Libgit2Error.last(code: result) + } + return result +} + +// MARK: - Value conversion + +func gitString(_ pointer: UnsafePointer?) -> String? { + pointer.map { String(cString: $0) } +} + +func gitString(_ oid: UnsafePointer?) -> String { + guard let oid else { + return "" + } + var buffer = [CChar](repeating: 0, count: 41) + git_oid_tostr(&buffer, buffer.count, oid) + return String(cString: buffer) +} + +extension GitTransferProgress { + init(_ progress: git_indexer_progress) { + self.init( + receivedObjects: progress.received_objects, + indexedObjects: progress.indexed_objects, + totalObjects: progress.total_objects, + receivedBytes: progress.received_bytes + ) + } +} + +extension GitSignature { + init?(_ signature: UnsafePointer?) { + guard let signature, + let name = gitString(signature.pointee.name), + let email = gitString(signature.pointee.email) else { + return nil + } + self.init(name: name, email: email, time: Date(timeIntervalSince1970: TimeInterval(signature.pointee.when.time))) + } + + /// Creates the libgit2 signature and hands it to `body`, freeing it afterwards. + /// Throws if libgit2 rejects the name or email. + func withBackendSignature(_ body: (UnsafeMutablePointer) throws -> T) throws -> T { + var signature: UnsafeMutablePointer? + let offset = Int32(TimeZone.current.secondsFromGMT(for: time) / 60) + try gitTry(git_signature_new(&signature, name, email, git_time_t(time.timeIntervalSince1970), offset)) + guard let signature else { + throw AppError.gitCreateSignature + } + defer { git_signature_free(signature) } + return try body(signature) + } + + /// Whether libgit2 accepts this name and email. + public var isValid: Bool { + initializeLibgit2() + return (try? withBackendSignature { _ in }) != nil + } +} + +extension GitCommit { + init(_ commit: OpaquePointer) { + self.init( + sha: gitString(git_commit_id(commit)), + message: gitString(git_commit_message(commit)), + date: Date(timeIntervalSince1970: TimeInterval(git_commit_time(commit))), + author: GitSignature(git_commit_author(commit)) + ) + } +} + +// MARK: - Callbacks + +/// Carries the Swift handlers of one operation through the `void *payload` of +/// the libgit2 callbacks. Kept alive by the function running the operation. +final class GitCallbackContext { + let credentialProvider: GitCredentialProvider? + let transferProgress: TransferProgressHandler? + let checkoutProgress: CheckoutProgressHandler? + let pushProgress: PushProgressHandler? + + #if DEBUG + /// One certificate to accept besides those the system trusts, so that the + /// transport tests can talk to a server of their own. Compiled out of a + /// release build entirely. + var pinnedCertificate: Data? + #endif + + /// References the remote refused during a push, keyed by reference name. + private(set) var rejectedReferences: [String: String] = [:] + + init( + credentialProvider: GitCredentialProvider? = nil, + transferProgress: TransferProgressHandler? = nil, + checkoutProgress: CheckoutProgressHandler? = nil, + pushProgress: PushProgressHandler? = nil + ) { + self.credentialProvider = credentialProvider + self.transferProgress = transferProgress + self.checkoutProgress = checkoutProgress + self.pushProgress = pushProgress + } + + var payload: UnsafeMutableRawPointer { + Unmanaged.passUnretained(self).toOpaque() + } + + func reject(reference: String, reason: String) { + rejectedReferences[reference] = reason + } + + /// Compiles to nothing in a release build, where options carry no pin. + func applyPin(from options: GitCredentialOptions) { + #if DEBUG + pinnedCertificate = options.pinnedCertificate + #endif + } + + static func from(_ payload: UnsafeMutableRawPointer?) -> GitCallbackContext? { + payload.map { Unmanaged.fromOpaque($0).takeUnretainedValue() } + } +} + +let gitTransferProgressCallback: git_indexer_progress_cb = { stats, payload in + guard let stats, let handler = GitCallbackContext.from(payload)?.transferProgress else { + return 0 + } + var stop = false + handler(GitTransferProgress(stats.pointee), &stop) + return stop ? -1 : 0 +} + +let gitCheckoutProgressCallback: git_checkout_progress_cb = { path, completedSteps, totalSteps, payload in + guard let handler = GitCallbackContext.from(payload)?.checkoutProgress else { + return + } + handler(GitCheckoutProgress(path: gitString(path), completedSteps: UInt(completedSteps), totalSteps: UInt(totalSteps))) +} + +let gitPushProgressCallback: git_push_transfer_progress_cb = { current, total, bytes, payload in + guard let handler = GitCallbackContext.from(payload)?.pushProgress else { + return 0 + } + var stop = false + handler(GitPushProgress(current: current, total: total, bytes: bytes), &stop) + return stop ? -1 : 0 +} + +/// Records rejections instead of failing, so that all of them can be reported together. +let gitPushUpdateReferenceCallback: git_push_update_reference_cb = { refname, status, payload in + guard let context = GitCallbackContext.from(payload), let refname, let status else { + return 0 + } + context.reject(reference: String(cString: refname), reason: String(cString: status)) + return 0 +} + +/// libgit2 refuses an SSH host key that is not in a known_hosts file, and iOS +/// has none, so without this every SSH remote fails with "invalid or unknown +/// remote ssh hostkey". The wrapper this replaced never verified host keys +/// either, so accepting them keeps the behaviour the app has always had. +/// +/// TLS certificates are deliberately left alone: passing through means libgit2 +/// keeps the verdict it reached from the trust store of the system, so an +/// untrusted HTTPS remote is still refused. +let gitCertificateCheckCallback: git_transport_certificate_check_cb = { certificate, _, _, payload in + guard let certificate else { + return GIT_PASSTHROUGH.rawValue + } + if certificate.pointee.cert_type == GIT_CERT_HOSTKEY_LIBSSH2 { + return 0 + } + #if DEBUG + // Carried by the operation rather than held globally, so that one test + // cannot affect another and nothing is shared across threads. + if certificate.pointee.cert_type == GIT_CERT_X509, + let pinned = GitCallbackContext.from(payload)?.pinnedCertificate { + let presented = certificate.withMemoryRebound(to: git_cert_x509.self, capacity: 1) { + Data(bytes: $0.pointee.data, count: $0.pointee.len) + } + if presented == pinned { + return 0 + } + } + #endif + return GIT_PASSTHROUGH.rawValue +} + +/// libgit2 reports the message left in its error slot, which without this would +/// be whatever an earlier operation put there, or nothing at all. +private func failCredentials(_ message: String) -> Int32 { + git_error_set_str(Int32(GIT_ERROR_NET.rawValue), message) + return -1 +} + +let gitCredentialsCallback: git_credential_acquire_cb = { credential, _, _, allowedTypes, payload in + guard let credential, let provider = GitCallbackContext.from(payload)?.credentialProvider else { + return failCredentials("AuthenticationRequired.".localize()) + } + // Asked before the credential itself when the remote URL carries no user name. + if allowedTypes & GIT_CREDENTIAL_USERNAME.rawValue != 0 { + return git_credential_username_new(credential, provider.userName) + } + switch provider.nextCredential() { + case let .userPassPlaintext(userName, password): + return git_credential_userpass_plaintext_new(credential, userName, password) + case let .sshKeyMemory(userName, publicKey, privateKey, passphrase): + return git_credential_ssh_key_memory_new(credential, userName, publicKey, privateKey, passphrase) + case .none: + return failCredentials("AuthenticationCancelled.".localize()) + } +} + +// MARK: - Option builders + +func gitCheckoutOptions(strategy: git_checkout_strategy_t, context: GitCallbackContext? = nil) throws -> git_checkout_options { + var options = git_checkout_options() + try gitTry(git_checkout_options_init(&options, UInt32(GIT_CHECKOUT_OPTIONS_VERSION))) + options.checkout_strategy = strategy.rawValue + if let context, context.checkoutProgress != nil { + options.progress_cb = gitCheckoutProgressCallback + options.progress_payload = context.payload + } + return options +} + +func gitFetchOptions(context: GitCallbackContext) throws -> git_fetch_options { + var options = git_fetch_options() + try gitTry(git_fetch_options_init(&options, UInt32(GIT_FETCH_OPTIONS_VERSION))) + options.callbacks.transfer_progress = gitTransferProgressCallback + options.callbacks.certificate_check = gitCertificateCheckCallback + options.callbacks.credentials = gitCredentialsCallback + options.callbacks.payload = context.payload + return options +} + +func gitPushOptions(context: GitCallbackContext) throws -> git_push_options { + var options = git_push_options() + try gitTry(git_push_options_init(&options, UInt32(GIT_PUSH_OPTIONS_VERSION))) + options.callbacks.push_transfer_progress = gitPushProgressCallback + options.callbacks.certificate_check = gitCertificateCheckCallback + options.callbacks.push_update_reference = gitPushUpdateReferenceCallback + options.callbacks.credentials = gitCredentialsCallback + options.callbacks.payload = context.payload + return options +} diff --git a/passKit/Models/PasswordEntity.swift b/passKit/Models/PasswordEntity.swift index 0afdb6d6..d6b1a05f 100644 --- a/passKit/Models/PasswordEntity.swift +++ b/passKit/Models/PasswordEntity.swift @@ -9,7 +9,6 @@ import CoreData import DequeModule import Foundation -import ObjectiveGit import SwiftyUserDefaults public final class PasswordEntity: NSManagedObject, Identifiable { diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index b918ab4f..4a22c8e8 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -9,7 +9,6 @@ import CoreData import Foundation import KeychainAccess -import ObjectiveGit import SwiftyUserDefaults import UIKit @@ -26,10 +25,14 @@ public class PasswordStore { public var gitRepository: GitRepository? - public var gitSignatureForNow: GTSignature? { - let gitSignatureName = Defaults.gitSignatureName ?? Globals.gitSignatureDefaultName - let gitSignatureEmail = Defaults.gitSignatureEmail ?? Globals.gitSignatureDefaultEmail - return GTSignature(name: gitSignatureName, email: gitSignatureEmail, time: Date()) + /// Not validated here: creating the libgit2 signature to check it and then + /// throwing it away doubles the work of every commit, and the commit itself + /// reports a name or email libgit2 will not accept. + public var gitSignatureForNow: GitSignature { + GitSignature( + name: Defaults.gitSignatureName ?? Globals.gitSignatureDefaultName, + email: Defaults.gitSignatureEmail ?? Globals.gitSignatureDefaultEmail + ) } public var gitPassword: String? { @@ -63,7 +66,7 @@ public class PasswordStore { } public var numberOfLocalCommits: Int { - (try? getLocalCommits()).map(\.count) ?? 0 + (try? gitRepository?.numberOfLocalCommits()) as? Int ?? 0 } public var lastSyncedTime: Date? { @@ -116,9 +119,9 @@ public class PasswordStore { public func cloneRepository( remoteRepoURL: URL, branchName: String, - options: CloneOptions = [:], + options: GitCredentialOptions = GitCredentialOptions(), transferProgressBlock: @escaping TransferProgressHandler = { _, _ in }, - checkoutProgressBlock: @escaping CheckoutProgressHandler = { _, _, _ in } + checkoutProgressBlock: @escaping CheckoutProgressHandler = { _ in } ) throws { try? fileManager.removeItem(at: storeURL) gitPassword = nil @@ -142,13 +145,13 @@ public class PasswordStore { } public func pullRepository( - options: PullOptions, + options: GitCredentialOptions, progressBlock: @escaping TransferProgressHandler = { _, _ in } ) throws { guard let gitRepository else { throw AppError.repositoryNotSet } - try gitRepository.pull(options: options, transferProgressBlock: progressBlock) + try gitRepository.pull(options: options, signature: gitSignatureForNow, transferProgressBlock: progressBlock) Defaults.lastSyncedTime = Date() setAllSynced() DispatchQueue.main.async { @@ -163,7 +166,7 @@ public class PasswordStore { saveUpdatedContext() } - public func getRecentCommits(count: Int) throws -> [GTCommit] { + public func getRecentCommits(count: Int) throws -> [GitCommit] { guard let gitRepository else { throw AppError.repositoryNotSet } @@ -218,8 +221,8 @@ public class PasswordStore { } public func pushRepository( - options: PushOptions, - transferProgressBlock: @escaping PushProgressHandler = { _, _, _, _ in } + options: GitCredentialOptions, + transferProgressBlock: @escaping PushProgressHandler = { _, _ in } ) throws { guard let gitRepository else { throw AppError.repositoryNotSet @@ -374,8 +377,7 @@ public class PasswordStore { guard let gitRepository else { throw AppError.repositoryNotSet } - let localCommitsCount = try getLocalCommits().count - try gitRepository.reset() + let localCommitsCount = try gitRepository.reset() setAllSynced() deleteCoreData() initPasswordEntityCoreData() @@ -385,7 +387,7 @@ public class PasswordStore { return localCommitsCount } - private func getLocalCommits() throws -> [GTCommit] { + private func getLocalCommits() throws -> [GitCommit] { guard let gitRepository else { throw AppError.repositoryNotSet } @@ -461,8 +463,8 @@ extension PasswordStore { } @discardableResult - private func gitCommit(message: String) throws -> GTCommit { - guard let gitRepository, let gitSignatureForNow else { + private func gitCommit(message: String) throws -> GitCommit { + guard let gitRepository else { throw AppError.repositoryNotSet } return try gitRepository.commit(signature: gitSignatureForNow, message: message) diff --git a/passKitTests/Models/GitCredentialTest.swift b/passKitTests/Models/GitCredentialTest.swift index 9a0d3e84..c67a9f02 100644 --- a/passKitTests/Models/GitCredentialTest.swift +++ b/passKitTests/Models/GitCredentialTest.swift @@ -8,7 +8,6 @@ import XCTest -import ObjectiveGit import SwiftyUserDefaults @testable import passKit @@ -48,13 +47,38 @@ final class GitCredentialTest: XCTestCase { func testOptions() { let password = GitCredential.from(authenticationMethod: .password, userName: "user", keyStore: keyStore) - let options = password.getCredentialOptions() - XCTAssertEqual(options.count, 2) + let provider = password.getCredentialOptions().credentialProvider + XCTAssertNotNil(provider) + XCTAssertEqual(provider?.userName, "user") + } + + func testEmptyOptions() { + XCTAssertNil(GitCredentialOptions().credentialProvider) + } - let cloneCredentialProvider = options[GTRepositoryCloneOptionsCredentialProvider] as! GTCredentialProvider - let remoteCredentialProvider = options[GTRepositoryRemoteOptionsCredentialProvider] as! GTCredentialProvider - XCTAssertNotNil(cloneCredentialProvider) - XCTAssertEqual(cloneCredentialProvider, remoteCredentialProvider) + func testPasswordCredentialSpec() { + let credentialProvider = getCredentialProvider(authenticationMethod: .password) + + guard case let .userPassPlaintext(userName, password) = credentialProvider.nextCredential() else { + XCTFail("Expected a plaintext user name and password.") + return + } + XCTAssertEqual(userName, "user") + XCTAssertEqual(password, "password") + } + + func testSSHKeyCredentialSpec() { + keyStore.add(string: "private key", for: SSHKey.PRIVATE.getKeychainKey()) + let credentialProvider = getCredentialProvider(authenticationMethod: .key) + + guard case let .sshKeyMemory(userName, publicKey, privateKey, passphrase) = credentialProvider.nextCredential() else { + XCTFail("Expected an in-memory SSH key.") + return + } + XCTAssertEqual(userName, "user") + XCTAssertNil(publicKey) + XCTAssertEqual(privateKey, "private key") + XCTAssertEqual(passphrase, "passphrase") } func testPasswordCredentialProvider() { @@ -62,32 +86,30 @@ final class GitCredentialTest: XCTestCase { let expectation = expectation(description: "Password is requested.") expectation.assertForOverFulfill = true expectation.expectedFulfillmentCount = 3 - let options = password.getCredentialOptions { _, _ in + let credentialProvider = password.createCredentialProvider { _, _ in expectation.fulfill() return "otherPassword" } - let credentialProvider = options[GTRepositoryCloneOptionsCredentialProvider] as! GTCredentialProvider (1 ..< 5).forEach { _ in - XCTAssertNotNil(credentialProvider.credential(for: .userPassPlaintext, url: nil, userName: nil)) + XCTAssertNotNil(credentialProvider.nextCredential()) } - XCTAssertNil(credentialProvider.credential(for: .userPassPlaintext, url: nil, userName: nil)) + XCTAssertNil(credentialProvider.nextCredential()) wait(for: [expectation], timeout: 0) } - func testSSHKeyCredentialProvider() throws { - throw XCTSkip("Skipped. This test failed in CI environment. Reason still unknown.") + func testSSHKeyCredentialProvider() { let credentialProvider = getCredentialProvider(authenticationMethod: .key) - XCTAssertNotNil(credentialProvider.credential(for: .sshCustom, url: nil, userName: nil)) - XCTAssertNil(credentialProvider.credential(for: .sshCustom, url: nil, userName: nil)) + XCTAssertNotNil(credentialProvider.nextCredential()) + XCTAssertNil(credentialProvider.nextCredential()) } func testCannotGetPassword() { let credentialProvider = getCredentialProvider(authenticationMethod: .password) - XCTAssertNotNil(credentialProvider.credential(for: .userPassPlaintext, url: nil, userName: nil)) - XCTAssertNil(credentialProvider.credential(for: .userPassPlaintext, url: nil, userName: nil)) + XCTAssertNotNil(credentialProvider.nextCredential()) + XCTAssertNil(credentialProvider.nextCredential()) } func testSaveToKeyStore() { @@ -95,14 +117,13 @@ final class GitCredentialTest: XCTestCase { passKit.Defaults.isRememberGitCredentialPassphraseOn = true keyStore.removeAllContent() - credentialProvider.credential(for: .sshCustom, url: nil, userName: nil) + _ = credentialProvider.nextCredential() XCTAssertEqual(keyStore.get(for: Globals.gitSSHPrivateKeyPassphrase), "otherPassword") } - private func getCredentialProvider(authenticationMethod: GitAuthenticationMethod, password: String? = nil) -> GTCredentialProvider { + private func getCredentialProvider(authenticationMethod: GitAuthenticationMethod, password: String? = nil) -> GitCredentialProvider { let credential = GitCredential.from(authenticationMethod: authenticationMethod, userName: "user", keyStore: keyStore) - let options = credential.getCredentialOptions { _, _ in password } - return options[GTRepositoryCloneOptionsCredentialProvider] as! GTCredentialProvider + return credential.createCredentialProvider { _, _ in password } } } diff --git a/passKitTests/Models/GitRepositoryTest.swift b/passKitTests/Models/GitRepositoryTest.swift index 8135ddda..49fe4ad6 100644 --- a/passKitTests/Models/GitRepositoryTest.swift +++ b/passKitTests/Models/GitRepositoryTest.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Bob Sun. All rights reserved. // -import ObjectiveGit +import Libgit2 import XCTest @testable import passKit @@ -15,13 +15,13 @@ final class GitRepositoryTest: XCTestCase { private var workingRepositoryURL: URL! private var repository: GitRepository! private let fileManager = FileManager.default - private let checkoutProgressBlock: CheckoutProgressHandler = { _, _, _ in + private let checkoutProgressBlock: CheckoutProgressHandler = { _ in } private let transferProgressBlock: TransferProgressHandler = { _, _ in } - private let pushProgressBlock: PushProgressHandler = { _, _, _, _ in + private let pushProgressBlock: PushProgressHandler = { _, _ in } override func setUpWithError() throws { @@ -35,12 +35,9 @@ final class GitRepositoryTest: XCTestCase { withIntermediateDirectories: true ) - let options = [ - GTRepositoryInitOptionsFlags: GTRepositoryInitFlags.bare.rawValue, - ] - try GTRepository.initializeEmpty(atFileURL: bareRepositoryURL, options: options) + try initializeBareRepository(at: bareRepositoryURL) - repository = try GitRepository(from: bareRepositoryURL, to: workingRepositoryURL, branchName: "master", options: options, transferProgressBlock: transferProgressBlock, checkoutProgressBlock: checkoutProgressBlock) + repository = try GitRepository(from: bareRepositoryURL, to: workingRepositoryURL, branchName: "master", transferProgressBlock: transferProgressBlock, checkoutProgressBlock: checkoutProgressBlock) } func testSetup() { @@ -53,64 +50,108 @@ final class GitRepositoryTest: XCTestCase { } func testCommit() throws { - try ["file1", "file2"].forEach { filename in - let fileURL = workingRepositoryURL.appendingPathComponent(filename) - try "change1".write(toFile: fileURL.path, atomically: true, encoding: .utf8) - try repository.add(path: filename) - _ = try repository.commit(name: "name", email: "email@email.com", message: "message: \(filename)") - } + try commitFiles(["file1", "file2"]) + } + + func testCommitRejectsInvalidSignature() throws { + XCTAssertThrowsError(try repository.commit(name: "na?] = [UnsafeMutablePointer(mutating: refspec)] + try refspecs.withUnsafeMutableBufferPointer { buffer in + var array = git_strarray(strings: buffer.baseAddress, count: 1) + try gitTry(git_remote_push(remote, &array, &options)) + } + } + + if deleteLocally { + try gitTry(git_branch_delete(branch)) + } + } } diff --git a/passKitTests/Models/GitTransportTest.swift b/passKitTests/Models/GitTransportTest.swift new file mode 100644 index 00000000..8d4e9a34 --- /dev/null +++ b/passKitTests/Models/GitTransportTest.swift @@ -0,0 +1,212 @@ +// +// GitTransportTest.swift +// passKitTests +// +// Created by Mingshen Sun on 8/4/26. +// Copyright © 2026 Bob Sun. All rights reserved. +// + +import XCTest +@testable import passKit + +/// Exercises the SSH and HTTPS transports against the servers started by +/// scripts/git_servers.sh. Every other test talks to a repository on disk, so +/// without these nothing covers libssh2, the TLS stream of libgit2 or the +/// credential callbacks that only remote operations reach. +/// +/// The tests skip when the script has not been run, so that `fastlane test` +/// still works without it. +final class GitTransportTest: XCTestCase { + private let fileManager = FileManager.default + private var workingDirectory: URL! + + private let noProgress: TransferProgressHandler = { _, _ in } + private let noCheckoutProgress: CheckoutProgressHandler = { _ in } + private let noPushProgress: PushProgressHandler = { _, _ in } + + private var environment: [String: String] { ProcessInfo.processInfo.environment } + + override func setUpWithError() throws { + try super.setUpWithError() + workingDirectory = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString) + } + + override func tearDownWithError() throws { + try? fileManager.removeItem(at: workingDirectory) + try super.tearDownWithError() + } + + // MARK: - HTTPS + + func testClonesOverHTTPS() throws { + let repository = try cloneOverHTTPS() + XCTAssertTrue(fileManager.fileExists(atPath: workingDirectory.appendingPathComponent("README").path)) + XCTAssertTrue(try containsSeedCommit(repository)) + } + + func testPushesOverHTTPS() throws { + let repository = try cloneOverHTTPS() + try commitFile(named: "over-https", in: repository) + try repository.push(options: try httpsCredentials(), transferProgressBlock: noPushProgress) + XCTAssertEqual(try repository.getLocalCommits().count, 0) + } + + func testPullsOverHTTPS() throws { + let repository = try cloneOverHTTPS() + try repository.pull(options: try httpsCredentials(), transferProgressBlock: noProgress) + XCTAssertTrue(try containsSeedCommit(repository)) + } + + /// The wrong password must fail rather than hang, and must not be retried forever. + func testRejectsWrongHTTPSPassword() throws { + let url = try requireURL("GIT_HTTPS_URL") + let userName = try requireValue("GIT_HTTPS_USER") + var options = GitCredentialOptions(credentialProvider: provider(userName: userName, attempts: 1) { + .userPassPlaintext(userName: userName, password: "definitely-not-the-password") + }) + options.pinnedCertificate = try serverCertificate() + XCTAssertThrowsError(try clone(from: url, options: options)) { error in + // Otherwise this passes without proving anything whenever the server + // is unreachable or its certificate is not trusted. + XCTAssertFalse( + error.localizedDescription.contains("untrusted"), + "the connection itself failed, so nothing about the password was tested: \(error.localizedDescription)" + ) + } + } + + /// The pin accepts one certificate, not any certificate: a server presenting + /// something else has to be refused. + func testRejectsUnpinnedCertificate() throws { + let url = try requireURL("GIT_HTTPS_URL") + let wrongCertificate = Data("not the certificate of the server".utf8) + + XCTAssertThrowsError(try clone(from: url, options: try httpsCredentials(pinning: wrongCertificate))) { error in + XCTAssertTrue( + error.localizedDescription.contains("untrusted"), + "expected the connection to be refused, got: \(error.localizedDescription)" + ) + } + } + + // MARK: - SSH + + func testClonesOverSSH() throws { + let repository = try cloneOverSSH() + XCTAssertTrue(fileManager.fileExists(atPath: workingDirectory.appendingPathComponent("README").path)) + XCTAssertTrue(try containsSeedCommit(repository)) + } + + func testPushesOverSSH() throws { + let repository = try cloneOverSSH() + try commitFile(named: "over-ssh", in: repository) + try repository.push(options: try sshCredentials(), transferProgressBlock: noPushProgress) + XCTAssertEqual(try repository.getLocalCommits().count, 0) + } + + func testPullsOverSSH() throws { + let repository = try cloneOverSSH() + try repository.pull(options: try sshCredentials(), transferProgressBlock: noProgress) + XCTAssertTrue(try containsSeedCommit(repository)) + } + + /// A provider that gives up stands for the user dismissing the passphrase + /// prompt. libgit2 must report that rather than a message left over from + /// something else. + func testReportsCancelledAuthentication() throws { + let url = try requireURL("GIT_SSH_URL") + let options = GitCredentialOptions(credentialProvider: provider(userName: "git", attempts: 0) { nil }) + + XCTAssertThrowsError(try clone(from: url, options: options)) { error in + // Naming the message, so this cannot pass on an unrelated failure. + XCTAssertEqual(error.localizedDescription, "AuthenticationCancelled.".localize()) + } + } + + // MARK: - Fixtures + + private func cloneOverHTTPS() throws -> GitRepository { + try clone(from: try requireURL("GIT_HTTPS_URL"), options: try httpsCredentials()) + } + + private func cloneOverSSH() throws -> GitRepository { + try clone(from: try requireURL("GIT_SSH_URL"), options: try sshCredentials()) + } + + private func clone(from url: URL, options: GitCredentialOptions) throws -> GitRepository { + try GitRepository( + from: url, + to: workingDirectory, + branchName: "master", + options: options, + transferProgressBlock: noProgress, + checkoutProgressBlock: noCheckoutProgress + ) + } + + private func httpsCredentials(pinning certificate: Data? = nil) throws -> GitCredentialOptions { + let userName = try requireValue("GIT_HTTPS_USER") + let password = try requireValue("GIT_HTTPS_PASSWORD") + var options = GitCredentialOptions(credentialProvider: provider(userName: userName, attempts: 1) { + .userPassPlaintext(userName: userName, password: password) + }) + // The certificate of the test server is pinned rather than added to the + // trust store of the simulator, which cannot be relied on: simctl + // reports success and the trust does not take effect everywhere. + options.pinnedCertificate = try certificate ?? serverCertificate() + return options + } + + private func serverCertificate() throws -> Data { + let encoded = try requireValue("GIT_HTTPS_CERTIFICATE_BASE64") + return try XCTUnwrap(Data(base64Encoded: encoded)) + } + + private func sshCredentials() throws -> GitCredentialOptions { + let userName = try requireValue("GIT_SSH_USER") + let encodedKey = try requireValue("GIT_SSH_PRIVATE_KEY_BASE64") + let privateKey = try XCTUnwrap(Data(base64Encoded: encodedKey).flatMap { String(data: $0, encoding: .utf8) }) + return GitCredentialOptions(credentialProvider: provider(userName: userName, attempts: 1) { + .sshKeyMemory(userName: userName, publicKey: nil, privateKey: privateKey, passphrase: "") + }) + } + + /// libgit2 asks until it is authenticated or the provider stops, so the + /// number of attempts has to be bounded or a rejection loops. + private func provider(userName: String, attempts: Int, credential: @escaping () -> GitCredentialSpec?) -> GitCredentialProvider { + var remaining = attempts + return GitCredentialProvider(userName: userName) { + guard remaining > 0 else { + return nil + } + remaining -= 1 + return credential() + } + } + + /// The push tests add to the same repositories, so nothing may assume that + /// the seeded commit is still the most recent one. + private func containsSeedCommit(_ repository: GitRepository) throws -> Bool { + try repository.getRecentCommits(count: 50).contains { $0.message?.trimmed == "seed" } + } + + private func commitFile(named name: String, in repository: GitRepository) throws { + try "content".write(to: workingDirectory.appendingPathComponent(name), atomically: true, encoding: .utf8) + try repository.add(path: name) + _ = try repository.commit(name: "Test", email: "test@example.com", message: "add \(name)") + } + + private func requireValue(_ name: String) throws -> String { + guard let value = environment[name], !value.isEmpty else { + throw XCTSkip("\(name) is not set; run scripts/git_servers.sh start and source .git-servers/env") + } + return value + } + + private func requireURL(_ name: String) throws -> URL { + // Resolved before the unwrap: XCTUnwrap catches a skip thrown inside its + // closure and records it as a failure of its own. + let value = try requireValue(name) + return try XCTUnwrap(URL(string: value)) + } +} diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index e4f7fae4..c0493157 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -7,7 +7,6 @@ // import Foundation -import ObjectiveGit import XCTest @testable import passKit diff --git a/scripts/git_servers.sh b/scripts/git_servers.sh new file mode 100755 index 00000000..c53941c3 --- /dev/null +++ b/scripts/git_servers.sh @@ -0,0 +1,273 @@ +#!/bin/bash + +# Starts local SSH and HTTPS git servers for the transport tests, which are the +# only tests that exercise libssh2 and the TLS stream of libgit2. Everything +# lives in .git-servers and is thrown away by `stop`. +# +# ./scripts/git_servers.sh start +# set -a; source .git-servers/env; set +a +# bundle exec fastlane test +# ./scripts/git_servers.sh stop +# +# The tests pin the certificate of the HTTPS server rather than adding it to the +# trusted roots of the simulator: simctl reports that it added it and on a +# runner the trust does not take effect. Validation against the trust store of +# the system is therefore not what these tests cover. +# +# HTTPS is served by the Apache that ships with macOS, SSH by its sshd. + +set -euo pipefail + +SSH_PORT="${GIT_SERVERS_SSH_PORT:-47022}" +HTTPS_PORT="${GIT_SERVERS_HTTPS_PORT:-47443}" +DEVICE="${GIT_SERVERS_DEVICE:-iPhone 16}" + +HTTP_USER="testuser" +HTTP_PASSWORD="testpassword" + +STATE_PATH="$(pwd)/.git-servers" + +log() { echo "[git_servers] $*" >&2; } + +# Only processes this script started are killed. Matching by port alone would +# take down whatever else a developer happens to be running on it, and matching +# by name would miss a stale server that then answers with an old certificate. +port_in_use() { + lsof -nP -iTCP:"$1" -sTCP:LISTEN -t >/dev/null 2>&1 +} + +# A recorded number is not proof: process ids are reused, and a stale file from +# an interrupted run can name something else by the time it is read. The match +# is done with a case statement rather than grep, because a grep would carry +# the very path it looks for in its own arguments and so match itself. +is_our_server() { + local command + command="$(ps -o command= -p "$1" 2>/dev/null)" || return 1 + case "$command" in + *"$STATE_PATH"*) return 0 ;; + *) return 1 ;; + esac +} + +stop() { + local pid_file pid pids="" + for pid_file in "$STATE_PATH"/*.pid; do + [ -f "$pid_file" ] || continue + pid="$(cat "$pid_file")" + [ -n "$pid" ] || continue + is_our_server "$pid" || continue + pids="$pids $pid" + # Asked to shut down rather than killed outright: Apache forks workers, and + # killing the parent leaves them orphaned and still holding the port. + kill "$pid" 2>/dev/null || true + done + + for _ in $(seq 1 50); do + port_in_use "$SSH_PORT" || port_in_use "$HTTPS_PORT" || break + sleep 0.2 + done + for pid in $pids; do + is_our_server "$pid" && kill -9 "$pid" 2>/dev/null || true + done + + rm -rf "$STATE_PATH" + log "stopped" +} + +require_free_port() { + local port="$1" name="$2" + if port_in_use "$port"; then + log "port $port is already in use, so the $name server cannot start" + log "stop whatever is holding it, or set GIT_SERVERS_${name}_PORT to another one" + return 1 + fi +} + +wait_for_port() { + local port="$1" name="$2" pid="${3:-}" + for _ in $(seq 1 100); do + if lsof -nP -iTCP:"$port" -sTCP:LISTEN -t >/dev/null 2>&1; then + return 0 + fi + if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then + log "$name exited before it listened on port $port" + sed 's/^/ /' "$STATE_PATH/$name.log" >&2 2>/dev/null || log "(no output)" + return 1 + fi + sleep 0.2 + done + log "$name did not come up on port $port within 20s" + sed 's/^/ /' "$STATE_PATH/$name.log" >&2 2>/dev/null || log "(no output)" + return 1 +} + +seed_repository() { + local repository="$1" work="$STATE_PATH/seed" + git init -q --bare "$repository" + git -C "$repository" config http.receivepack true + rm -rf "$work" + git init -q "$work" + echo "seeded" > "$work/README" + git -C "$work" add README + git -C "$work" -c user.email=test@example.com -c user.name=Test commit -qm "seed" + git -C "$work" push -q "$repository" HEAD:refs/heads/master + rm -rf "$work" +} + +write_certificates() { + # Self-signed, and no authority: the tests pin this exact certificate, so + # nothing ever builds a chain. An authority was generated here until a spike + # showed openssl verify rejecting the very chain it had just produced, which + # no test noticed precisely because they all pin. + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$STATE_PATH/leaf.key" -out "$STATE_PATH/leaf.pem" \ + -days 30 -subj "/CN=127.0.0.1" \ + -addext "subjectAltName=IP:127.0.0.1" \ + -addext "keyUsage=critical,digitalSignature,keyEncipherment" \ + -addext "extendedKeyUsage=serverAuth" 2>/dev/null +} + +start_ssh_server() { + ssh-keygen -q -t ed25519 -f "$STATE_PATH/host_key" -N "" + ssh-keygen -q -t ed25519 -f "$STATE_PATH/client_key" -N "" + cp "$STATE_PATH/client_key.pub" "$STATE_PATH/authorized_keys" + chmod 600 "$STATE_PATH/authorized_keys" + + cat > "$STATE_PATH/sshd_config" < "$STATE_PATH/sshd.log" 2>&1 & + wait_for_port "$SSH_PORT" sshd "$!" +} + +start_https_server() { + # The Apache that ships with macOS, which speaks CGI, chunked bodies, basic + # authentication and TLS already. Doing it by hand meant decoding chunked + # transfers, forwarding the CGI status, and working around a hostname lookup + # between binding and listening, none of which is our problem here. + local modules=/usr/libexec/apache2 + + htpasswd -bc "$STATE_PATH/htpasswd" "$HTTP_USER" "$HTTP_PASSWORD" 2>/dev/null + + cat > "$STATE_PATH/httpd.conf" < + AuthType Basic + AuthName "pass" + AuthUserFile "$STATE_PATH/htpasswd" + Require valid-user + +EOF + + # Left to daemonise rather than held in the foreground. With -DFOREGROUND it + # never calls setsid, so it shares the process group of whoever started it, + # and shutting it down signals that whole group -- which means stopping the + # servers kills the shell that asked. Daemonised it owns its own group, writes + # its own pid file, and returns once it is up. + /usr/sbin/httpd -f "$STATE_PATH/httpd.conf" + wait_for_port "$HTTPS_PORT" https +} + +record_device() { + local selected + # Only to run the tests somewhere predictable. The certificate is pinned by + # the tests themselves, so nothing depends on the trust store of the device. + selected="$(xcrun simctl list devices available --json | python3 -c " +import json, re, sys + +name, preferred = sys.argv[1], sys.argv[2] +candidates = [] +for runtime, devices in json.load(sys.stdin)['devices'].items(): + version = re.search(r'iOS-([0-9]+)-([0-9]+)', runtime) + if not version: + continue + major, minor = int(version.group(1)), int(version.group(2)) + for device in devices: + if device['name'] == name: + candidates.append((major == int(preferred), major, minor, device['udid'])) +if candidates: + match = max(candidates) + print(f'{match[3]} iOS-{match[1]}.{match[2]}') +" "$DEVICE" "${GIT_SERVERS_IOS_MAJOR:-18}")" + + local udid="${selected%% *}" + if [ -z "$udid" ]; then + log "no simulator named '$DEVICE'; set GIT_SERVERS_DEVICE to one that exists" + return 1 + fi + echo "$udid" > "$STATE_PATH/device_udid" + log "tests will run on $DEVICE ${selected##* } ($udid)" +} + +start() { + case "$STATE_PATH" in + *[!A-Za-z0-9/._-]*) + log "the path $STATE_PATH contains characters that do not survive being put in a URL" + log "check the repository out somewhere without spaces or move it" + exit 1 + ;; + esac + stop + mkdir -p "$STATE_PATH" + require_free_port "$SSH_PORT" SSH + require_free_port "$HTTPS_PORT" HTTPS + write_certificates + seed_repository "$STATE_PATH/ssh-repo.git" + seed_repository "$STATE_PATH/repo.git" + start_ssh_server + start_https_server + record_device + + # Consumed by the tests. xcodebuild passes variables with this prefix into the + # test process with the prefix removed. + cat > "$STATE_PATH/env" <&2; exit 2 ;; +esac diff --git a/scripts/libgit2_build.sh b/scripts/libgit2_build.sh new file mode 100755 index 00000000..9f8f8140 --- /dev/null +++ b/scripts/libgit2_build.sh @@ -0,0 +1,166 @@ +#!/bin/bash + +# Builds libgit2 as an iOS xcframework, statically linked against libssh2 and +# the OpenSSL libcrypto libssh2 needs. HTTPS goes through SecureTransport, so +# OpenSSL is only ever reached by the SSH transport, which is how the +# ObjectiveGit framework this replaces was put together. +# +# This caps HTTPS at TLS 1.2, which libgit2 hardcodes in its SecureTransport +# stream. Raising it is not possible: SecureTransport on iOS rejects +# kTLSProtocol13 with errSSLIllegalParam, and libgit2 aborts the connection +# when that call fails, so patching the constant breaks HTTPS outright. TLS 1.3 +# would mean switching to OpenSSL, and shipping and maintaining a CA bundle +# with it, since OpenSSL cannot read the trust store of the system. + +set -euox pipefail + +OPENSSL_VERSION="openssl-3.5.7" +LIBSSH2_VERSION="libssh2-1.11.1" +LIBGIT2_VERSION="v1.9.6" + +IOS_DEPLOYMENT_TARGET="13.0" + +ROOT_PATH="$(pwd)/libgit2" +CHECKOUT_PATH="$ROOT_PATH/checkout" +BUILD_PATH="$ROOT_PATH/build" +INSTALL_PATH="$ROOT_PATH/install" +HEADER_PATH="$ROOT_PATH/include" +OUTPUT_PATH="$ROOT_PATH/dist" + +JOBS="$(sysctl -n hw.ncpu)" + +mkdir -p "$CHECKOUT_PATH" "$BUILD_PATH" "$INSTALL_PATH" "$OUTPUT_PATH" + +checkout() { + local url="$1" tag="$2" path="$3" + if [ ! -d "$path" ]; then + git clone --depth 1 --branch "$tag" "$url" "$path" + fi +} + +checkout https://github.com/openssl/openssl.git "$OPENSSL_VERSION" "$CHECKOUT_PATH/openssl" +checkout https://github.com/libssh2/libssh2.git "$LIBSSH2_VERSION" "$CHECKOUT_PATH/libssh2" +checkout https://github.com/libgit2/libgit2.git "$LIBGIT2_VERSION" "$CHECKOUT_PATH/libgit2" + +# CMake needs the platform spelled out for every dependency, and has to be kept +# from looking outside the slice being built when resolving the previous ones. +cmake_configure() { + local source="$1" build="$2" sdk="$3" arch="$4" prefix="$5" + shift 5 + cmake -S "$source" -B "$build" \ + -DCMAKE_SYSTEM_NAME=iOS \ + -DCMAKE_OSX_SYSROOT="$sdk" \ + -DCMAKE_OSX_ARCHITECTURES="$arch" \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="$IOS_DEPLOYMENT_TARGET" \ + -DCMAKE_INSTALL_PREFIX="$prefix" \ + -DCMAKE_PREFIX_PATH="$prefix" \ + -DCMAKE_FIND_ROOT_PATH="$prefix" \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + "$@" +} + +build_slice() { + local slice="$1" sdk="$2" arch="$3" openssl_target="$4" + local prefix="$INSTALL_PATH/$slice" + local min_flag + + if [ "$sdk" = "iphoneos" ]; then + min_flag="-mios-version-min=$IOS_DEPLOYMENT_TARGET" + else + min_flag="-mios-simulator-version-min=$IOS_DEPLOYMENT_TARGET" + fi + + # OpenSSL has its own build system and cannot produce a fat library, so every + # architecture is configured separately. Only libcrypto is used afterwards. + # The target already carries the architecture and picks the assembly for it; + # anything not starting with a dash would be read as a second target. + # OpenSSL takes by far the longest and is pinned, so an existing install is + # reused. Remove the install directory to force it to be built again. + local openssl_build="$BUILD_PATH/$slice/openssl" + if [ ! -f "$prefix/lib/libcrypto.a" ]; then + rm -rf "$openssl_build" + mkdir -p "$openssl_build" + ( + cd "$openssl_build" + "$CHECKOUT_PATH/openssl/Configure" "$openssl_target" \ + --prefix="$prefix" \ + --openssldir="$prefix" \ + no-shared no-tests no-apps no-docs \ + "$min_flag" + make -j"$JOBS" + make install_dev + ) + fi + + cmake_configure "$CHECKOUT_PATH/libssh2" "$BUILD_PATH/$slice/libssh2" "$sdk" "$arch" "$prefix" \ + -DBUILD_STATIC_LIBS=ON \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_TESTING=OFF \ + -DENABLE_ZLIB_COMPRESSION=ON \ + -DCRYPTO_BACKEND=OpenSSL \ + -DOPENSSL_ROOT_DIR="$prefix" \ + -DOPENSSL_USE_STATIC_LIBS=ON + cmake --build "$BUILD_PATH/$slice/libssh2" --target install -j "$JOBS" + + # regcomp_l, which CMake picks by default on Apple platforms, is marked + # unavailable on iOS, so the PCRE bundled with libgit2 is used instead. + cmake_configure "$CHECKOUT_PATH/libgit2" "$BUILD_PATH/$slice/libgit2" "$sdk" "$arch" "$prefix" \ + -DBUILD_TESTS=OFF \ + -DBUILD_CLI=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_FUZZERS=OFF \ + -DUSE_SSH=libssh2 \ + -DUSE_HTTPS=SecureTransport \ + -DUSE_ICONV=ON \ + -DREGEX_BACKEND=builtin \ + -DUSE_THREADS=ON + cmake --build "$BUILD_PATH/$slice/libgit2" --target install -j "$JOBS" + + # One archive per slice, so that consumers only have to link a single library. + libtool -static -o "$prefix/lib/libgit2_combined.a" \ + "$prefix/lib/libgit2.a" \ + "$prefix/lib/libssh2.a" \ + "$prefix/lib/libcrypto.a" +} + +build_slice "ios-arm64" "iphoneos" "arm64" "ios64-xcrun" +build_slice "iossimulator-arm64" "iphonesimulator" "arm64" "iossimulator-arm64-xcrun" + +# An Intel simulator runs only on an Intel Mac, and the runners are all Apple +# silicon, so a third of a cold build would otherwise be spent on a slice +# nothing here can execute. Set GIT_SERVERS_BUILD_X86_64=1 to force it. +SIMULATOR_LIBRARY="$BUILD_PATH/iossimulator/libgit2_combined.a" +mkdir -p "$(dirname "$SIMULATOR_LIBRARY")" +if [ "$(uname -m)" = "x86_64" ] || [ "${LIBGIT2_BUILD_X86_64:-0}" = "1" ]; then + build_slice "iossimulator-x86_64" "iphonesimulator" "x86_64" "iossimulator-x86_64-xcrun" + # The simulator slice of an xcframework holds both architectures in one archive. + lipo -create \ + "$INSTALL_PATH/iossimulator-arm64/lib/libgit2_combined.a" \ + "$INSTALL_PATH/iossimulator-x86_64/lib/libgit2_combined.a" \ + -output "$SIMULATOR_LIBRARY" +else + cp "$INSTALL_PATH/iossimulator-arm64/lib/libgit2_combined.a" "$SIMULATOR_LIBRARY" +fi + +# The headers are identical across slices, and the module map is what lets the +# library be imported from Swift. +rm -rf "$HEADER_PATH" +mkdir -p "$HEADER_PATH" +cp "$INSTALL_PATH/ios-arm64/include/git2.h" "$HEADER_PATH" +cp -R "$INSTALL_PATH/ios-arm64/include/git2" "$HEADER_PATH" +cat > "$HEADER_PATH/module.modulemap" <<'MODULEMAP' +module Libgit2 { + header "git2.h" + // Not reachable through git2.h, but needed to attach a message to the + // errors the credential callback reports. + header "git2/sys/errors.h" + export * +} +MODULEMAP + +rm -rf "$OUTPUT_PATH/libgit2.xcframework" +xcodebuild -create-xcframework \ + -library "$INSTALL_PATH/ios-arm64/lib/libgit2_combined.a" -headers "$HEADER_PATH" \ + -library "$SIMULATOR_LIBRARY" -headers "$HEADER_PATH" \ + -output "$OUTPUT_PATH/libgit2.xcframework"