From 7db85c9939c51e4a2b8e9a35a2af53002f0b5027 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Sun, 8 Mar 2026 11:34:23 +0100 Subject: [PATCH 01/42] DO NOT MERGE local development signing fixes --- pass.xcodeproj/project.pbxproj | 136 ++++++++---------- pass/pass.entitlements | 12 +- pass/passBeta.entitlements | 12 +- .../passAutoFillExtension.entitlements | 6 +- .../passBetaAutoFillExtension.entitlements | 6 +- passExtension/passBetaExtension.entitlements | 4 +- passExtension/passExtension.entitlements | 4 +- passKit/Helpers/Globals.swift | 4 +- passShortcuts/passBetaShortcuts.entitlements | 4 +- passShortcuts/passShortcuts.entitlements | 4 +- 10 files changed, 76 insertions(+), 116 deletions(-) diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index 807205a..ed9df0d 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -1292,14 +1292,10 @@ TargetAttributes = { 30A69944240EED5E00B7D967 = { CreatedOnToolsVersion = 11.3; - DevelopmentTeam = 4WDM8E95VU; - ProvisioningStyle = Manual; }; A239F5942158C08B00576CBF = { CreatedOnToolsVersion = 10.0; - DevelopmentTeam = 4WDM8E95VU; LastSwiftMigration = 1020; - ProvisioningStyle = Manual; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 1; @@ -1322,9 +1318,7 @@ }; A26700231EEC466A00176B8A = { CreatedOnToolsVersion = 8.3.3; - DevelopmentTeam = 4WDM8E95VU; LastSwiftMigration = 1020; - ProvisioningStyle = Manual; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 1; @@ -1336,16 +1330,13 @@ }; DC13B14D1E8640810097803F = { CreatedOnToolsVersion = 8.3; - DevelopmentTeam = 4WDM8E95VU; LastSwiftMigration = 1020; ProvisioningStyle = Automatic; TestTargetID = DC917BD21E2E8231000FDF54; }; DC917BD21E2E8231000FDF54 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = 4WDM8E95VU; LastSwiftMigration = 1020; - ProvisioningStyle = Manual; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 1; @@ -1886,11 +1877,10 @@ CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passShortcuts/PassShortcuts.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = 4WDM8E95VU; + DEVELOPMENT_TEAM = QLYN3TZMJW; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -1904,9 +1894,9 @@ MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforios.shortcuts; + PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforios.shortcuts; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match Development me.mssun.passforios.shortcuts"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -1922,11 +1912,10 @@ CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passShortcuts/PassShortcuts.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = 4WDM8E95VU; + DEVELOPMENT_TEAM = QLYN3TZMJW; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -1939,9 +1928,9 @@ ); MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforios.shortcuts; + PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforios.shortcuts; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforios.shortcuts"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -1980,7 +1969,6 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 0; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; @@ -2004,7 +1992,7 @@ MARKETING_VERSION = 0.19.0; MTL_ENABLE_DEBUG_INFO = NO; OTHER_SWIFT_FLAGS = "-D BETA"; - PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforiosbeta; + PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforiosbeta; PRODUCT_NAME = "Pass Beta"; SDKROOT = iphoneos; STRIP_INSTALLED_PRODUCT = NO; @@ -2022,11 +2010,9 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIconBeta; CODE_SIGN_ENTITLEMENTS = pass/passBeta.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; DEFINES_MODULE = NO; - DEVELOPMENT_TEAM = 4WDM8E95VU; + DEVELOPMENT_TEAM = QLYN3TZMJW; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -2042,8 +2028,6 @@ OTHER_LDFLAGS = "${inherited}"; OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; - PROVISIONING_PROFILE = "ee6e841d-ef77-4f00-b534-d7f1fd25dc1d"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforiosbeta"; STRIP_INSTALLED_PRODUCT = NO; SWIFT_OBJC_BRIDGING_HEADER = "pass/Helpers/Objective-CBridgingHeader.h"; SWIFT_VERSION = 5.0; @@ -2057,7 +2041,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = 4WDM8E95VU; + DEVELOPMENT_TEAM = QLYN3TZMJW; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = passTests/Info.plist; @@ -2086,9 +2070,10 @@ CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = NO; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passExtension/passBetaExtension.entitlements; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = 4WDM8E95VU; + DEVELOPMENT_TEAM = QLYN3TZMJW; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -2105,8 +2090,7 @@ OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).find-login-action-extension"; PRODUCT_NAME = passExtension; - PROVISIONING_PROFILE = "cbd86628-6f3e-40f3-b518-20d2330db545"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforiosbeta.find-login-action-extension"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; STRIP_INSTALLED_PRODUCT = NO; SWIFT_VERSION = 5.0; @@ -2159,6 +2143,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; + DEVELOPMENT_TEAM = QLYN3TZMJW; HEADER_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = passKitTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; @@ -2186,10 +2171,10 @@ CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passAutoFillExtension/passBetaAutoFillExtension.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = 4WDM8E95VU; + DEVELOPMENT_TEAM = QLYN3TZMJW; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -2204,9 +2189,9 @@ MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited)"; - PRODUCT_BUNDLE_IDENTIFIER = "me.mssun.passforiosbeta.auto-fill-credential-extension"; + PRODUCT_BUNDLE_IDENTIFIER = "org.lysanntranvouez.passforiosbeta.auto-fill-credential-extension"; PRODUCT_NAME = passAutoFillExtension; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforiosbeta.auto-fill-credential-extension"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; STRIP_INSTALLED_PRODUCT = NO; SWIFT_VERSION = 5.0; @@ -2223,11 +2208,10 @@ CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passShortcuts/passBetaShortcuts.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = 4WDM8E95VU; + DEVELOPMENT_TEAM = QLYN3TZMJW; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -2241,9 +2225,9 @@ MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited)"; - PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforiosbeta.shortcuts; + PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforiosbeta.shortcuts; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforiosbeta.shortcuts"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2259,10 +2243,10 @@ CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passAutoFillExtension/passAutoFillExtension.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = 4WDM8E95VU; + DEVELOPMENT_TEAM = QLYN3TZMJW; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -2277,9 +2261,9 @@ MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "me.mssun.passforios.auto-fill-credential-extension"; + PRODUCT_BUNDLE_IDENTIFIER = "org.lysanntranvouez.passforios.auto-fill-credential-extension"; PRODUCT_NAME = passAutoFillExtension; - PROVISIONING_PROFILE_SPECIFIER = "match Development me.mssun.passforios.auto-fill-credential-extension"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; STRIP_INSTALLED_PRODUCT = NO; SWIFT_VERSION = 5.0; @@ -2296,10 +2280,10 @@ CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passAutoFillExtension/passAutoFillExtension.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = 4WDM8E95VU; + DEVELOPMENT_TEAM = QLYN3TZMJW; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -2313,9 +2297,9 @@ ); MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "me.mssun.passforios.auto-fill-credential-extension"; + PRODUCT_BUNDLE_IDENTIFIER = "org.lysanntranvouez.passforios.auto-fill-credential-extension"; PRODUCT_NAME = passAutoFillExtension; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforios.auto-fill-credential-extension"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; STRIP_INSTALLED_PRODUCT = NO; SWIFT_VERSION = 5.0; @@ -2405,6 +2389,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; + DEVELOPMENT_TEAM = QLYN3TZMJW; HEADER_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = passKitTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; @@ -2429,6 +2414,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; + DEVELOPMENT_TEAM = QLYN3TZMJW; HEADER_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = passKitTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; @@ -2455,9 +2441,10 @@ CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = NO; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passExtension/passExtension.entitlements; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = 4WDM8E95VU; + DEVELOPMENT_TEAM = QLYN3TZMJW; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -2473,8 +2460,7 @@ OTHER_CFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).find-login-action-extension"; PRODUCT_NAME = passExtension; - PROVISIONING_PROFILE = "d25c9029-bca6-4b2d-b04e-4abc9d232740"; - PROVISIONING_PROFILE_SPECIFIER = "match Development me.mssun.passforios.find-login-action-extension"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; STRIP_INSTALLED_PRODUCT = NO; SWIFT_VERSION = 5.0; @@ -2491,9 +2477,10 @@ CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = NO; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passExtension/passExtension.entitlements; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = 4WDM8E95VU; + DEVELOPMENT_TEAM = QLYN3TZMJW; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -2509,8 +2496,7 @@ OTHER_CFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).find-login-action-extension"; PRODUCT_NAME = passExtension; - PROVISIONING_PROFILE = "cbd86628-6f3e-40f3-b518-20d2330db545"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforios.find-login-action-extension"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; STRIP_INSTALLED_PRODUCT = NO; SWIFT_VERSION = 5.0; @@ -2524,7 +2510,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = 4WDM8E95VU; + DEVELOPMENT_TEAM = QLYN3TZMJW; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = passTests/Info.plist; @@ -2549,7 +2535,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = 4WDM8E95VU; + DEVELOPMENT_TEAM = QLYN3TZMJW; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = passTests/Info.plist; @@ -2600,7 +2586,6 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 0; DEBUG_INFORMATION_FORMAT = dwarf; @@ -2630,7 +2615,7 @@ MARKETING_VERSION = 0.19.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforios; + PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforios; PRODUCT_NAME = Pass; SDKROOT = iphoneos; STRIP_INSTALLED_PRODUCT = NO; @@ -2674,7 +2659,6 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 0; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; @@ -2697,7 +2681,7 @@ LD_RUNPATH_SEARCH_PATHS = ""; MARKETING_VERSION = 0.19.0; MTL_ENABLE_DEBUG_INFO = NO; - PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforios; + PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforios; PRODUCT_NAME = Pass; SDKROOT = iphoneos; STRIP_INSTALLED_PRODUCT = NO; @@ -2715,11 +2699,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = pass/pass.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; DEFINES_MODULE = NO; - DEVELOPMENT_TEAM = 4WDM8E95VU; + DEVELOPMENT_TEAM = QLYN3TZMJW; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -2734,8 +2717,7 @@ OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = "${inherited}"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; - PROVISIONING_PROFILE = "3c4f599a-ce77-4184-b4c4-edebf09cba3b"; - PROVISIONING_PROFILE_SPECIFIER = "match Development me.mssun.passforios"; + PROVISIONING_PROFILE_SPECIFIER = ""; STRIP_INSTALLED_PRODUCT = NO; SWIFT_OBJC_BRIDGING_HEADER = "pass/Helpers/Objective-CBridgingHeader.h"; SWIFT_VERSION = 5.0; @@ -2748,11 +2730,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = pass/pass.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; DEFINES_MODULE = NO; - DEVELOPMENT_TEAM = 4WDM8E95VU; + DEVELOPMENT_TEAM = QLYN3TZMJW; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -2768,8 +2749,7 @@ OTHER_LDFLAGS = "${inherited}"; OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; - PROVISIONING_PROFILE = "ee6e841d-ef77-4f00-b534-d7f1fd25dc1d"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforios"; + PROVISIONING_PROFILE_SPECIFIER = ""; STRIP_INSTALLED_PRODUCT = NO; SWIFT_OBJC_BRIDGING_HEADER = "pass/Helpers/Objective-CBridgingHeader.h"; SWIFT_VERSION = 5.0; diff --git a/pass/pass.entitlements b/pass/pass.entitlements index 26deb49..a844039 100644 --- a/pass/pass.entitlements +++ b/pass/pass.entitlements @@ -2,21 +2,13 @@ - com.apple.developer.authentication-services.autofill-credential-provider - - com.apple.developer.nfc.readersession.formats - - TAG - - com.apple.developer.siri - com.apple.security.application-groups - group.me.mssun.passforios + group.org.lysanntranvouez.passforios keychain-access-groups - $(AppIdentifierPrefix)group.me.mssun.passforios + $(AppIdentifierPrefix)group.org.lysanntranvouez.passforios diff --git a/pass/passBeta.entitlements b/pass/passBeta.entitlements index 1a9efa6..b6ffc4a 100644 --- a/pass/passBeta.entitlements +++ b/pass/passBeta.entitlements @@ -2,21 +2,13 @@ - com.apple.developer.authentication-services.autofill-credential-provider - - com.apple.developer.nfc.readersession.formats - - TAG - - com.apple.developer.siri - com.apple.security.application-groups - group.me.mssun.passforiosbeta + group.org.lysanntranvouez.passforiosbeta keychain-access-groups - $(AppIdentifierPrefix)group.me.mssun.passforiosbeta + $(AppIdentifierPrefix)group.org.lysanntranvouez.passforiosbeta diff --git a/passAutoFillExtension/passAutoFillExtension.entitlements b/passAutoFillExtension/passAutoFillExtension.entitlements index d58dedc..a844039 100644 --- a/passAutoFillExtension/passAutoFillExtension.entitlements +++ b/passAutoFillExtension/passAutoFillExtension.entitlements @@ -2,15 +2,13 @@ - com.apple.developer.authentication-services.autofill-credential-provider - com.apple.security.application-groups - group.me.mssun.passforios + group.org.lysanntranvouez.passforios keychain-access-groups - $(AppIdentifierPrefix)group.me.mssun.passforios + $(AppIdentifierPrefix)group.org.lysanntranvouez.passforios diff --git a/passAutoFillExtension/passBetaAutoFillExtension.entitlements b/passAutoFillExtension/passBetaAutoFillExtension.entitlements index 272d9c8..b6ffc4a 100644 --- a/passAutoFillExtension/passBetaAutoFillExtension.entitlements +++ b/passAutoFillExtension/passBetaAutoFillExtension.entitlements @@ -2,15 +2,13 @@ - com.apple.developer.authentication-services.autofill-credential-provider - com.apple.security.application-groups - group.me.mssun.passforiosbeta + group.org.lysanntranvouez.passforiosbeta keychain-access-groups - $(AppIdentifierPrefix)group.me.mssun.passforiosbeta + $(AppIdentifierPrefix)group.org.lysanntranvouez.passforiosbeta diff --git a/passExtension/passBetaExtension.entitlements b/passExtension/passBetaExtension.entitlements index 9bcfd12..b6ffc4a 100644 --- a/passExtension/passBetaExtension.entitlements +++ b/passExtension/passBetaExtension.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.me.mssun.passforiosbeta + group.org.lysanntranvouez.passforiosbeta keychain-access-groups - $(AppIdentifierPrefix)group.me.mssun.passforiosbeta + $(AppIdentifierPrefix)group.org.lysanntranvouez.passforiosbeta diff --git a/passExtension/passExtension.entitlements b/passExtension/passExtension.entitlements index 13c5967..a844039 100644 --- a/passExtension/passExtension.entitlements +++ b/passExtension/passExtension.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.me.mssun.passforios + group.org.lysanntranvouez.passforios keychain-access-groups - $(AppIdentifierPrefix)group.me.mssun.passforios + $(AppIdentifierPrefix)group.org.lysanntranvouez.passforios diff --git a/passKit/Helpers/Globals.swift b/passKit/Helpers/Globals.swift index a9242c2..2135dde 100644 --- a/passKit/Helpers/Globals.swift +++ b/passKit/Helpers/Globals.swift @@ -12,9 +12,9 @@ import UIKit public final class Globals { public static let bundleIdentifier: String = { #if BETA - return "me.mssun.passforiosbeta" + return "org.lysanntranvouez.passforiosbeta" #else - return "me.mssun.passforios" + return "org.lysanntranvouez.passforios" #endif }() diff --git a/passShortcuts/passBetaShortcuts.entitlements b/passShortcuts/passBetaShortcuts.entitlements index 9bcfd12..b6ffc4a 100644 --- a/passShortcuts/passBetaShortcuts.entitlements +++ b/passShortcuts/passBetaShortcuts.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.me.mssun.passforiosbeta + group.org.lysanntranvouez.passforiosbeta keychain-access-groups - $(AppIdentifierPrefix)group.me.mssun.passforiosbeta + $(AppIdentifierPrefix)group.org.lysanntranvouez.passforiosbeta diff --git a/passShortcuts/passShortcuts.entitlements b/passShortcuts/passShortcuts.entitlements index 13c5967..a844039 100644 --- a/passShortcuts/passShortcuts.entitlements +++ b/passShortcuts/passShortcuts.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.me.mssun.passforios + group.org.lysanntranvouez.passforios keychain-access-groups - $(AppIdentifierPrefix)group.me.mssun.passforios + $(AppIdentifierPrefix)group.org.lysanntranvouez.passforios From c30e1922f1d7d578548c303d14453f204a66200f Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Sun, 8 Mar 2026 21:09:00 +0100 Subject: [PATCH 02/42] feature implementation plans --- plans/02-multi-recipient-encryption-plan.md | 192 +++++++++ plans/03-multi-store-plan.md | 423 ++++++++++++++++++++ 2 files changed, 615 insertions(+) create mode 100644 plans/02-multi-recipient-encryption-plan.md create mode 100644 plans/03-multi-store-plan.md diff --git a/plans/02-multi-recipient-encryption-plan.md b/plans/02-multi-recipient-encryption-plan.md new file mode 100644 index 0000000..28defd4 --- /dev/null +++ b/plans/02-multi-recipient-encryption-plan.md @@ -0,0 +1,192 @@ +# Multi-Recipient Encryption Plan + +## Concept + +The `pass` password store format supports encrypting each password to multiple PGP keys via `.gpg-id` files (one key ID per line). This enables sharing a store with other users — each person imports the same git repository but decrypts with their own private key. When adding or editing a password, it must be encrypted to **all** key IDs listed in `.gpg-id`. + +The app currently has a setting (`isEnableGPGIDOn`) that reads `.gpg-id` for per-directory key selection, but it only supports a single key ID. This plan fixes every layer to support multiple recipients. + +This is standalone — it can be implemented before or after multi-store support. + +--- + +## Current State + +The codebase does **not** support encrypting to multiple public keys. Every layer assumes a single recipient: + +| Layer | Current state | What needs to change | +|-------|--------------|---------------------| +| `.gpg-id` file format | Supports multiple key IDs (one per line) | No change needed | +| `findGPGID(from:)` | Returns the **entire file as one trimmed string** — does not split by newline | Split by newline, return `[String]` | +| `PGPInterface.encrypt()` | Signature: `encrypt(plainData:keyID:)` — singular `keyID: String?` | Add `encrypt(plainData:keyIDs:[String])` or change `keyID` to `keyIDs: [String]?` | +| `GopenPGPInterface` | Creates a `CryptoKeyRing` with **one** public key | Add all recipient public keys to the keyring before encrypting | +| `ObjectivePGPInterface` | Passes `keyring.keys` (all keys, including private) — accidentally multi-recipient but not intentionally | Filter to only the specified public keys, pass those to `ObjectivePGP.encrypt()` | +| `PGPAgent.encrypt()` | Routes to a single key via `keyID: String` | Accept `[String]` and pass through to the interface | +| `PasswordStore.encrypt()` | Calls `findGPGID()` for a single key ID string | Call the updated `findGPGID()`, pass the key ID array | + +--- + +## Implementation + +### 1. `findGPGID(from:) -> [String]` + +Split file contents by newline, trim each line, filter empty lines. Return array of key IDs. Callers that only need a single key (e.g. for decryption routing) can use `.first`. + +### 2. `PGPInterface` protocol + +Change `encrypt(plainData:keyID:)` to `encrypt(plainData:keyIDs:)` where `keyIDs: [String]?`. When `nil`, encrypt to the first/default key (backward compatible). + +### 3. `GopenPGPInterface.encrypt()` + +Look up all keys matching the `keyIDs` array from `publicKeys`. Add each to the `CryptoKeyRing` (GopenPGP's `CryptoKeyRing` supports multiple keys via `add()`). Encrypt with the multi-key ring. + +### 4. `ObjectivePGPInterface.encrypt()` + +Filter `keyring.keys` to only the public keys matching the requested `keyIDs`. Pass the filtered array to `ObjectivePGP.encrypt()`. + +### 5. `PGPAgent.encrypt()` + +Update both overloads to accept `keyIDs: [String]?` and pass through to the interface. + +### 6. `PasswordStore.encrypt()` + +Call updated `findGPGID()`, pass the array to `PGPAgent`. + +--- + +## Public Key Management + +When a store lists multiple key IDs in `.gpg-id`, the user needs the public keys of all recipients. The user's own private key is sufficient for decryption (since the message is encrypted to all recipients), but all public keys are needed for re-encryption when editing. + +### Current state + +- The keychain holds exactly **one** `pgpPublicKey` blob and **one** `pgpPrivateKey` blob. +- The import UI (armor paste, URL, file picker) has one public key field + one private key field. Importing **replaces** the previous key pair entirely. +- Both `GopenPGPInterface` and `ObjectivePGPInterface` *can* parse multiple keys from a single armored blob (e.g. concatenated armor blocks). So if the user pastes multiple public keys into the single field, they would be parsed — but the encrypt path only uses one key, and the UI doesn't communicate this. +- There is no UI for viewing which key IDs are loaded. + +### Key storage approach + +Store all public keys as a single concatenated armored blob in the keychain (`pgpPublicKey`). Both interface implementations already parse multi-key blobs into dictionaries/keyrings. This avoids schema changes — we just need to **append** instead of **replace** when importing additional public keys. + +The user's own private key stays as a separate single blob (`pgpPrivateKey`). + +### 7. UI: Import additional recipient public keys + +Add an "Import Recipient Key" action to the PGP key settings (alongside the existing import that sets the user's own key pair). This flow: + +- Imports a public-key-only armored blob +- **Appends** it to the existing `pgpPublicKey` keychain entry (concatenating armored blocks) +- Does **not** touch the private key +- On success, shows the newly imported key ID(s) + +The existing import flow ("Set PGP Keys") continues to replace the user's own key pair (public + private). + +### 8. UI: View loaded key IDs and metadata + +PGP keys carry a **User ID** field, typically in the format `"Name "`. Both GopenPGP (`key.entity.PrimaryIdentity()`) and ObjectivePGP (`key.keyID` + user ID packets) can access this. The app currently doesn't expose it. + +Add key metadata to the `PGPInterface` protocol: + +```swift +struct PGPKeyInfo { + let fingerprint: String // full fingerprint + let shortKeyID: String // last 8 hex chars + let userID: String? // "Name " from the primary identity + let isPrivate: Bool // has a matching private key + let isExpired: Bool + let isRevoked: Bool +} +var keyInfo: [PGPKeyInfo] { get } +``` + +Both `GopenPGPInterface` and `ObjectivePGPInterface` should implement this by iterating their loaded keys. + +Add a read-only section to the PGP key settings showing all loaded public keys. Each row shows: + +- **User ID** (e.g. `"Alice "`) as the primary label — this is the human-readable identifier +- **Short key ID** (e.g. `ABCD1234`) as the secondary label +- Badge/icon if it's the user's own key (has matching private key) vs a recipient-only key +- Badge/icon if expired or revoked +- Swipe-to-delete to remove a recipient public key + +This also informs the `.gpg-id` editing UI (§9) — when the user adds/removes recipients from `.gpg-id`, they see names and emails, not just opaque hex key IDs. + +### 9. UI: View/edit `.gpg-id` files + +When `isEnableGPGIDOn` is enabled, add visibility into `.gpg-id`: + +- In the password detail view, show which key IDs the password is encrypted to (from the nearest `.gpg-id` file) +- In folder navigation, show an indicator on directories that have their own `.gpg-id` +- Tapping the indicator shows the `.gpg-id` contents (list of key IDs) with an option to edit +- Editing `.gpg-id` triggers re-encryption of all passwords in the directory (see §10) + +Note: Viewing `.gpg-id` is low-effort and high-value. Editing is more complex due to re-encryption. These can be split into separate steps. + +### 10. Re-encryption when `.gpg-id` changes + +When the user edits a `.gpg-id` file (adding/removing a recipient), all `.gpg` files in that directory (and subdirectories without their own `.gpg-id`) must be re-encrypted to the new recipient list. This is equivalent to `pass init -p subfolder KEY1 KEY2`. + +Steps: +1. Write the new `.gpg-id` file +2. For each `.gpg` file under the directory: + - Decrypt with the user's private key + - Re-encrypt to the new recipient list + - Overwrite the `.gpg` file +3. Git add all changed files + `.gpg-id` +4. Git commit + +This can be expensive for large directories. Show progress and allow cancellation. + +--- + +## Implementation Order + +| Step | Description | Depends On | +|------|-------------|------------| +| 1 | `findGPGID` returns `[String]` + update callers | — | +| 2 | `PGPInterface` protocol change (`keyIDs: [String]?`) | — | +| 3 | `GopenPGPInterface` multi-key encryption | Step 2 | +| 4 | `ObjectivePGPInterface` multi-key encryption | Step 2 | +| 5 | `PGPAgent` updated overloads | Steps 2-4 | +| 6 | `PasswordStore.encrypt()` uses `[String]` from `findGPGID` | Steps 1+5 | +| 7 | UI: import additional recipient public keys | Step 5 | +| 8 | UI: view loaded key IDs | Step 5 | +| 9a | UI: view `.gpg-id` in password detail / folder view | Step 1 | +| 9b | UI: edit `.gpg-id` | Step 9a | +| 10 | Re-encryption when `.gpg-id` changes | Steps 6+9b | +| T | Tests (see testing section) | Steps 1-10 | + +--- + +## Testing + +### Pre-work: existing encryption tests + +The `PGPAgentTest` already covers single-key encrypt/decrypt with multiple key types. These serve as the regression baseline. + +### Multi-recipient encryption tests + +- **Test `findGPGID` with multi-line `.gpg-id`**: File with two key IDs on separate lines → returns `[String]` with both. +- **Test `findGPGID` with single-line `.gpg-id`**: Backward compatible → returns `[String]` with one element. +- **Test `findGPGID` with empty lines and whitespace**: Trims and filters correctly. +- **Test `GopenPGPInterface.encrypt` with multiple keys**: Encrypt with two public keys → decrypt succeeds with either private key. +- **Test `ObjectivePGPInterface.encrypt` with multiple keys**: Same as above. +- **Test `PGPAgent.encrypt` with `keyIDs` array**: Routes through correctly to the interface. +- **Test round-trip**: Encrypt with key IDs `[A, B]` → user with private key A can decrypt, user with private key B can decrypt. +- **Test encrypt with single keyID still works**: Backward compatibility — `keyIDs: ["X"]` behaves like the old `keyID: "X"`. +- **Test encrypt with unknown keyID in list**: If one of the key IDs is not in the keyring, appropriate error is thrown. +- **Test multi-key public key import**: Import an armored blob containing multiple public keys → all are available for encryption. + +### Key management tests + +- **Test appending recipient public key**: Import user's key pair → append a second public key → both key IDs are available. Original private key still works for decryption. +- **Test removing a recipient public key**: Remove one public key from the concatenated blob → only the remaining key IDs are available. +- **Test replacing key pair doesn't lose recipient keys**: Import user's key pair → add recipient key → re-import user's key pair → recipient key is still present (or: design decision — should re-import clear everything?). + +### `.gpg-id` and re-encryption tests + +- **Test re-encryption**: Edit `.gpg-id` to add a recipient → all passwords in directory are re-encrypted → new recipient can decrypt. +- **Test re-encryption removes access**: Edit `.gpg-id` to remove a recipient → re-encrypted passwords cannot be decrypted with the removed key. +- **Test `.gpg-id` directory scoping**: Subdirectory `.gpg-id` overrides parent. Passwords in subdirectory use subdirectory's recipients. +- **Test multi-key public key import**: Import an armored blob containing multiple public keys → all are available for encryption. diff --git a/plans/03-multi-store-plan.md b/plans/03-multi-store-plan.md new file mode 100644 index 0000000..11541b2 --- /dev/null +++ b/plans/03-multi-store-plan.md @@ -0,0 +1,423 @@ +# Multi-Store Support — Implementation Plan + +## Concept + +Each **store** is an independent password repository with its own git remote, credentials, branch, and (optionally) its own PGP key pair. Users can enable/disable individual stores for the password list and separately for AutoFill. Stores can be shared between users who each decrypt with their own key (leveraging the existing `.gpg-id` per-directory mechanism from `pass`). + +--- + +## Phase 1: Improve Test Coverage Before Refactoring + +See [01-improve-test-coverage-plan.md](01-improve-test-coverage-plan.md). This is standalone and should be done before any refactoring to catch regressions. + +--- + +## Phase 2: Data Model — `StoreConfiguration` + +Create a new persistent model for store definitions. This is the foundation everything else builds on. + +### 2.1 Define `StoreConfiguration` as a Core Data entity + +→ Testing: [T1 — `StoreConfiguration` entity tests](#t1-storeconfiguration-entity-tests) + +Add a `StoreConfiguration` entity to the existing Core Data model (`pass.xcdatamodeld`), with attributes: + +- `id: UUID` — unique identifier +- `name: String` — display name (e.g. "Personal", "Work") +- `gitURL: URI` (stored as String) +- `gitBranchName: String` +- `gitAuthenticationMethod: String` (raw value of `GitAuthenticationMethod`) +- `gitUsername: String` +- `pgpKeySource: String?` (raw value of `KeySource`) +- `isVisibleInPasswords: Bool` — shown in the password list +- `isVisibleInAutoFill: Bool` — shown in AutoFill +- `sortOrder: Int16` — for user-defined ordering +- `lastSyncedTime: Date?` + +Relationship: `passwords` → to-many `PasswordEntity` (inverse: `store`; cascade delete rule — deleting a store removes all its password entities). + +Using Core Data instead of a separate JSON file because: +- The Core Data stack already exists and is shared across all targets via the app group +- The `StoreConfiguration` ↔ `PasswordEntity` relationship gives referential integrity and cascade deletes for free +- No second persistence mechanism to maintain +- Built-in concurrency/conflict handling + +### 2.2 Define `StoreConfigurationManager` + +→ Testing: [T1 — `StoreConfiguration` entity tests](#t1-storeconfiguration-entity-tests), [T3 — `PasswordStoreManager` tests](#t3-passwordstoremanager-tests) + +Manages the list of stores via Core Data. Provides CRUD, reordering, and lookup by ID. Observable (via `NotificationCenter` or Combine) so UI updates when stores change. + +### 2.3 Migration from single-store + +→ Testing: [T2 — Migration tests](#t2-migration-tests) + +On first launch after upgrade, create a single `StoreConfiguration` from the current `Defaults.*` values and keychain entries. Assign all existing `PasswordEntity` rows to this store. Existing users see no change. + +This is a Core Data model version migration: add the `StoreConfiguration` entity, add the `store` relationship to `PasswordEntity`, and populate it in a post-migration step. + +### 2.4 Per-store secrets + +→ Testing: [T5 — Per-store keychain namespace tests](#t5-per-store-keychain-namespace-tests) + +Per-store secrets go in the keychain with namespaced keys: + +- `"{storeID}.gitPassword"`, `"{storeID}.gitSSHPrivateKeyPassphrase"`, `"{storeID}.sshPrivateKey"` +- `"{storeID}.pgpPublicKey"`, `"{storeID}.pgpPrivateKey"` +- The existing `"pgpKeyPassphrase-{keyID}"` scheme already works across stores since it's keyed by PGP key ID. + +--- + +## Phase 3: De-singleton the Backend + +The most invasive but essential change. Requires careful sequencing. + +### 3.1 Parameterize `Globals` paths + +Add a method to compute the per-store repository directory: + +- `repositoryURL(for storeID: UUID) -> URL` — e.g. `Library/password-stores/{storeID}/` + +The database path (`dbPath`) stays single since we use one Core Data database with a relationship. + +### 3.2 Make `PasswordStore` non-singleton + +→ Testing: [T3 — `PasswordStoreManager` tests](#t3-passwordstoremanager-tests), [T4 — Per-store `PasswordStore` tests](#t4-per-store-passwordstore-tests) + +Convert to a class that takes a `StoreConfiguration` at init: + +- Each instance owns its own `storeURL`, `gitRepository`, `context` +- Inject `StoreConfiguration` (for git URL, branch, credentials) and a `PGPAgent` instance +- Keep a **`PasswordStoreManager`** that holds all active `PasswordStore` instances (keyed by store ID), lazily creating them +- `PasswordStoreManager` replaces all `PasswordStore.shared` call sites + +### 3.3 Core Data: `PasswordEntity` ↔ `StoreConfiguration` relationship + +→ Testing: [T1 — `StoreConfiguration` entity tests](#t1-storeconfiguration-entity-tests), [T6 — `PasswordEntity` fetch filtering tests](#t6-passwordentity-fetch-filtering-tests) + +Add a `store` relationship (to-one) on `PasswordEntity` pointing to `StoreConfiguration` (inverse: `passwords`, to-many, cascade delete). This replaces the need for a separate `storeID` UUID attribute — the relationship provides referential integrity and cascade deletes. + +All `PasswordEntity` fetch requests must be updated to filter by store (or by set of visible stores for the password list / AutoFill). The `initPasswordEntityCoreData(url:in:)` method already takes a URL parameter; pass the per-store URL and set the `store` relationship on each created entity. + +### 3.4 Make `PGPAgent` per-store + +→ Testing: [T4 — Per-store `PasswordStore` tests](#t4-per-store-passwordstore-tests) (encrypt/decrypt with per-store keys) + +Remove the singleton. `PasswordStore` instances each hold an optional `PGPAgent`. Stores sharing the same PGP key pair just load the same keychain entries. Stores using different keys load different ones. The `KeyStore` protocol already supports this — just pass different key names. + +### 3.5 Make `GitCredential` per-store + +→ Testing: [T5 — Per-store keychain namespace tests](#t5-per-store-keychain-namespace-tests) + +Already not a singleton, just reads from `Defaults`. Change it to read from `StoreConfiguration` + namespaced keychain keys instead. + +--- + +## Phase 4: Settings UI — Store Management + +### 4.1 New "Stores" settings section + +Replace the current single "Password Repository" and "PGP Key" rows with a section listing all configured stores, plus an "Add Store" button: + +- Each store row shows: name, git host, sync status indicator +- Tapping a store opens `StoreSettingsTableViewController` +- Swipe-to-delete removes a store (with confirmation) +- Drag-to-reorder for sort order + +### 4.2 `StoreSettingsTableViewController` + +Per-store settings screen: + +- Store name (editable text field) +- **Repository section**: Git URL, branch, username, auth method (reuse existing `GitRepositorySettingsTableViewController` logic, but scoped to this store's config) +- **PGP Key section**: Same import options as today but scoped to this store's keychain namespace. Add an option "Use same key as [other store]" for convenience. +- **Visibility section**: Two toggles — "Show in Passwords", "Show in AutoFill" +- **Sync section**: Last synced time, manual sync button +- **Danger zone**: Delete store (see §4.4 for full cleanup steps) + +### 4.3 Migrate existing settings screens + +`GitRepositorySettingsTableViewController`, `PGPKeyArmorImportTableViewController`, etc. currently read/write global `Defaults`. Refactor them to accept a `StoreConfiguration` and read/write to that store's Core Data entity and namespaced keychain keys instead. + +### 4.4 Store lifecycle: adding a store + +→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) + +Currently, configuring git settings triggers a clone immediately (`GitRepositorySettingsTableViewController.save()` → `cloneAndSegueIfSuccess()`), and the clone rebuilds Core Data from the filesystem. The multi-store equivalent: + +1. User taps "Add Store" → presented with `StoreSettingsTableViewController` +2. User fills in store name, git URL, branch, username, auth method +3. User imports PGP keys (public + private) for this store +4. User taps "Save" → creates a `StoreConfiguration` entity in Core Data +5. Clone is triggered for this store: + - Compute per-store repo directory: `Library/password-stores/{storeID}/` + - Call `PasswordStore.cloneRepository()` scoped to that directory + - On success: BFS-walk the cloned repo, create `PasswordEntity` rows linked to this `StoreConfiguration` via the `store` relationship + - On success: validate `.gpg-id` exists (warn if missing, since decryption will fail) + - On failure: delete the `StoreConfiguration` entity (cascade deletes any partial `PasswordEntity` rows), clean up the repo directory, remove keychain entries for this store ID +6. Post `.passwordStoreUpdated` notification so the password list refreshes + +### 4.5 Store lifecycle: removing a store + +→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) + +Currently `erase()` nukes everything globally. Per-store removal must be scoped: + +1. User confirms deletion (destructive action sheet) +2. Cleanup steps: + - Delete the repo directory: `Library/password-stores/{storeID}/` (rm -rf) + - Delete `StoreConfiguration` entity from Core Data → cascade-deletes all linked `PasswordEntity` rows automatically + - Remove namespaced keychain entries: `"{storeID}.gitPassword"`, `"{storeID}.gitSSHPrivateKeyPassphrase"`, `"{storeID}.sshPrivateKey"`, `"{storeID}.pgpPublicKey"`, `"{storeID}.pgpPrivateKey"` + - Drop the in-memory `PasswordStore` instance from `PasswordStoreManager` + - Post `.passwordStoreUpdated` so the password list refreshes +3. PGP key passphrase entries (`"pgpKeyPassphrase-{keyID}"`) may be shared with other stores using the same key — only remove if no other store references that key ID + +### 4.6 Store lifecycle: re-cloning / changing git URL + +→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) + +When the user changes the git URL or branch of an existing store (equivalent to today's "overwrite" flow): + +1. Delete the existing repo directory for this store +2. Delete all `PasswordEntity` rows linked to this `StoreConfiguration` (but keep the `StoreConfiguration` entity itself) +3. Clone the new repo into the store's directory +4. Rebuild `PasswordEntity` rows from the new clone, linked to the same `StoreConfiguration` +5. Clear and re-prompt for git credentials + +### 4.7 Global "Erase all data" + +→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) (test global erase) + +The existing "Erase Password Store Data" action in Advanced Settings should: + +1. Delete all `StoreConfiguration` entities (cascade-deletes all `PasswordEntity` rows) +2. Delete all repo directories under `Library/password-stores/` +3. Remove all keychain entries (`AppKeychain.shared.removeAllContent()`) +4. Clear all UserDefaults (`Defaults.removeAll()`) +5. Clear passcode, uninit all PGP agents, drop all `PasswordStore` instances +6. Post `.passwordStoreErased` + +--- + +## Phase 5: Password List UI — Multi-Store Browsing + +### 5.1 Unified password list + +`PasswordNavigationViewController` should show passwords from all visible stores together: + +- **Folder mode**: Add a top-level grouping by store name, then the folder hierarchy within each store. The store name row could have a distinct style (e.g. bold, with a colored dot or icon). +- **Flat mode**: Show all passwords from all visible stores. Subtitle or accessory showing which store each password belongs to. +- **Search**: Searches across all visible stores simultaneously. Results annotated with store name. + +### 5.2 Password detail + +`PasswordDetailTableViewController` needs to know which store a password belongs to (to decrypt with the right `PGPAgent` and write changes back to the right repo). Pass the store context through from the list. + +### 5.3 Add password flow + +`AddPasswordTableViewController` needs a store picker if multiple stores are visible. Default to a "primary" store or the last-used one. + +### 5.4 Sync + +→ Testing: [T9 — Sync tests](#t9-sync-tests) + +Pull-to-refresh in the password list syncs all visible stores (sequentially or in parallel). Show per-store sync status. Allow syncing individual stores from their settings or via long-press. + +--- + +## Phase 6: AutoFill Extension + +### 6.1 Multi-store AutoFill + +→ Testing: [T8 — AutoFill multi-store tests](#t8-autofill-multi-store-tests) + +`CredentialProviderViewController`: + +- Fetch passwords from all stores where `isVisibleInAutoFill == true` +- The "Suggested" section should search across all AutoFill-visible stores +- Each password entry carries its store context for decryption +- No store picker needed — just include all enabled stores transparently +- Consider showing store name in the cell subtitle for disambiguation + +### 6.2 QuickType integration + +→ Testing: [T8 — AutoFill multi-store tests](#t8-autofill-multi-store-tests) (store ID in `recordIdentifier`) + +`provideCredentialWithoutUserInteraction` needs to try the right store's PGP agent for decryption. Since it gets a `credentialIdentity` (which contains a `recordIdentifier` = password path), the path must now encode or be mappable to a store ID. + +--- + +## Phase 7: Extensions & Shortcuts + +### 7.1 passExtension (share extension) + +Same multi-store search as AutoFill. Minor. + +### 7.2 Shortcuts + +`SyncRepositoryIntentHandler`: + +- Add a store parameter to the intent (optional — if nil, sync all stores) +- Register each store as a Shortcut parameter option +- Support "Sync All" and "Sync [store name]" + +--- + +## Phase 8: Multi-Recipient Encryption + +See [02-multi-recipient-encryption-plan.md](02-multi-recipient-encryption-plan.md). This is standalone and can be implemented before or after multi-store support. In a multi-store context, `isEnableGPGIDOn` becomes a per-store setting. + +--- + +## Implementation Order + +| Step | Phase | Description | Depends On | +|------|-------|-------------|------------| +| 1 | 1 | Improve test coverage (see [separate plan](01-improve-test-coverage-plan.md)) | — | +| 2a | 2 | `StoreConfiguration` Core Data entity + relationship to `PasswordEntity` + model migration | Phase 1 | +| 2b | 2 | `StoreConfigurationManager` + single-store migration from existing Defaults/keychain | Step 2a | +| 2t | T | Tests: `StoreConfiguration` CRUD, cascade delete, migration (T1, T2) | Steps 2a+2b | +| 3a | 3 | Parameterize `Globals` paths (per-store repo directory) | Step 2a | +| 3b | 3 | Namespace keychain keys per store | Step 2a | +| 3bt | T | Tests: per-store keychain namespace (T5) | Step 3b | +| 3c | 3 | De-singleton `PGPAgent` | Steps 2a+3a+3b | +| 3d | 3 | De-singleton `PasswordStore` → `PasswordStoreManager` | Steps 2b-3c | +| 3dt | T | Tests: `PasswordStoreManager`, per-store `PasswordStore`, entity filtering (T3, T4, T6) | Step 3d | +| 3e | 3 | Per-store `GitCredential` | Steps 3b+3d | +| 3f | 3 | Store lifecycle: add/clone, remove/cleanup, re-clone, global erase | Steps 3d+3e | +| 3ft | T | Tests: store lifecycle integration (T7) | Step 3f | +| 4a | 4 | Store management UI (add/edit/delete/reorder) | Step 3f | +| 4b | 4 | Migrate existing settings screens to per-store | Step 4a | +| 5a | 5 | Multi-store password list | Step 3d | +| 5b | 5 | Multi-store add/edit/detail | Step 5a | +| 5c | 5 | Multi-store sync | Steps 3e+5a | +| 5ct | T | Tests: sync (T9) | Step 5c | +| 6a | 6 | Multi-store AutoFill | Step 3d | +| 6t | T | Tests: AutoFill multi-store (T8) | Step 6a | +| 7a | 7 | Multi-store Shortcuts | Step 3d | +| 8a | 8 | Multi-recipient encryption (see [separate plan](02-multi-recipient-encryption-plan.md)) | Step 3d | + +--- + +## Testing Plan + +For baseline test coverage of existing code, see [01-improve-test-coverage-plan.md](01-improve-test-coverage-plan.md). + +### Testing new multi-store code + +#### T1: `StoreConfiguration` entity tests + +- **Test CRUD**: Create, read, update, delete `StoreConfiguration` entities. +- **Test cascade delete**: Delete a `StoreConfiguration` → verify all linked `PasswordEntity` rows are deleted. +- **Test relationship integrity**: Create `PasswordEntity` rows linked to a store → verify fetching by store returns the right entities. +- **Test `StoreConfigurationManager`**: Create, list, reorder, delete stores via the manager. + +#### T2: Migration tests + +- **Test fresh install**: No existing data → no `StoreConfiguration` created, app works. +- **Test upgrade migration from single-store**: + 1. Set up a pre-migration Core Data database (using the old model version) with `PasswordEntity` rows, populate `Defaults` with git URL/branch/username, and populate keychain with PGP + SSH keys. + 2. Run the migration. + 3. Verify: one `StoreConfiguration` exists with values from Defaults, all `PasswordEntity` rows are linked to it, keychain entries are namespaced under the new store's ID. +- **Test idempotency**: Running migration twice doesn't create duplicate stores. +- **Test migration with empty repo** (no passwords, just settings): Still creates a `StoreConfiguration`. + +#### T3: `PasswordStoreManager` tests + +- **Test store lookup by ID**. +- **Test lazy instantiation**: Requesting a store creates `PasswordStore` on demand. +- **Test listing visible stores** (filtered by `isVisibleInPasswords` / `isVisibleInAutoFill`). +- **Test adding/removing stores updates the manager**. + +#### T4: Per-store `PasswordStore` tests + +- **Test clone scoped to per-store directory**: Clone into `Library/password-stores/{storeID}/`, verify `PasswordEntity` rows are linked to the right `StoreConfiguration`. +- **Test two stores independently**: Clone two different repos, verify each store's entities are separate, deleting one doesn't affect the other. +- **Test `eraseStoreData` scoped to one store**: Only that store's directory and entities are deleted. +- **Test encrypt/decrypt with per-store PGP keys**: Store A uses key pair X, store B uses key pair Y, each can only decrypt its own passwords. +- **Test store sharing one PGP key pair**: Two stores referencing the same keychain entries both decrypt correctly. + +#### T5: Per-store keychain namespace tests + +- **Test namespaced keys don't collide**: Store A's `"{A}.gitPassword"` and store B's `"{B}.gitPassword"` are independent. +- **Test `removeAllContent(withPrefix:)`**: Removing store A's keys doesn't affect store B's. +- **Test `pgpKeyPassphrase-{keyID}`** shared across stores using the same key. + +#### T6: `PasswordEntity` fetch filtering tests + +- **Test `fetchAll` filtered by one store**. +- **Test `fetchAll` filtered by multiple visible stores** (the AutoFill / password list scenario). +- **Test `fetchUnsynced` filtered by store**. +- **Test search across multiple stores**. + +#### T7: Store lifecycle integration tests + +- **Test add store flow**: Create config → clone → BFS walk → entities linked → notification posted. +- **Test remove store flow**: Delete config → cascade deletes entities → repo directory removed → keychain cleaned → notification posted. +- **Test re-clone flow**: Change git URL → old entities deleted → new clone → new entities → same `StoreConfiguration`. +- **Test global erase**: Multiple stores → all gone. +- **Test clone failure cleanup**: Clone fails → `StoreConfiguration` deleted → no orphan entities or directories. + +#### T8: AutoFill multi-store tests + +- **Test credential listing from multiple stores**: Entries from all AutoFill-visible stores appear. +- **Test store ID encoded in `recordIdentifier`**: Can map a credential identity back to the correct store for decryption. +- **Test filtering**: Only `isVisibleInAutoFill == true` stores appear. + +#### T9: Sync tests + +- **Test pull updates one store's entities without affecting others**. +- **Test sync-all triggers pull for each visible store**. + +### Test infrastructure additions needed + +- **Multi-store `CoreDataTestCase`**: Extend `CoreDataTestCase` to support the new model version with `StoreConfiguration`. Provide a helper to create a `StoreConfiguration` + linked entities in one call. +- **Pre-migration database fixture**: A snapshot of the old Core Data model (without `StoreConfiguration`) to use in migration tests. Can be a `.sqlite` file committed to the test bundle. + +--- + +## Risks & Considerations + +- **Data migration**: Existing users must be migrated seamlessly. The migration (steps 2a-2b) should be idempotent and tested thoroughly. +- **Core Data migration**: Adding the `StoreConfiguration` entity and the `store` relationship on `PasswordEntity` requires a lightweight migration (new entity + new optional relationship). The post-migration step creates a default `StoreConfiguration` from existing Defaults and assigns all existing `PasswordEntity` rows to it. +- **Memory**: Multiple `PasswordStore` instances each holding a `GTRepository` and `PGPAgent` — lazy instantiation is important. Only active/visible stores should be loaded. +- **Concurrency**: Git operations (pull/push) across multiple stores should not block each other. Use per-store serial queues. +- **AutoFill performance**: The extension has strict memory limits (~30MB). Loading all stores' Core Data is fine (single DB), but loading multiple PGP agents may be expensive. Decrypt lazily, only when the user selects a password. +- **Backward compatibility**: Older versions won't understand the new data layout. Consider a one-way migration flag. + +--- + +## Context + +### Prompt + +I want to add support for several separate password repositories, each with a unique repository connection (url, authnetication), and potentially separate encryption/decryption keys. + +Another GUI app that supports this is QtPass. There is information about this its readme: https://raw.githubusercontent.com/IJHack/QtPass/refs/heads/main/README.md +It calls it "profiles". I would probably call it "stores". + +I want to be able to configure which stores are enabled when I view the list, and separately also for the autofill feature. + +It should be possible to share a store with another user (who would be using a separate key on their end). + +Make a plan for what needs to be done to support this in this application. + +### Key Architecture Facts +- `PasswordStore.shared` singleton referenced from ~20+ call sites (app, AutoFill, passExtension, Shortcuts) +- `PGPAgent.shared` singleton holds single key pair +- `Globals` has all paths as `static let` (single repo, single DB, single key paths) +- `DefaultsKeys` — all git/PGP settings single-valued in shared UserDefaults +- `AppKeychain.shared` — flat keys, no per-store namespace +- Core Data: single `PasswordEntity` entity, no store discriminator, single SQLite DB +- `PersistenceController.shared` — single NSPersistentContainer +- UI: UITabBarController with 2 tabs (Passwords, Settings). Passwords tab uses PasswordNavigationViewController +- AutoFill: CredentialProviderViewController uses PasswordStore.shared directly +- App group + keychain group shared across all targets +- `.gpg-id` per-directory key selection already exists (closest to multi-key concept) +- QtPass calls them "profiles" — each can have different git repo and GPG key + +### User Requirements +- Multiple password stores, each with unique repo connection (URL, auth) and potentially separate PGP keys +- Call them "stores" (not profiles) +- Configure which stores are visible in password list vs AutoFill separately +- Support sharing a store with another user (who uses a different key) From 17b6bb8bc235d94d7a8f902e666d1ec53d66597c Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Sun, 8 Mar 2026 22:05:58 +0100 Subject: [PATCH 03/42] fix test cleanup --- passKitTests/Models/PasswordStoreTest.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index b65bf95..c6550e8 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -19,7 +19,13 @@ final class PasswordStoreTest: XCTestCase { let url = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") Defaults.isEnableGPGIDOn = true + defer { + Defaults.isEnableGPGIDOn = false + } let passwordStore = PasswordStore(url: url) + defer { + passwordStore.erase() + } try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: url.path) }, evaluatedWith: nil) waitForExpectations(timeout: 3, handler: nil) @@ -47,9 +53,6 @@ final class PasswordStoreTest: XCTestCase { let testPasswordEntity = try passwordStore.add(password: testPassword)! let testPasswordPlain = try passwordStore.decrypt(passwordEntity: testPasswordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) XCTAssertEqual(testPasswordPlain.plainText, "testpassword") - - passwordStore.erase() - Defaults.isEnableGPGIDOn = false } private func decrypt(passwordStore: PasswordStore, path: String, passphrase _: String) throws -> Password { From 85972a02c350e7c13f9cf46c8c86963ff5537abd Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Sun, 8 Mar 2026 22:56:21 +0100 Subject: [PATCH 04/42] include repo as text fixture, no need to clone from actual github --- pass.xcodeproj/project.pbxproj | 4 ++++ .../Fixtures/password-store.git/FETCH_HEAD | 1 + passKitTests/Fixtures/password-store.git/HEAD | 1 + passKitTests/Fixtures/password-store.git/config | 9 +++++++++ .../Fixtures/password-store.git/description | 1 + .../Fixtures/password-store.git/info/exclude | 6 ++++++ ...6a8dbb253e7642cc425de97363624aab04882615.idx | Bin 0 -> 2724 bytes ...a8dbb253e7642cc425de97363624aab04882615.pack | Bin 0 -> 15331 bytes .../Fixtures/password-store.git/packed-refs | 2 ++ .../refs/remotes/origin/master | 1 + passKitTests/Models/PasswordStoreTest.swift | 3 +-- 11 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 passKitTests/Fixtures/password-store.git/FETCH_HEAD create mode 100644 passKitTests/Fixtures/password-store.git/HEAD create mode 100644 passKitTests/Fixtures/password-store.git/config create mode 100644 passKitTests/Fixtures/password-store.git/description create mode 100644 passKitTests/Fixtures/password-store.git/info/exclude create mode 100644 passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx create mode 100644 passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack create mode 100644 passKitTests/Fixtures/password-store.git/packed-refs create mode 100644 passKitTests/Fixtures/password-store.git/refs/remotes/origin/master diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index 807205a..e3e63e8 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -114,6 +114,7 @@ 5F9D7B0D27AF6F7500A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0E27AF6FCA00A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; + 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 8AD8EBF22F5E268D007475AB /* Fixtures */; }; 9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; }; 9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; }; 9A1F47FA26E5CF4B000C0E01 /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1F47F926E5CF4B000C0E01 /* OneTimePassword */; }; @@ -422,6 +423,7 @@ 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; }; + 8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = ""; }; 9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = ""; }; 9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = ""; }; 9A1EF0B524C50EE00074FEAC /* passBetaExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaExtension.entitlements; sourceTree = ""; }; @@ -883,6 +885,7 @@ 30A86F93230F235800F821A4 /* Crypto */, 30BAC8C322E3BA4300438475 /* Testbase */, 30697C5521F63F870064FCAC /* Extensions */, + 8AD8EBF22F5E268D007475AB /* Fixtures */, 301F6464216164670071A4CE /* Helpers */, 30C015A7214ED378005BB6DF /* Models */, 30C015A6214ED32A005BB6DF /* Parser */, @@ -1436,6 +1439,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/passKitTests/Fixtures/password-store.git/FETCH_HEAD b/passKitTests/Fixtures/password-store.git/FETCH_HEAD new file mode 100644 index 0000000..ef06926 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/FETCH_HEAD @@ -0,0 +1 @@ +925eb0f6b19282b5f10dfe008e0062b4be6dd41a not-for-merge branch 'master' of https://github.com/mssun/passforios-password-store diff --git a/passKitTests/Fixtures/password-store.git/HEAD b/passKitTests/Fixtures/password-store.git/HEAD new file mode 100644 index 0000000..cb089cd --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/passKitTests/Fixtures/password-store.git/config b/passKitTests/Fixtures/password-store.git/config new file mode 100644 index 0000000..876f087 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/config @@ -0,0 +1,9 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + ignorecase = true + precomposeunicode = true +[remote "origin"] + url = https://github.com/mssun/passforios-password-store.git + fetch = +refs/heads/*:refs/remotes/origin/* diff --git a/passKitTests/Fixtures/password-store.git/description b/passKitTests/Fixtures/password-store.git/description new file mode 100644 index 0000000..9258d15 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/description @@ -0,0 +1 @@ +Example password store repository for passforios tests. diff --git a/passKitTests/Fixtures/password-store.git/info/exclude b/passKitTests/Fixtures/password-store.git/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx b/passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx new file mode 100644 index 0000000000000000000000000000000000000000..7efd056d551ebf17f65647ce79e30d8731c56274 GIT binary patch literal 2724 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 AY5)KL literal 0 HcmV?d00001 diff --git a/passKitTests/Fixtures/password-store.git/packed-refs b/passKitTests/Fixtures/password-store.git/packed-refs new file mode 100644 index 0000000..5b72267 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/packed-refs @@ -0,0 +1,2 @@ +# pack-refs with: peeled fully-peeled sorted +925eb0f6b19282b5f10dfe008e0062b4be6dd41a refs/heads/master diff --git a/passKitTests/Fixtures/password-store.git/refs/remotes/origin/master b/passKitTests/Fixtures/password-store.git/refs/remotes/origin/master new file mode 100644 index 0000000..7d10008 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/refs/remotes/origin/master @@ -0,0 +1 @@ +925eb0f6b19282b5f10dfe008e0062b4be6dd41a diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index c6550e8..3f1363d 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -13,9 +13,8 @@ import XCTest @testable import passKit final class PasswordStoreTest: XCTestCase { - private let remoteRepoURL = URL(string: "https://github.com/mssun/passforios-password-store.git")! - func testCloneAndDecryptMultiKeys() throws { + let remoteRepoURL = Bundle(for: type(of: self)).resourceURL!.appendingPathComponent("Fixtures/password-store.git") let url = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") Defaults.isEnableGPGIDOn = true From ef188fcfba7604a52847d266e423b74347d1c0d1 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 00:17:31 +0100 Subject: [PATCH 05/42] basic core data tests upon clone --- passKitTests/Models/PasswordStoreTest.swift | 56 +++++++++++++++++---- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 3f1363d..40357fa 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -13,27 +13,55 @@ import XCTest @testable import passKit final class PasswordStoreTest: XCTestCase { + private let remoteRepoURL: URL = Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store.git") + private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") + + private var passwordStore: PasswordStore! = nil + + override func setUp() { + passwordStore = PasswordStore(url: localRepoURL) + } + + override func tearDown() { + passwordStore.erase() + passwordStore = nil + } + + func testInitPasswordEntityCoreData() throws { + try cloneRepository() + + XCTAssertEqual(passwordStore.numberOfPasswords, 4) + + let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") + XCTAssertEqual(entity!.path, "personal/github.com.gpg") + XCTAssertEqual(entity!.name, "github.com") + XCTAssertTrue(entity!.isSynced) + XCTAssertEqual(entity!.parent!.name, "personal") + + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "family/amazon.com.gpg")) + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "work/github.com.gpg")) + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "shared/github.com.gpg")) + + let dirEntity = passwordStore.fetchPasswordEntity(with: "shared") + XCTAssertNotNil(dirEntity) + XCTAssertTrue(dirEntity!.isDir) + XCTAssertEqual(dirEntity!.name, "shared") + XCTAssertEqual(dirEntity!.children.count, 1) + } + func testCloneAndDecryptMultiKeys() throws { - let remoteRepoURL = Bundle(for: type(of: self)).resourceURL!.appendingPathComponent("Fixtures/password-store.git") - let url = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") + try cloneRepository() Defaults.isEnableGPGIDOn = true defer { Defaults.isEnableGPGIDOn = false } - let passwordStore = PasswordStore(url: url) - defer { - passwordStore.erase() - } - try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") - expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: url.path) }, evaluatedWith: nil) - waitForExpectations(timeout: 3, handler: nil) [ ("work/github.com", "4712286271220DB299883EA7062E678DA1024DAE"), ("personal/github.com", "787EAE1A5FA3E749AA34CC6AA0645EBED862027E"), ].forEach { path, id in - let keyID = findGPGID(from: url.appendingPathComponent(path)) + let keyID = findGPGID(from: localRepoURL.appendingPathComponent(path)) XCTAssertEqual(keyID, id) } @@ -54,7 +82,13 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(testPasswordPlain.plainText, "testpassword") } - private func decrypt(passwordStore: PasswordStore, path: String, passphrase _: String) throws -> Password { + fileprivate func cloneRepository() throws { + try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") + expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: self.localRepoURL.path) }, evaluatedWith: nil) + waitForExpectations(timeout: 3, handler: nil) + } + + fileprivate func decrypt(passwordStore: PasswordStore, path: String, passphrase _: String) throws -> Password { let entity = passwordStore.fetchPasswordEntity(with: path)! return try passwordStore.decrypt(passwordEntity: entity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) } From 60999c7eabe4548f73c6277c0fedcb845880ae85 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 00:34:34 +0100 Subject: [PATCH 06/42] more tests: entity fetching + erase --- passKit/Crypto/PGPAgent.swift | 4 + passKit/Models/PasswordStore.swift | 2 +- passKitTests/Models/PasswordStoreTest.swift | 84 ++++++++++++++++++--- 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index 66fbbed..f193515 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -34,6 +34,10 @@ public class PGPAgent { pgpInterface = nil } + public func isInitialized() -> Bool { + pgpInterface != nil + } + public func getKeyID() throws -> [String] { try checkAndInit() return pgpInterface?.keyID ?? [] diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index 495dce2..37f57f8 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -324,7 +324,7 @@ public class PasswordStore { PersistenceController.shared.save() } - public func deleteCoreData() { + private func deleteCoreData() { PasswordEntity.deleteAll(in: context) PersistenceController.shared.save() } diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 40357fa..5e12555 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -13,7 +13,7 @@ import XCTest @testable import passKit final class PasswordStoreTest: XCTestCase { - private let remoteRepoURL: URL = Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store.git") + private lazy var remoteRepoURL: URL = Bundle(for: type(of: self)).resourceURL!.appendingPathComponent("Fixtures/password-store.git") private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") private var passwordStore: PasswordStore! = nil @@ -49,8 +49,68 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(dirEntity!.children.count, 1) } + func testEraseStoreData() throws { + try cloneRepository() + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.path)) + XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) + XCTAssertNotNil(passwordStore.gitRepository) + + passwordStore.eraseStoreData() + + XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.path)) + XCTAssertEqual(passwordStore.numberOfPasswords, 0) + XCTAssertNil(passwordStore.gitRepository) + } + + func testErase() throws { + try cloneRepository() + try importPGPKeys() + Defaults.gitSignatureName = "Test User" + PasscodeLock.shared.save(passcode: "1234") + + XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) + XCTAssertTrue(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey())) + XCTAssertEqual(Defaults.gitSignatureName, "Test User") + XCTAssertTrue(PasscodeLock.shared.hasPasscode) + XCTAssertTrue(PGPAgent.shared.isInitialized()) + + passwordStore.erase() + + XCTAssertEqual(passwordStore.numberOfPasswords, 0) + XCTAssertFalse(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey())) + XCTAssertFalse(Defaults.hasKey(\.gitSignatureName)) + XCTAssertFalse(PasscodeLock.shared.hasPasscode) + XCTAssertFalse(PGPAgent.shared.isInitialized()) + } + + func testFetchPasswordEntityCoreDataByParent() throws { + try cloneRepository() + + let rootChildren = passwordStore.fetchPasswordEntityCoreData(parent: nil) + XCTAssertGreaterThan(rootChildren.count, 0) + rootChildren.forEach { entity in + XCTAssertTrue(entity.isDir) + } + + let personalDir = passwordStore.fetchPasswordEntity(with: "personal") + let personalChildren = passwordStore.fetchPasswordEntityCoreData(parent: personalDir) + XCTAssertEqual(personalChildren.count, 1) + XCTAssertEqual(personalChildren.first?.name, "github.com") + } + + func testFetchPasswordEntityCoreDataWithDir() throws { + try cloneRepository() + + let allPasswords = passwordStore.fetchPasswordEntityCoreData(withDir: false) + XCTAssertEqual(allPasswords.count, 4) + allPasswords.forEach { entity in + XCTAssertFalse(entity.isDir) + } + } + func testCloneAndDecryptMultiKeys() throws { try cloneRepository() + try importPGPKeys() Defaults.isEnableGPGIDOn = true defer { @@ -65,15 +125,10 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(keyID, id) } - let keychain = AppKeychain.shared - try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys) - try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys) - try PGPAgent.shared.initKeys() - - let personal = try decrypt(passwordStore: passwordStore, path: "personal/github.com.gpg", passphrase: "passforios") + let personal = try decrypt(path: "personal/github.com.gpg") XCTAssertEqual(personal.plainText, "passwordforpersonal\n") - let work = try decrypt(passwordStore: passwordStore, path: "work/github.com.gpg", passphrase: "passforios") + let work = try decrypt(path: "work/github.com.gpg") XCTAssertEqual(work.plainText, "passwordforwork\n") let testPassword = Password(name: "test", path: "test.gpg", plainText: "testpassword") @@ -82,13 +137,22 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(testPasswordPlain.plainText, "testpassword") } - fileprivate func cloneRepository() throws { + // MARK: - Helpers + + private func cloneRepository() throws { try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: self.localRepoURL.path) }, evaluatedWith: nil) waitForExpectations(timeout: 3, handler: nil) } - fileprivate func decrypt(passwordStore: PasswordStore, path: String, passphrase _: String) throws -> Password { + private func importPGPKeys() throws { + let keychain = AppKeychain.shared + try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys) + try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys) + try PGPAgent.shared.initKeys() + } + + private func decrypt(path: String) throws -> Password { let entity = passwordStore.fetchPasswordEntity(with: path)! return try passwordStore.decrypt(passwordEntity: entity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) } From e5650ec75698f931f8e7ed9b1d3aef192edc9be7 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 10:57:39 +0100 Subject: [PATCH 07/42] add encrypt-save-decrypt roundtrip test --- .../Fixtures/password-store-empty.git/HEAD | 1 + .../Fixtures/password-store-empty.git/config | 6 ++ .../password-store-empty.git/description | 1 + .../info/exclude | 0 .../4b/825dc642cb6eb9a060e54bf8d69288fbee4904 | Bin 0 -> 15 bytes .../4e/b23a2d659dcaa6fbc01ada57aed6d1fbeb0520 | Bin 0 -> 139 bytes .../50/96ac11d1376ea9b22ddedac1130f45ec618d11 | Bin 0 -> 57 bytes .../ae/a863facd4acba3b4862e3f42847da1000e486a | Bin 0 -> 52 bytes .../f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd | 3 + .../password-store-empty.git/packed-refs | 2 + .../FETCH_HEAD | 0 .../HEAD | 0 .../config | 0 .../password-store-with-gpgid.git/description | 1 + .../info/exclude | 6 ++ ...8dbb253e7642cc425de97363624aab04882615.idx | Bin ...dbb253e7642cc425de97363624aab04882615.pack | Bin .../packed-refs | 0 .../refs/remotes/origin/master | 0 .../Fixtures/password-store.git/description | 1 - passKitTests/Models/PasswordStoreTest.swift | 72 +++++++++++++----- 21 files changed, 75 insertions(+), 18 deletions(-) create mode 100644 passKitTests/Fixtures/password-store-empty.git/HEAD create mode 100644 passKitTests/Fixtures/password-store-empty.git/config create mode 100644 passKitTests/Fixtures/password-store-empty.git/description rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/info/exclude (100%) create mode 100644 passKitTests/Fixtures/password-store-empty.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 create mode 100644 passKitTests/Fixtures/password-store-empty.git/objects/4e/b23a2d659dcaa6fbc01ada57aed6d1fbeb0520 create mode 100644 passKitTests/Fixtures/password-store-empty.git/objects/50/96ac11d1376ea9b22ddedac1130f45ec618d11 create mode 100644 passKitTests/Fixtures/password-store-empty.git/objects/ae/a863facd4acba3b4862e3f42847da1000e486a create mode 100644 passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd create mode 100644 passKitTests/Fixtures/password-store-empty.git/packed-refs rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/FETCH_HEAD (100%) rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/HEAD (100%) rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/config (100%) create mode 100644 passKitTests/Fixtures/password-store-with-gpgid.git/description create mode 100644 passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx (100%) rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack (100%) rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/packed-refs (100%) rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/refs/remotes/origin/master (100%) delete mode 100644 passKitTests/Fixtures/password-store.git/description diff --git a/passKitTests/Fixtures/password-store-empty.git/HEAD b/passKitTests/Fixtures/password-store-empty.git/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/passKitTests/Fixtures/password-store-empty.git/config b/passKitTests/Fixtures/password-store-empty.git/config new file mode 100644 index 0000000..e6da231 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + ignorecase = true + precomposeunicode = true diff --git a/passKitTests/Fixtures/password-store-empty.git/description b/passKitTests/Fixtures/password-store-empty.git/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/passKitTests/Fixtures/password-store.git/info/exclude b/passKitTests/Fixtures/password-store-empty.git/info/exclude similarity index 100% rename from passKitTests/Fixtures/password-store.git/info/exclude rename to passKitTests/Fixtures/password-store-empty.git/info/exclude diff --git a/passKitTests/Fixtures/password-store-empty.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 b/passKitTests/Fixtures/password-store-empty.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 new file mode 100644 index 0000000000000000000000000000000000000000..adf64119a33d7621aeeaa505d30adb58afaa5559 GIT binary patch literal 15 Wcmbxed=mpo3N25RFzb7z)m#LF#lE?O>d;b__(?p%%XJz0Po5p1u^g&Az;9qc@zU twOsgD4%E&d`~Ha9B0AWkL=KJLS9Ud!DSwv+~->H?%nz I)N=Tv0PoloKmY&$ literal 0 HcmV?d00001 diff --git a/passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd b/passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd new file mode 100644 index 0000000..3e561dd --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd @@ -0,0 +1,3 @@ +xM +0@a9 \zi:@BzzK}eN*3Sv48J=Eo؎D҈k"EMnKNRy,_pr_9p;qETھ +&rɒi@ \ No newline at end of file diff --git a/passKitTests/Fixtures/password-store-empty.git/packed-refs b/passKitTests/Fixtures/password-store-empty.git/packed-refs new file mode 100644 index 0000000..432aeb4 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/packed-refs @@ -0,0 +1,2 @@ +# pack-refs with: peeled fully-peeled sorted +f095bb4897e4cd58faadfe4d4f678fb697be3ffd refs/heads/main diff --git a/passKitTests/Fixtures/password-store.git/FETCH_HEAD b/passKitTests/Fixtures/password-store-with-gpgid.git/FETCH_HEAD similarity index 100% rename from passKitTests/Fixtures/password-store.git/FETCH_HEAD rename to passKitTests/Fixtures/password-store-with-gpgid.git/FETCH_HEAD diff --git a/passKitTests/Fixtures/password-store.git/HEAD b/passKitTests/Fixtures/password-store-with-gpgid.git/HEAD similarity index 100% rename from passKitTests/Fixtures/password-store.git/HEAD rename to passKitTests/Fixtures/password-store-with-gpgid.git/HEAD diff --git a/passKitTests/Fixtures/password-store.git/config b/passKitTests/Fixtures/password-store-with-gpgid.git/config similarity index 100% rename from passKitTests/Fixtures/password-store.git/config rename to passKitTests/Fixtures/password-store-with-gpgid.git/config diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/description b/passKitTests/Fixtures/password-store-with-gpgid.git/description new file mode 100644 index 0000000..5536ff7 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/description @@ -0,0 +1 @@ +Example password store repository for passforios tests with .gpg-id files. diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude b/passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx similarity index 100% rename from passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx rename to passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx diff --git a/passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack similarity index 100% rename from passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack rename to passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack diff --git a/passKitTests/Fixtures/password-store.git/packed-refs b/passKitTests/Fixtures/password-store-with-gpgid.git/packed-refs similarity index 100% rename from passKitTests/Fixtures/password-store.git/packed-refs rename to passKitTests/Fixtures/password-store-with-gpgid.git/packed-refs diff --git a/passKitTests/Fixtures/password-store.git/refs/remotes/origin/master b/passKitTests/Fixtures/password-store-with-gpgid.git/refs/remotes/origin/master similarity index 100% rename from passKitTests/Fixtures/password-store.git/refs/remotes/origin/master rename to passKitTests/Fixtures/password-store-with-gpgid.git/refs/remotes/origin/master diff --git a/passKitTests/Fixtures/password-store.git/description b/passKitTests/Fixtures/password-store.git/description deleted file mode 100644 index 9258d15..0000000 --- a/passKitTests/Fixtures/password-store.git/description +++ /dev/null @@ -1 +0,0 @@ -Example password store repository for passforios tests. diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 5e12555..205ab13 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -13,7 +13,6 @@ import XCTest @testable import passKit final class PasswordStoreTest: XCTestCase { - private lazy var remoteRepoURL: URL = Bundle(for: type(of: self)).resourceURL!.appendingPathComponent("Fixtures/password-store.git") private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") private var passwordStore: PasswordStore! = nil @@ -25,10 +24,12 @@ final class PasswordStoreTest: XCTestCase { override func tearDown() { passwordStore.erase() passwordStore = nil + + Defaults.removeAll() } func testInitPasswordEntityCoreData() throws { - try cloneRepository() + try cloneRepository(.withGPGID) XCTAssertEqual(passwordStore.numberOfPasswords, 4) @@ -50,7 +51,7 @@ final class PasswordStoreTest: XCTestCase { } func testEraseStoreData() throws { - try cloneRepository() + try cloneRepository(.withGPGID) XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.path)) XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) XCTAssertNotNil(passwordStore.gitRepository) @@ -63,8 +64,8 @@ final class PasswordStoreTest: XCTestCase { } func testErase() throws { - try cloneRepository() - try importPGPKeys() + try cloneRepository(.withGPGID) + try importSinglePGPKey() Defaults.gitSignatureName = "Test User" PasscodeLock.shared.save(passcode: "1234") @@ -84,7 +85,7 @@ final class PasswordStoreTest: XCTestCase { } func testFetchPasswordEntityCoreDataByParent() throws { - try cloneRepository() + try cloneRepository(.withGPGID) let rootChildren = passwordStore.fetchPasswordEntityCoreData(parent: nil) XCTAssertGreaterThan(rootChildren.count, 0) @@ -99,7 +100,7 @@ final class PasswordStoreTest: XCTestCase { } func testFetchPasswordEntityCoreDataWithDir() throws { - try cloneRepository() + try cloneRepository(.withGPGID) let allPasswords = passwordStore.fetchPasswordEntityCoreData(withDir: false) XCTAssertEqual(allPasswords.count, 4) @@ -108,14 +109,21 @@ final class PasswordStoreTest: XCTestCase { } } + func testEncryptSaveDecryptMultiline() throws { + try cloneRepository(.empty) + try importSinglePGPKey() + + let password = Password(name: "test", path: "test.gpg", plainText: "foobar\nwith\nmultiple\nlines") + _ = try passwordStore.add(password: password) + let decryptedPassword = try decrypt(path: "test.gpg") + XCTAssertEqual(decryptedPassword.plainText, "foobar\nwith\nmultiple\nlines") + } + func testCloneAndDecryptMultiKeys() throws { - try cloneRepository() - try importPGPKeys() + try cloneRepository(.withGPGID) + try importMultiplePGPKeys() Defaults.isEnableGPGIDOn = true - defer { - Defaults.isEnableGPGIDOn = false - } [ ("work/github.com", "4712286271220DB299883EA7062E678DA1024DAE"), @@ -139,21 +147,51 @@ final class PasswordStoreTest: XCTestCase { // MARK: - Helpers - private func cloneRepository() throws { - try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") + private enum RemoteRepo { + case empty + case withGPGID + + var url: URL { + switch self { + case .empty: + Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store-empty.git") + case .withGPGID: + Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store-with-gpgid.git") + } + } + + var branchName: String { + switch self { + case .empty: + "main" + case .withGPGID: + "master" + } + } + } + + private func cloneRepository(_ remote: RemoteRepo) throws { + try passwordStore.cloneRepository(remoteRepoURL: remote.url, branchName: remote.branchName) expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: self.localRepoURL.path) }, evaluatedWith: nil) waitForExpectations(timeout: 3, handler: nil) } - private func importPGPKeys() throws { + private func importSinglePGPKey() throws { + let keychain = AppKeychain.shared + try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.publicKey) + try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.privateKey) + try PGPAgent.shared.initKeys() + } + + private func importMultiplePGPKeys() throws { let keychain = AppKeychain.shared try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys) try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys) try PGPAgent.shared.initKeys() } - private func decrypt(path: String) throws -> Password { + private func decrypt(path: String, keyID: String? = nil) throws -> Password { let entity = passwordStore.fetchPasswordEntity(with: path)! - return try passwordStore.decrypt(passwordEntity: entity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + return try passwordStore.decrypt(passwordEntity: entity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) } } From 98ad3234316fb462f636f9f16d6b3a86ff59f049 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 11:45:04 +0100 Subject: [PATCH 08/42] check notification center notifications --- passKitTests/Models/PasswordStoreTest.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 205ab13..60ddf48 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -56,11 +56,14 @@ final class PasswordStoreTest: XCTestCase { XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) XCTAssertNotNil(passwordStore.gitRepository) + expectation(forNotification: .passwordStoreUpdated, object: nil) + expectation(forNotification: .passwordStoreErased, object: nil) passwordStore.eraseStoreData() XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.path)) XCTAssertEqual(passwordStore.numberOfPasswords, 0) XCTAssertNil(passwordStore.gitRepository) + waitForExpectations(timeout: 1, handler: nil) } func testErase() throws { @@ -75,6 +78,8 @@ final class PasswordStoreTest: XCTestCase { XCTAssertTrue(PasscodeLock.shared.hasPasscode) XCTAssertTrue(PGPAgent.shared.isInitialized()) + expectation(forNotification: .passwordStoreUpdated, object: nil) + expectation(forNotification: .passwordStoreErased, object: nil) passwordStore.erase() XCTAssertEqual(passwordStore.numberOfPasswords, 0) @@ -82,6 +87,7 @@ final class PasswordStoreTest: XCTestCase { XCTAssertFalse(Defaults.hasKey(\.gitSignatureName)) XCTAssertFalse(PasscodeLock.shared.hasPasscode) XCTAssertFalse(PGPAgent.shared.isInitialized()) + waitForExpectations(timeout: 1, handler: nil) } func testFetchPasswordEntityCoreDataByParent() throws { @@ -171,8 +177,11 @@ final class PasswordStoreTest: XCTestCase { } private func cloneRepository(_ remote: RemoteRepo) throws { - try passwordStore.cloneRepository(remoteRepoURL: remote.url, branchName: remote.branchName) expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: self.localRepoURL.path) }, evaluatedWith: nil) + expectation(forNotification: .passwordStoreUpdated, object: nil) + + try passwordStore.cloneRepository(remoteRepoURL: remote.url, branchName: remote.branchName) + waitForExpectations(timeout: 3, handler: nil) } From 12c8c042035befa0952e726f11cb62457afb387c Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 11:46:26 +0100 Subject: [PATCH 09/42] test add, edit, delete --- passKit/Models/PasswordStore.swift | 2 +- passKitTests/Models/PasswordStoreTest.swift | 69 +++++++++++++++++++-- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index 37f57f8..22c90d9 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -320,7 +320,7 @@ public class PasswordStore { saveUpdatedContext() } - public func saveUpdatedContext() { + private func saveUpdatedContext() { PersistenceController.shared.save() } diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 60ddf48..883471c 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -115,16 +115,75 @@ final class PasswordStoreTest: XCTestCase { } } - func testEncryptSaveDecryptMultiline() throws { + func testAddPassword() throws { try cloneRepository(.empty) try importSinglePGPKey() - let password = Password(name: "test", path: "test.gpg", plainText: "foobar\nwith\nmultiple\nlines") - _ = try passwordStore.add(password: password) - let decryptedPassword = try decrypt(path: "test.gpg") - XCTAssertEqual(decryptedPassword.plainText, "foobar\nwith\nmultiple\nlines") + let password1 = Password(name: "test1", path: "test1.gpg", plainText: "foobar") + let password2 = Password(name: "test2", path: "test2.gpg", plainText: "hello world") + let password3 = Password(name: "test3", path: "test3.gpg", plainText: "lorem ipsum") + let password4 = Password(name: "test4", path: "test4.gpg", plainText: "you are valuable and you matter") + + [password1, password2, password3, password4].forEach { password in + expectation(forNotification: .passwordStoreUpdated, object: nil) + + let savedEntity = try? passwordStore.add(password: password) + + XCTAssertEqual(savedEntity!.name, password.name) + waitForExpectations(timeout: 1, handler: nil) + } } + func testDeletePassword() throws { + try cloneRepository(.withGPGID) + + expectation(forNotification: .passwordStoreUpdated, object: nil) + + let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") + try passwordStore.delete(passwordEntity: entity!) + + XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + waitForExpectations(timeout: 1, handler: nil) + } + + func testEditPasswordValue() throws { + try cloneRepository(.withGPGID) + try importSinglePGPKey() + let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! + + expectation(forNotification: .passwordStoreUpdated, object: nil) + + let editedPassword = Password(name: entity.name, path: entity.path, plainText: "editedpassword") + editedPassword.changed = PasswordChange.content.rawValue + let editedEntity = try passwordStore.edit(passwordEntity: entity, password: editedPassword) + + XCTAssertNotNil(editedEntity) + XCTAssertEqual(editedEntity!.name, "github.com") + XCTAssertFalse(editedEntity!.isSynced) + XCTAssertEqual(try decrypt(path: "personal/github.com.gpg").plainText, "editedpassword") + waitForExpectations(timeout: 1, handler: nil) + } + + func testMovePassword() throws { + try cloneRepository(.withGPGID) + try importSinglePGPKey() + let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! + + expectation(forNotification: .passwordStoreUpdated, object: nil) + + let editedPassword = Password(name: "new name", path: "new name.gpg", plainText: "passwordforpersonal\n") + editedPassword.changed = PasswordChange.path.rawValue + let editedEntity = try passwordStore.edit(passwordEntity: entity, password: editedPassword) + + XCTAssertEqual(editedEntity!.name, "new name") + XCTAssertFalse(editedEntity!.isSynced) + XCTAssertEqual(try decrypt(path: "new name.gpg").plainText, "passwordforpersonal\n") + XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + waitForExpectations(timeout: 1, handler: nil) + } + + // MARK: - .gpg-id support + func testCloneAndDecryptMultiKeys() throws { try cloneRepository(.withGPGID) try importMultiplePGPKeys() From 98646242e0fa6c4bacbb4edcc99e273e0120064b Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 12:11:37 +0100 Subject: [PATCH 10/42] fix deleting directory this used to corrupt the local state (password entities remained in DB but files/dirs were removed from git and disk) --- pass/en.lproj/Localizable.strings | 1 + passKit/Helpers/AppError.swift | 1 + passKit/Models/PasswordStore.swift | 4 ++++ passKitTests/Models/PasswordStoreTest.swift | 18 ++++++++++++++++++ 4 files changed, 24 insertions(+) diff --git a/pass/en.lproj/Localizable.strings b/pass/en.lproj/Localizable.strings index 5f26010..10d8b30 100644 --- a/pass/en.lproj/Localizable.strings +++ b/pass/en.lproj/Localizable.strings @@ -73,6 +73,7 @@ "KeyImportError." = "Cannot import the key."; "FileNotFoundError." = "File '%@' cannot be read."; "PasswordDuplicatedError." = "Cannot add the password; password is duplicated."; +"CannotDeleteDirectoryError." = "Cannot delete directories; delete passwords instead."; "GitResetError." = "Cannot identify the latest synced commit."; "GitCreateSignatureError." = "Cannot create a valid author/committer signature."; "GitPushNotSuccessfulError." = "Pushing local changes was not successful. Make sure there are no uncommitted changes on the remote repository."; diff --git a/passKit/Helpers/AppError.swift b/passKit/Helpers/AppError.swift index 8e0aa21..8bcc84b 100644 --- a/passKit/Helpers/AppError.swift +++ b/passKit/Helpers/AppError.swift @@ -15,6 +15,7 @@ public enum AppError: Error, Equatable { case keyImport case readingFile(fileName: String) case passwordDuplicated + case cannotDeleteDirectory case gitReset case gitCommit case gitCreateSignature diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index 22c90d9..78c92c4 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -273,6 +273,10 @@ public class PasswordStore { } public func delete(passwordEntity: PasswordEntity) throws { + if passwordEntity.isDir { + throw AppError.cannotDeleteDirectory + } + let deletedFileURL = passwordEntity.fileURL(in: storeURL) let deletedFilePath = passwordEntity.path try gitRm(path: passwordEntity.path) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 883471c..8b4d670 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -143,9 +143,27 @@ final class PasswordStoreTest: XCTestCase { try passwordStore.delete(passwordEntity: entity!) XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal")) + XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal").path)) waitForExpectations(timeout: 1, handler: nil) } + func testDeleteDirectoryFails() throws { + try cloneRepository(.withGPGID) + + expectation(forNotification: .passwordStoreUpdated, object: nil).isInverted = true + + let entity = passwordStore.fetchPasswordEntity(with: "personal") + XCTAssertThrowsError(try passwordStore.delete(passwordEntity: entity!)) { error in + XCTAssertTrue(error is AppError, "Unexpected error type: \(type(of: error))") + XCTAssertEqual(error as? AppError, .cannotDeleteDirectory) + } + + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal/github.com.gpg").path)) + waitForExpectations(timeout: 0.1, handler: nil) + } + func testEditPasswordValue() throws { try cloneRepository(.withGPGID) try importSinglePGPKey() From c3bfa861f42b7212ce515bfcac74c1b98958d44d Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 12:49:33 +0100 Subject: [PATCH 11/42] check file system and commits upon changes to store --- passKitTests/Models/PasswordStoreTest.swift | 31 ++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 8b4d670..94dd8eb 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -32,6 +32,8 @@ final class PasswordStoreTest: XCTestCase { try cloneRepository(.withGPGID) XCTAssertEqual(passwordStore.numberOfPasswords, 4) + XCTAssertEqual(passwordStore.numberOfCommits, 16) + XCTAssertEqual(passwordStore.numberOfLocalCommits, 0) let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") XCTAssertEqual(entity!.path, "personal/github.com.gpg") @@ -118,10 +120,12 @@ final class PasswordStoreTest: XCTestCase { func testAddPassword() throws { try cloneRepository(.empty) try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits let password1 = Password(name: "test1", path: "test1.gpg", plainText: "foobar") let password2 = Password(name: "test2", path: "test2.gpg", plainText: "hello world") - let password3 = Password(name: "test3", path: "test3.gpg", plainText: "lorem ipsum") + let password3 = Password(name: "test3", path: "folder/test3.gpg", plainText: "lorem ipsum") let password4 = Password(name: "test4", path: "test4.gpg", plainText: "you are valuable and you matter") [password1, password2, password3, password4].forEach { password in @@ -132,10 +136,21 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(savedEntity!.name, password.name) waitForExpectations(timeout: 1, handler: nil) } + + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test1.gpg").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test2.gpg").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("folder").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("folder/test3.gpg").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test4.gpg").path)) + + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 4) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 4) } func testDeletePassword() throws { try cloneRepository(.withGPGID) + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits expectation(forNotification: .passwordStoreUpdated, object: nil) @@ -145,11 +160,15 @@ final class PasswordStoreTest: XCTestCase { XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal")) XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal").path)) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) waitForExpectations(timeout: 1, handler: nil) } func testDeleteDirectoryFails() throws { try cloneRepository(.withGPGID) + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits expectation(forNotification: .passwordStoreUpdated, object: nil).isInverted = true @@ -161,12 +180,16 @@ final class PasswordStoreTest: XCTestCase { XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal/github.com.gpg").path)) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore) waitForExpectations(timeout: 0.1, handler: nil) } func testEditPasswordValue() throws { try cloneRepository(.withGPGID) try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! expectation(forNotification: .passwordStoreUpdated, object: nil) @@ -179,12 +202,16 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(editedEntity!.name, "github.com") XCTAssertFalse(editedEntity!.isSynced) XCTAssertEqual(try decrypt(path: "personal/github.com.gpg").plainText, "editedpassword") + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) waitForExpectations(timeout: 1, handler: nil) } func testMovePassword() throws { try cloneRepository(.withGPGID) try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! expectation(forNotification: .passwordStoreUpdated, object: nil) @@ -197,6 +224,8 @@ final class PasswordStoreTest: XCTestCase { XCTAssertFalse(editedEntity!.isSynced) XCTAssertEqual(try decrypt(path: "new name.gpg").plainText, "passwordforpersonal\n") XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) waitForExpectations(timeout: 1, handler: nil) } From e195280efc153ef62496872a05fc11ad94142b49 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 12:55:16 +0100 Subject: [PATCH 12/42] test resetting local changes --- passKitTests/Models/PasswordStoreTest.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 94dd8eb..812a96b 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -229,6 +229,26 @@ final class PasswordStoreTest: XCTestCase { waitForExpectations(timeout: 1, handler: nil) } + func testReset() throws { + try cloneRepository(.withGPGID) + try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits + + _ = try? passwordStore.add(password: Password(name: "test", path: "test.gpg", plainText: "foobar")) + try passwordStore.delete(passwordEntity: passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")!) + + expectation(forNotification: .passwordStoreUpdated, object: nil) + let numDroppedCommits = try passwordStore.reset() + + XCTAssertEqual(numDroppedCommits, 2) + XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test.gpg").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal/github.com.gpg").path)) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore) + waitForExpectations(timeout: 1, handler: nil) + } + // MARK: - .gpg-id support func testCloneAndDecryptMultiKeys() throws { From e1da1988b45b4852491c923716ad682d12624913 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 13:15:03 +0100 Subject: [PATCH 13/42] add save and decrypt round trip --- passKitTests/Models/PasswordStoreTest.swift | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 812a96b..de3f7b3 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -128,10 +128,10 @@ final class PasswordStoreTest: XCTestCase { let password3 = Password(name: "test3", path: "folder/test3.gpg", plainText: "lorem ipsum") let password4 = Password(name: "test4", path: "test4.gpg", plainText: "you are valuable and you matter") - [password1, password2, password3, password4].forEach { password in + for password in [password1, password2, password3, password4] { expectation(forNotification: .passwordStoreUpdated, object: nil) - let savedEntity = try? passwordStore.add(password: password) + let savedEntity = try passwordStore.add(password: password) XCTAssertEqual(savedEntity!.name, password.name) waitForExpectations(timeout: 1, handler: nil) @@ -147,6 +147,17 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 4) } + func testAddAndDecryptRoundTrip() throws { + try cloneRepository(.empty) + try importSinglePGPKey() + + let password = Password(name: "test", path: "test.gpg", plainText: "foobar") + let savedEntity = try passwordStore.add(password: password) + + let decryptedPassword = try passwordStore.decrypt(passwordEntity: savedEntity!, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + XCTAssertEqual(decryptedPassword.plainText, "foobar") + } + func testDeletePassword() throws { try cloneRepository(.withGPGID) let numCommitsBefore = passwordStore.numberOfCommits! @@ -235,7 +246,7 @@ final class PasswordStoreTest: XCTestCase { let numCommitsBefore = passwordStore.numberOfCommits! let numLocalCommitsBefore = passwordStore.numberOfLocalCommits - _ = try? passwordStore.add(password: Password(name: "test", path: "test.gpg", plainText: "foobar")) + _ = try passwordStore.add(password: Password(name: "test", path: "test.gpg", plainText: "foobar")) try passwordStore.delete(passwordEntity: passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")!) expectation(forNotification: .passwordStoreUpdated, object: nil) From c6a4f805038d0c8f805019d84bb4d9cb5057df77 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 13:30:22 +0100 Subject: [PATCH 14/42] add initPasswordEntityCoreData tests --- .../CoreData/PasswordEntityTest.swift | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/passKitTests/CoreData/PasswordEntityTest.swift b/passKitTests/CoreData/PasswordEntityTest.swift index 6362e2a..ad70f73 100644 --- a/passKitTests/CoreData/PasswordEntityTest.swift +++ b/passKitTests/CoreData/PasswordEntityTest.swift @@ -85,4 +85,99 @@ final class PasswordEntityTest: CoreDataTestCase { XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 0) } + + // MARK: - initPasswordEntityCoreData tests + + func testInitPasswordEntityCoreDataBuildsTree() throws { + let rootDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: rootDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: rootDir) } + + // Create directory structure: + // email/ + // work.gpg + // personal.gpg + // social/ + // mastodon.gpg + // toplevel.gpg + // notes.txt (non-.gpg file) + let emailDir = rootDir.appendingPathComponent("email") + let socialDir = rootDir.appendingPathComponent("social") + try FileManager.default.createDirectory(at: emailDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: socialDir, withIntermediateDirectories: true) + try Data("test1".utf8).write(to: emailDir.appendingPathComponent("work.gpg")) + try Data("test2".utf8).write(to: emailDir.appendingPathComponent("personal.gpg")) + try Data("test3".utf8).write(to: socialDir.appendingPathComponent("mastodon.gpg")) + try Data("test4".utf8).write(to: rootDir.appendingPathComponent("toplevel.gpg")) + try Data("test5".utf8).write(to: rootDir.appendingPathComponent("notes.txt")) + + let context = controller.viewContext() + PasswordEntity.initPasswordEntityCoreData(url: rootDir, in: context) + + // Verify total counts + let allEntities = PasswordEntity.fetchAll(in: context) + let files = allEntities.filter { !$0.isDir } + let dirs = allEntities.filter(\.isDir) + XCTAssertEqual(files.count, 5) // 4 .gpg + 1 .txt + XCTAssertEqual(dirs.count, 2) // email, social + + // Verify .gpg extension is stripped + let workEntity = allEntities.first { $0.path == "email/work.gpg" } + XCTAssertNotNil(workEntity) + XCTAssertEqual(workEntity!.name, "work") + + // Verify non-.gpg file keeps its extension + let notesEntity = allEntities.first { $0.path == "notes.txt" } + XCTAssertNotNil(notesEntity) + XCTAssertEqual(notesEntity!.name, "notes.txt") + + // Verify parent-child relationships + let emailEntity = allEntities.first { $0.path == "email" && $0.isDir } + XCTAssertNotNil(emailEntity) + XCTAssertEqual(emailEntity!.children.count, 2) + + // Verify top-level files have no parent (root was deleted) + let toplevelEntity = allEntities.first { $0.path == "toplevel.gpg" } + XCTAssertNotNil(toplevelEntity) + XCTAssertEqual(toplevelEntity!.name, "toplevel") + XCTAssertNil(toplevelEntity!.parent) + } + + func testInitPasswordEntityCoreDataSkipsHiddenFiles() throws { + let rootDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: rootDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: rootDir) } + + try Data("test".utf8).write(to: rootDir.appendingPathComponent("visible.gpg")) + try Data("test".utf8).write(to: rootDir.appendingPathComponent(".hidden.gpg")) + try Data("test".utf8).write(to: rootDir.appendingPathComponent(".gpg-id")) + try FileManager.default.createDirectory(at: rootDir.appendingPathComponent(".git"), withIntermediateDirectories: true) + try Data("test".utf8).write(to: rootDir.appendingPathComponent(".git/config")) + + let context = controller.viewContext() + PasswordEntity.initPasswordEntityCoreData(url: rootDir, in: context) + + let allEntities = PasswordEntity.fetchAll(in: context) + XCTAssertEqual(allEntities.count, 1) + XCTAssertEqual(allEntities.first!.name, "visible") + } + + func testInitPasswordEntityCoreDataHandlesEmptyDirectory() throws { + let rootDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: rootDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: rootDir) } + + try FileManager.default.createDirectory(at: rootDir.appendingPathComponent("emptydir"), withIntermediateDirectories: true) + + let context = controller.viewContext() + PasswordEntity.initPasswordEntityCoreData(url: rootDir, in: context) + + let allEntities = PasswordEntity.fetchAll(in: context) + let dirs = allEntities.filter(\.isDir) + let files = allEntities.filter { !$0.isDir } + XCTAssertEqual(dirs.count, 1) + XCTAssertEqual(dirs.first!.name, "emptydir") + XCTAssertEqual(dirs.first!.children.count, 0) + XCTAssertEqual(files.count, 0) + } } From 4c21ab99adc9174f8fa17f80cdce87700b0664e7 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 13:36:19 +0100 Subject: [PATCH 15/42] add tests for AppKeychain --- pass.xcodeproj/project.pbxproj | 4 + passKitTests/Helpers/AppKeychainTest.swift | 101 +++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 passKitTests/Helpers/AppKeychainTest.swift diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index e3e63e8..3b42e4b 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -114,6 +114,7 @@ 5F9D7B0D27AF6F7500A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0E27AF6FCA00A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; + 8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */; }; 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 8AD8EBF22F5E268D007475AB /* Fixtures */; }; 9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; }; 9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; }; @@ -423,6 +424,7 @@ 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; }; + 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeychainTest.swift; sourceTree = ""; }; 8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = ""; }; 9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = ""; }; 9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = ""; }; @@ -626,6 +628,7 @@ 301F6464216164670071A4CE /* Helpers */ = { isa = PBXGroup; children = ( + 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */, 3032328922C9FBA2009EBD9C /* KeyFileManagerTest.swift */, ); path = Helpers; @@ -1643,6 +1646,7 @@ A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */, 30FD2F78214D9E0E005E0A92 /* ParserTest.swift in Sources */, A2AA934622DE3A8000D79A00 /* PGPAgentTest.swift in Sources */, + 8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */, 30695E2524FAEF2600C9D46E /* GitCredentialTest.swift in Sources */, 30BAC8C622E3BAAF00438475 /* TestBase.swift in Sources */, 30B04860209A5141001013CA /* PasswordTest.swift in Sources */, diff --git a/passKitTests/Helpers/AppKeychainTest.swift b/passKitTests/Helpers/AppKeychainTest.swift new file mode 100644 index 0000000..9b420fc --- /dev/null +++ b/passKitTests/Helpers/AppKeychainTest.swift @@ -0,0 +1,101 @@ +// +// AppKeychainTest.swift +// passKitTests +// +// Created by Lysann Tranvouez on 9/3/26. +// Copyright © 2026 Bob Sun. All rights reserved. +// + +import XCTest + +@testable import passKit + +final class AppKeychainTest: XCTestCase { + private let keychain = AppKeychain.shared + private let testPrefix = "test.AppKeychainTest." + + override func tearDown() { + super.tearDown() + keychain.removeAllContent(withPrefix: testPrefix) + } + + private func key(_ name: String) -> String { + "\(testPrefix)\(name)" + } + + // MARK: - Basic round-trip + + func testAddAndGet() { + keychain.add(string: "hello", for: key("addGet")) + + XCTAssertEqual(keychain.get(for: key("addGet")), "hello") + } + + func testGetMissingKeyReturnsNil() { + XCTAssertNil(keychain.get(for: key("nonexistent"))) + } + + func testOverwriteValue() { + keychain.add(string: "first", for: key("overwrite")) + keychain.add(string: "second", for: key("overwrite")) + + XCTAssertEqual(keychain.get(for: key("overwrite")), "second") + } + + func testAddNilRemovesValue() { + keychain.add(string: "value", for: key("addNil")) + keychain.add(string: nil, for: key("addNil")) + + XCTAssertNil(keychain.get(for: key("addNil"))) + XCTAssertFalse(keychain.contains(key: key("addNil"))) + } + + // MARK: - contains + + func testContainsReturnsTrueForExistingKey() { + keychain.add(string: "value", for: key("exists")) + + XCTAssertTrue(keychain.contains(key: key("exists"))) + } + + func testContainsReturnsFalseForMissingKey() { + XCTAssertFalse(keychain.contains(key: key("missing"))) + } + + // MARK: - removeContent + + func testRemoveContent() { + keychain.add(string: "value", for: key("remove")) + keychain.removeContent(for: key("remove")) + + XCTAssertNil(keychain.get(for: key("remove"))) + XCTAssertFalse(keychain.contains(key: key("remove"))) + } + + func testRemoveContentForMissingKeyDoesNotThrow() { + keychain.removeContent(for: key("neverExisted")) + // No assertion needed — just verifying it doesn't crash + } + + // MARK: - removeAllContent(withPrefix:) + + func testRemoveAllContentWithPrefix() { + keychain.add(string: "1", for: key("prefixA.one")) + keychain.add(string: "2", for: key("prefixA.two")) + keychain.add(string: "3", for: key("prefixB.one")) + + keychain.removeAllContent(withPrefix: key("prefixA")) + + XCTAssertNil(keychain.get(for: key("prefixA.one"))) + XCTAssertNil(keychain.get(for: key("prefixA.two"))) + XCTAssertEqual(keychain.get(for: key("prefixB.one")), "3") + } + + func testRemoveAllContentWithPrefixNoMatches() { + keychain.add(string: "value", for: key("survivor")) + + keychain.removeAllContent(withPrefix: key("noMatch")) + + XCTAssertEqual(keychain.get(for: key("survivor")), "value") + } +} From cde82d956b08c3b5303552348f59b3f05678636d Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 13:41:51 +0100 Subject: [PATCH 16/42] rename file to match contained class --- pass.xcodeproj/project.pbxproj | 8 ++++---- .../{CoreDataStack.swift => PersistenceController.swift} | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename passKit/Controllers/{CoreDataStack.swift => PersistenceController.swift} (98%) diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index 3b42e4b..95c542d 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -197,7 +197,7 @@ DC4914961E434301007FF592 /* LabelTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4914941E434301007FF592 /* LabelTableViewCell.swift */; }; DC4914991E434600007FF592 /* PasswordDetailTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4914981E434600007FF592 /* PasswordDetailTableViewController.swift */; }; DC5F385B1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5F385A1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift */; }; - DC6474532D20DD0C004B4BBC /* CoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */; }; + DC6474532D20DD0C004B4BBC /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474522D20DD0C004B4BBC /* PersistenceController.swift */; }; 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 */; }; @@ -502,7 +502,7 @@ DC4914941E434301007FF592 /* LabelTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LabelTableViewCell.swift; sourceTree = ""; }; DC4914981E434600007FF592 /* PasswordDetailTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PasswordDetailTableViewController.swift; sourceTree = ""; }; DC5F385A1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PGPKeyArmorImportTableViewController.swift; sourceTree = ""; }; - DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStack.swift; sourceTree = ""; }; + DC6474522D20DD0C004B4BBC /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; 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 = ""; }; @@ -919,7 +919,7 @@ children = ( 30697C3121F63C8B0064FCAC /* PasscodeLockPresenter.swift */, 30697C3221F63C8B0064FCAC /* PasscodeLockViewController.swift */, - DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */, + DC6474522D20DD0C004B4BBC /* PersistenceController.swift */, ); path = Controllers; sourceTree = ""; @@ -1620,7 +1620,7 @@ 3087574F2343E42A00B971A2 /* Colors.swift in Sources */, 30697C2C21F63C5A0064FCAC /* FileManagerExtension.swift in Sources */, 30697C3321F63C8B0064FCAC /* PasscodeLockPresenter.swift in Sources */, - DC6474532D20DD0C004B4BBC /* CoreDataStack.swift in Sources */, + DC6474532D20DD0C004B4BBC /* PersistenceController.swift in Sources */, 30697C3D21F63C990064FCAC /* UIViewExtension.swift in Sources */, 30697C3A21F63C990064FCAC /* UIViewControllerExtension.swift in Sources */, 30697C2E21F63C5A0064FCAC /* Utils.swift in Sources */, diff --git a/passKit/Controllers/CoreDataStack.swift b/passKit/Controllers/PersistenceController.swift similarity index 98% rename from passKit/Controllers/CoreDataStack.swift rename to passKit/Controllers/PersistenceController.swift index 0452259..67ad4fe 100644 --- a/passKit/Controllers/CoreDataStack.swift +++ b/passKit/Controllers/PersistenceController.swift @@ -1,5 +1,5 @@ // -// CoreDataStack.swift +// PersistenceController.swift // passKit // // Created by Mingshen Sun on 12/28/24. From b8b7e1f913e79071d7441d187b72c4e45a4d5c57 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 14:04:12 +0100 Subject: [PATCH 17/42] PersistenceController tests --- pass.xcodeproj/project.pbxproj | 12 +++ .../Controllers/PersistenceController.swift | 12 +-- .../PersistenceControllerTest.swift | 93 +++++++++++++++++++ passKitTests/CoreData/CoreDataTestCase.swift | 2 +- 4 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 passKitTests/Controllers/PersistenceControllerTest.swift diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index 95c542d..952867f 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ 5F9D7B0E27AF6FCA00A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */; }; + 8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */; }; 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 8AD8EBF22F5E268D007475AB /* Fixtures */; }; 9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; }; 9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; }; @@ -425,6 +426,7 @@ 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; }; 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeychainTest.swift; sourceTree = ""; }; + 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerTest.swift; sourceTree = ""; }; 8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = ""; }; 9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = ""; }; 9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = ""; }; @@ -766,6 +768,14 @@ path = Crypto; sourceTree = ""; }; + 8A4716702F5EF7A900C7A64D /* Controllers */ = { + isa = PBXGroup; + children = ( + 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */, + ); + path = Controllers; + sourceTree = ""; + }; 9A58664F25AADB66006719C2 /* Services */ = { isa = PBXGroup; children = ( @@ -884,6 +894,7 @@ A26075861EEC6F34005DB03E /* passKitTests */ = { isa = PBXGroup; children = ( + 8A4716702F5EF7A900C7A64D /* Controllers */, DC64745A2D29BD43004B4BBC /* CoreData */, 30A86F93230F235800F821A4 /* Crypto */, 30BAC8C322E3BA4300438475 /* Testbase */, @@ -1639,6 +1650,7 @@ 30A86F95230F237000F821A4 /* CryptoFrameworkTest.swift in Sources */, 30A1D2AC21B32C2A00E2D1F7 /* TokenBuilderTest.swift in Sources */, 30DAFD4C240985E3002456E7 /* Array+SlicesTest.swift in Sources */, + 8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */, 301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */, 9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */, 30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */, diff --git a/passKit/Controllers/PersistenceController.swift b/passKit/Controllers/PersistenceController.swift index 67ad4fe..5d05001 100644 --- a/passKit/Controllers/PersistenceController.swift +++ b/passKit/Controllers/PersistenceController.swift @@ -18,19 +18,19 @@ public class PersistenceController { let container: NSPersistentContainer - init(isUnitTest: Bool = false) { + init(storeURL: URL? = nil) { self.container = NSPersistentContainer(name: Self.modelName, managedObjectModel: .sharedModel) let description = container.persistentStoreDescriptions.first description?.shouldMigrateStoreAutomatically = false description?.shouldInferMappingModelAutomatically = false - if isUnitTest { - description?.url = URL(fileURLWithPath: "/dev/null") - } else { - description?.url = URL(fileURLWithPath: Globals.dbPath) - } + description?.url = storeURL ?? URL(fileURLWithPath: Globals.dbPath) setup() } + static func forUnitTests() -> PersistenceController { + PersistenceController(storeURL: URL(fileURLWithPath: "/dev/null")) + } + func setup() { container.loadPersistentStores { _, error in if error != nil { diff --git a/passKitTests/Controllers/PersistenceControllerTest.swift b/passKitTests/Controllers/PersistenceControllerTest.swift new file mode 100644 index 0000000..4ae642f --- /dev/null +++ b/passKitTests/Controllers/PersistenceControllerTest.swift @@ -0,0 +1,93 @@ +// +// PersistenceControllerTest.swift +// passKitTests +// +// Created by Lysann Tranvouez on 9/3/26. +// Copyright © 2026 Bob Sun. All rights reserved. +// + +import CoreData +import XCTest + +@testable import passKit + +final class PersistenceControllerTest: XCTestCase { + func testModelLoads() { + let controller = PersistenceController.forUnitTests() + let context = controller.viewContext() + + let entityNames = context.persistentStoreCoordinator!.managedObjectModel.entities.map(\.name) + XCTAssertEqual(entityNames, ["PasswordEntity"]) + } + + func testInsertAndFetch() { + let controller = PersistenceController.forUnitTests() + let context = controller.viewContext() + XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 0) + + PasswordEntity.insert(name: "test", path: "test.gpg", isDir: false, into: context) + try? context.save() + + XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 1) + } + + func testReinitializePersistentStoreClearsData() { + let controller = PersistenceController.forUnitTests() + let context = controller.viewContext() + + PasswordEntity.insert(name: "test1", path: "test1.gpg", isDir: false, into: context) + PasswordEntity.insert(name: "test2", path: "test2.gpg", isDir: false, into: context) + try? context.save() + XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 2) + + controller.reinitializePersistentStore() + + // After reinitialize, old data should be gone + // (reinitializePersistentStore calls initPasswordEntityCoreData with the default repo URL, + // which won't exist in tests, so the result should be an empty store) + let remaining = PasswordEntity.fetchAll(in: context) + XCTAssertEqual(remaining.count, 0) + } + + func testMultipleControllersAreIndependent() { + let controller1 = PersistenceController.forUnitTests() + let controller2 = PersistenceController.forUnitTests() + + let context1 = controller1.viewContext() + let context2 = controller2.viewContext() + + PasswordEntity.insert(name: "only-in-1", path: "only-in-1.gpg", isDir: false, into: context1) + try? context1.save() + + XCTAssertEqual(PasswordEntity.fetchAll(in: context1).count, 1) + XCTAssertEqual(PasswordEntity.fetchAll(in: context2).count, 0) + } + + func testSaveAndLoadFromFile() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let storeURL = tempDir.appendingPathComponent("test.sqlite") + + // Write + let controller1 = PersistenceController(storeURL: storeURL) + let context1 = controller1.viewContext() + PasswordEntity.insert(name: "saved", path: "saved.gpg", isDir: false, into: context1) + PasswordEntity.insert(name: "dir", path: "dir", isDir: true, into: context1) + controller1.save() + + // Load in a fresh controller from the same file + let controller2 = PersistenceController(storeURL: storeURL) + let context2 = controller2.viewContext() + let allEntities = PasswordEntity.fetchAll(in: context2) + + XCTAssertEqual(allEntities.count, 2) + XCTAssertNotNil(allEntities.first { $0.name == "saved" && !$0.isDir }) + XCTAssertNotNil(allEntities.first { $0.name == "dir" && $0.isDir }) + } + + func testSaveError() throws { + // NOTE: save() calls fatalError on Core Data save failures, so error propagation + // cannot be tested without refactoring save() to throw... + } +} diff --git a/passKitTests/CoreData/CoreDataTestCase.swift b/passKitTests/CoreData/CoreDataTestCase.swift index fa356bf..b11ddd1 100644 --- a/passKitTests/CoreData/CoreDataTestCase.swift +++ b/passKitTests/CoreData/CoreDataTestCase.swift @@ -20,7 +20,7 @@ class CoreDataTestCase: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() - controller = PersistenceController(isUnitTest: true) + controller = PersistenceController.forUnitTests() } override func tearDown() { From 55b682b4b0cf7e836be1cd099e4010178440d439 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 22:16:15 +0100 Subject: [PATCH 18/42] improve directory deletion/editing handling --- pass/de.lproj/Localizable.strings | 1 + pass/en.lproj/Localizable.strings | 2 +- passKit/Helpers/AppError.swift | 2 +- passKit/Models/PasswordStore.swift | 13 +++- .../password-store-empty-dirs.git/HEAD | 1 + .../password-store-empty-dirs.git/config | 6 ++ .../password-store-empty-dirs.git/description | 1 + .../info/exclude | 6 ++ .../44/73d8218dcffe837e18d56d74c240e565461aea | Bin 0 -> 131 bytes .../4c/f9f177c4c015836fca6a31f9c3917e89ae29ec | Bin 0 -> 53 bytes .../50/96ac11d1376ea9b22ddedac1130f45ec618d11 | Bin 0 -> 57 bytes .../ce/013625030ba8dba906f756967f9e9ca394464a | Bin 0 -> 21 bytes .../d5/64d0bc3dd917926892c55e3706cc116d5b165e | Bin 0 -> 53 bytes .../e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 | Bin 0 -> 15 bytes .../fb/cbb5819e1c864ef33cfffa179a71387a5d90d0 | Bin 0 -> 131 bytes .../password-store-empty-dirs.git/packed-refs | 2 + passKitTests/Models/PasswordStoreTest.swift | 59 +++++++++++++++++- 17 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 passKitTests/Fixtures/password-store-empty-dirs.git/HEAD create mode 100644 passKitTests/Fixtures/password-store-empty-dirs.git/config create mode 100644 passKitTests/Fixtures/password-store-empty-dirs.git/description create mode 100644 passKitTests/Fixtures/password-store-empty-dirs.git/info/exclude create mode 100644 passKitTests/Fixtures/password-store-empty-dirs.git/objects/44/73d8218dcffe837e18d56d74c240e565461aea create mode 100644 passKitTests/Fixtures/password-store-empty-dirs.git/objects/4c/f9f177c4c015836fca6a31f9c3917e89ae29ec create mode 100644 passKitTests/Fixtures/password-store-empty-dirs.git/objects/50/96ac11d1376ea9b22ddedac1130f45ec618d11 create mode 100644 passKitTests/Fixtures/password-store-empty-dirs.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a create mode 100644 passKitTests/Fixtures/password-store-empty-dirs.git/objects/d5/64d0bc3dd917926892c55e3706cc116d5b165e create mode 100644 passKitTests/Fixtures/password-store-empty-dirs.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 create mode 100644 passKitTests/Fixtures/password-store-empty-dirs.git/objects/fb/cbb5819e1c864ef33cfffa179a71387a5d90d0 create mode 100644 passKitTests/Fixtures/password-store-empty-dirs.git/packed-refs diff --git a/pass/de.lproj/Localizable.strings b/pass/de.lproj/Localizable.strings index 8177a35..355b200 100644 --- a/pass/de.lproj/Localizable.strings +++ b/pass/de.lproj/Localizable.strings @@ -72,6 +72,7 @@ "KeyImportError." = "Schlüssel kann nicht importiert werden."; "FileNotFoundError." = "Die Datei '%@' kann nicht gelesen werden."; "PasswordDuplicatedError." = "Passwort kann nicht hinzugefügt werden; es existiert bereits."; +"CannotDeleteNonEmptyDirectoryError." = "Ordner muss erst leer sein um gelöscht werden zu können."; "GitResetError." = "Der zuletzt synchronisierte Commit kann nicht identifiziert werden."; "GitCreateSignatureError." = "Es konnte keine valide Signatur für den Author/Committer angelegt werden."; "GitPushNotSuccessfulError." = "Die Übertragung der lokalen Änderungen war nicht erfolgreich. Stelle bitte sicher, dass auf dem Remote-Repository alle Änderungen commitet sind."; diff --git a/pass/en.lproj/Localizable.strings b/pass/en.lproj/Localizable.strings index 10d8b30..a4f74e3 100644 --- a/pass/en.lproj/Localizable.strings +++ b/pass/en.lproj/Localizable.strings @@ -73,7 +73,7 @@ "KeyImportError." = "Cannot import the key."; "FileNotFoundError." = "File '%@' cannot be read."; "PasswordDuplicatedError." = "Cannot add the password; password is duplicated."; -"CannotDeleteDirectoryError." = "Cannot delete directories; delete passwords instead."; +"CannotDeleteNonEmptyDirectoryError." = "Delete passwords from the directory before deleting the directory itself."; "GitResetError." = "Cannot identify the latest synced commit."; "GitCreateSignatureError." = "Cannot create a valid author/committer signature."; "GitPushNotSuccessfulError." = "Pushing local changes was not successful. Make sure there are no uncommitted changes on the remote repository."; diff --git a/passKit/Helpers/AppError.swift b/passKit/Helpers/AppError.swift index 8bcc84b..28ceb73 100644 --- a/passKit/Helpers/AppError.swift +++ b/passKit/Helpers/AppError.swift @@ -15,7 +15,7 @@ public enum AppError: Error, Equatable { case keyImport case readingFile(fileName: String) case passwordDuplicated - case cannotDeleteDirectory + case cannotDeleteNonEmptyDirectory case gitReset case gitCommit case gitCreateSignature diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index 78c92c4..b918ab4 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -273,13 +273,15 @@ public class PasswordStore { } public func delete(passwordEntity: PasswordEntity) throws { - if passwordEntity.isDir { - throw AppError.cannotDeleteDirectory + if !passwordEntity.children.isEmpty { + throw AppError.cannotDeleteNonEmptyDirectory } let deletedFileURL = passwordEntity.fileURL(in: storeURL) let deletedFilePath = passwordEntity.path - try gitRm(path: passwordEntity.path) + if !passwordEntity.isDir { + try gitRm(path: passwordEntity.path) + } try deletePasswordEntities(passwordEntity: passwordEntity) try deleteDirectoryTree(at: deletedFileURL) try gitCommit(message: "RemovePassword.".localize(deletedFilePath)) @@ -287,6 +289,11 @@ public class PasswordStore { } public func edit(passwordEntity: PasswordEntity, password: Password, keyID: String? = nil) throws -> PasswordEntity? { + guard !passwordEntity.isDir else { + // caller should ensure this, so this is not a user-facing error + throw AppError.other(message: "Cannot edit a directory") + } + var newPasswordEntity: PasswordEntity? = passwordEntity let url = passwordEntity.fileURL(in: storeURL) diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/HEAD b/passKitTests/Fixtures/password-store-empty-dirs.git/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty-dirs.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/config b/passKitTests/Fixtures/password-store-empty-dirs.git/config new file mode 100644 index 0000000..e6da231 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty-dirs.git/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + ignorecase = true + precomposeunicode = true diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/description b/passKitTests/Fixtures/password-store-empty-dirs.git/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty-dirs.git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/info/exclude b/passKitTests/Fixtures/password-store-empty-dirs.git/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty-dirs.git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/44/73d8218dcffe837e18d56d74c240e565461aea b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/44/73d8218dcffe837e18d56d74c240e565461aea new file mode 100644 index 0000000000000000000000000000000000000000..6fa149a030a864de0d0dd70acc3d69ad9e8ae592 GIT binary patch literal 131 zcmV-}0DS*=0V^p=O;s>7G-oh0FfcPQQP4{-NY~9wVF;MEM)0C}-pWn7_ih~&=68LQ z*ehsa00astnMJzgnI##z`6U^tMY?I3IjIajKR=cqIUw4ce=5uH=i!NUo$EB;KvbvZ l7L-)#0`)LlO}Vhg_NMrxj7dl1%-PNe=0=Of0RVWLF+DgWJPiN< literal 0 HcmV?d00001 diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/4c/f9f177c4c015836fca6a31f9c3917e89ae29ec b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/4c/f9f177c4c015836fca6a31f9c3917e89ae29ec new file mode 100644 index 0000000000000000000000000000000000000000..1c17f204ab847733104e72fef4ac750bd82d3e28 GIT binary patch literal 53 zcmV-50LuS(0V^p=O;s>9V=y!@Ff%bxNXyJg)hnqeVK~QVrpnB{;`U0m?_tyG=gnC> L#mx%x literal 0 HcmV?d00001 diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 new file mode 100644 index 0000000000000000000000000000000000000000..711223894375fe1186ac5bfffdc48fb1fa1e65cc GIT binary patch literal 15 WcmbD(;O_Aj@1=EF ziU5wdlg13z29x7}F{f$bXaaxG`5bH-tY+t(^+-&Ly4=