From 79a5d43e8db6bb959c87bd915693de9869ce095c Mon Sep 17 00:00:00 2001 From: Majid Achhoud Date: Wed, 21 Aug 2024 15:57:39 +0200 Subject: [PATCH] Added Box support (#34) Co-authored-by: Tobias Hagemann --- .../project.pbxproj | 82 +++ .../xcshareddata/swiftpm/Package.resolved | 9 + .../xcschemes/BoxIntegrationTests.xcscheme | 129 ++++ .../DropboxIntegrationTests.xcscheme | 9 + .../GoogleDriveIntegrationTests.xcscheme | 9 + .../LocalFileSystemIntegrationTests.xcscheme | 9 + .../OneDriveIntegrationTests.xcscheme | 9 + .../xcschemes/PCloudIntegrationTests.xcscheme | 9 + .../xcschemes/S3IntegrationTests.xcscheme | 9 + .../xcschemes/WebDAVIntegrationTests.xcscheme | 9 + Package.resolved | 9 + Package.swift | 9 +- README.md | 37 ++ .../Box/BoxAuthenticator.swift | 47 ++ .../Box/BoxCloudProvider.swift | 599 ++++++++++++++++++ .../Box/BoxCredential.swift | 71 +++ .../CryptomatorCloudAccess/Box/BoxError.swift | 14 + .../Box/BoxIdentifierCache.swift | 44 ++ .../CryptomatorCloudAccess/Box/BoxItem.swift | 44 ++ .../CryptomatorCloudAccess/Box/BoxSetup.swift | 23 + .../Box/BoxAuthenticationMock.swift | 32 + .../BoxCloudProviderIntegrationTests.swift | 47 ++ .../Box/BoxCredentialMock.swift | 34 + .../VaultFormat6BoxIntegrationTests.swift | 74 +++ .../VaultFormat7BoxIntegrationTests.swift | 74 +++ .../Dropbox/DropboxCredentialMock.swift | 1 - .../README.md | 3 + create-integration-test-secrets-file.sh | 3 + 28 files changed, 1444 insertions(+), 4 deletions(-) create mode 100644 CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/BoxIntegrationTests.xcscheme create mode 100644 Sources/CryptomatorCloudAccess/Box/BoxAuthenticator.swift create mode 100644 Sources/CryptomatorCloudAccess/Box/BoxCloudProvider.swift create mode 100644 Sources/CryptomatorCloudAccess/Box/BoxCredential.swift create mode 100644 Sources/CryptomatorCloudAccess/Box/BoxError.swift create mode 100644 Sources/CryptomatorCloudAccess/Box/BoxIdentifierCache.swift create mode 100644 Sources/CryptomatorCloudAccess/Box/BoxItem.swift create mode 100644 Sources/CryptomatorCloudAccess/Box/BoxSetup.swift create mode 100644 Tests/CryptomatorCloudAccessIntegrationTests/Box/BoxAuthenticationMock.swift create mode 100644 Tests/CryptomatorCloudAccessIntegrationTests/Box/BoxCloudProviderIntegrationTests.swift create mode 100644 Tests/CryptomatorCloudAccessIntegrationTests/Box/BoxCredentialMock.swift create mode 100644 Tests/CryptomatorCloudAccessIntegrationTests/CryptoDecorator/VaultFormat6/VaultFormat6BoxIntegrationTests.swift create mode 100644 Tests/CryptomatorCloudAccessIntegrationTests/CryptoDecorator/VaultFormat7/VaultFormat7BoxIntegrationTests.swift diff --git a/CryptomatorCloudAccess.xcodeproj/project.pbxproj b/CryptomatorCloudAccess.xcodeproj/project.pbxproj index 298dc6b..ff3fe7d 100644 --- a/CryptomatorCloudAccess.xcodeproj/project.pbxproj +++ b/CryptomatorCloudAccess.xcodeproj/project.pbxproj @@ -148,6 +148,7 @@ 748A42B824AA231D00DEB6D0 /* WebDAVAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748A42B724AA231D00DEB6D0 /* WebDAVAuthenticator.swift */; }; 748A42BA24AA34F300DEB6D0 /* WebDAVCredential.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748A42B924AA34F300DEB6D0 /* WebDAVCredential.swift */; }; 748A42C024AB424500DEB6D0 /* WebDAVClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748A42BF24AB424500DEB6D0 /* WebDAVClient.swift */; }; + 748BAD5B2C76146B008534E1 /* BoxAuthenticationMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748BAD5A2C76146B008534E1 /* BoxAuthenticationMock.swift */; }; 748BD4CA24B4B1D50001CA8C /* PropfindResponseParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748BD4C924B4B1D50001CA8C /* PropfindResponseParser.swift */; }; 748BD4CC24B4D3820001CA8C /* Date+RFC822.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748BD4CB24B4D3820001CA8C /* Date+RFC822.swift */; }; 7494505F24BC5C3300149816 /* PropfindResponseParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7494505E24BC5C3300149816 /* PropfindResponseParserTests.swift */; }; @@ -186,6 +187,18 @@ 9ED0E624246198F600FDB438 /* VaultFormat7CloudProviderMockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ED0E623246198F600FDB438 /* VaultFormat7CloudProviderMockTests.swift */; }; 9EE62A0D247D54760089DAF7 /* CloudProvider+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE62A0C247D54760089DAF7 /* CloudProvider+Convenience.swift */; }; 9EE62A10247D54E90089DAF7 /* CloudProvider+ConvenienceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE62A0F247D54E90089DAF7 /* CloudProvider+ConvenienceTests.swift */; }; + B322A2BB2BDF7F9F00306F01 /* VaultFormat7BoxIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B322A2BA2BDF7F9F00306F01 /* VaultFormat7BoxIntegrationTests.swift */; }; + B322A2BD2BDF7FBE00306F01 /* VaultFormat6BoxIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B322A2BC2BDF7FBE00306F01 /* VaultFormat6BoxIntegrationTests.swift */; }; + B3408AC82BCD32CA005271D2 /* BoxCredentialMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3408AC72BCD32CA005271D2 /* BoxCredentialMock.swift */; }; + B3408ACA2BCDAA09005271D2 /* BoxError.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3408AC92BCDAA09005271D2 /* BoxError.swift */; }; + B3D513912BA9A32200DE0D36 /* BoxAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D5138D2BA9A32200DE0D36 /* BoxAuthenticator.swift */; }; + B3D513922BA9A32200DE0D36 /* BoxCredential.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D5138E2BA9A32200DE0D36 /* BoxCredential.swift */; }; + B3D513932BA9A32200DE0D36 /* BoxSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D5138F2BA9A32200DE0D36 /* BoxSetup.swift */; }; + B3D513972BA9A44000DE0D36 /* BoxCloudProviderIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D513952BA9A3BB00DE0D36 /* BoxCloudProviderIntegrationTests.swift */; }; + B3D620D22BFC7B2E007301C1 /* BoxSdkGen in Frameworks */ = {isa = PBXBuildFile; productRef = B3D620D12BFC7B2E007301C1 /* BoxSdkGen */; }; + B3FC94A62BA9AA4400D1ECFD /* BoxCloudProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3FC94A52BA9AA4400D1ECFD /* BoxCloudProvider.swift */; }; + B3FC94A82BA9AEEC00D1ECFD /* BoxIdentifierCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3FC94A72BA9AEEC00D1ECFD /* BoxIdentifierCache.swift */; }; + B3FC94AA2BA9AEFC00D1ECFD /* BoxItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3FC94A92BA9AEFC00D1ECFD /* BoxItem.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -339,6 +352,7 @@ 748A42B724AA231D00DEB6D0 /* WebDAVAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDAVAuthenticator.swift; sourceTree = ""; }; 748A42B924AA34F300DEB6D0 /* WebDAVCredential.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDAVCredential.swift; sourceTree = ""; }; 748A42BF24AB424500DEB6D0 /* WebDAVClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDAVClient.swift; sourceTree = ""; }; + 748BAD5A2C76146B008534E1 /* BoxAuthenticationMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoxAuthenticationMock.swift; sourceTree = ""; }; 748BD4C924B4B1D50001CA8C /* PropfindResponseParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropfindResponseParser.swift; sourceTree = ""; }; 748BD4CB24B4D3820001CA8C /* Date+RFC822.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+RFC822.swift"; sourceTree = ""; }; 7494505E24BC5C3300149816 /* PropfindResponseParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropfindResponseParserTests.swift; sourceTree = ""; }; @@ -374,6 +388,17 @@ 9ED0E623246198F600FDB438 /* VaultFormat7CloudProviderMockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultFormat7CloudProviderMockTests.swift; sourceTree = ""; }; 9EE62A0C247D54760089DAF7 /* CloudProvider+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudProvider+Convenience.swift"; sourceTree = ""; }; 9EE62A0F247D54E90089DAF7 /* CloudProvider+ConvenienceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudProvider+ConvenienceTests.swift"; sourceTree = ""; }; + B322A2BA2BDF7F9F00306F01 /* VaultFormat7BoxIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultFormat7BoxIntegrationTests.swift; sourceTree = ""; }; + B322A2BC2BDF7FBE00306F01 /* VaultFormat6BoxIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultFormat6BoxIntegrationTests.swift; sourceTree = ""; }; + B3408AC72BCD32CA005271D2 /* BoxCredentialMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoxCredentialMock.swift; sourceTree = ""; }; + B3408AC92BCDAA09005271D2 /* BoxError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoxError.swift; sourceTree = ""; }; + B3D5138D2BA9A32200DE0D36 /* BoxAuthenticator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BoxAuthenticator.swift; sourceTree = ""; }; + B3D5138E2BA9A32200DE0D36 /* BoxCredential.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BoxCredential.swift; sourceTree = ""; }; + B3D5138F2BA9A32200DE0D36 /* BoxSetup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BoxSetup.swift; sourceTree = ""; }; + B3D513952BA9A3BB00DE0D36 /* BoxCloudProviderIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoxCloudProviderIntegrationTests.swift; sourceTree = ""; }; + B3FC94A52BA9AA4400D1ECFD /* BoxCloudProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoxCloudProvider.swift; sourceTree = ""; }; + B3FC94A72BA9AEEC00D1ECFD /* BoxIdentifierCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoxIdentifierCache.swift; sourceTree = ""; }; + B3FC94A92BA9AEFC00D1ECFD /* BoxItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoxItem.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -395,6 +420,7 @@ 746F090E27BC0932003FCD9F /* PCloudSDKSwift in Frameworks */, 4A567B372615CAAC002C4D82 /* GTMSessionFetcher in Frameworks */, 4A567B1A2615C917002C4D82 /* GTMAppAuth in Frameworks */, + B3D620D22BFC7B2E007301C1 /* BoxSdkGen in Frameworks */, 4A8B872F287D7E77002D676E /* CocoaLumberjackSwift in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -443,6 +469,7 @@ 4A058FF424519FFC008831F9 /* CryptomatorCloudAccess.h */, 4A058FF524519FFC008831F9 /* Info.plist */, 4A0590162451A1BB008831F9 /* API */, + B3D513902BA9A32200DE0D36 /* Box */, 4A741FF0287C696F00489C23 /* Common */, 7416F22424F658160074DA8E /* Crypto */, 4A567AF02615C2DE002C4D82 /* Dropbox */, @@ -604,6 +631,7 @@ 4ACA63B02615FE8000D19304 /* CloudAccessIntegrationTestWithAuthentication.swift */, 4ACA63A42615FE5700D19304 /* IntegrationTestError.swift */, 4ACA64252616054F00D19304 /* IntegrationTestSecrets.swift */, + B3D513942BA9A37A00DE0D36 /* Box */, 4ACA63BF2615FEB200D19304 /* CryptoDecorator */, 4ACA63F02615FF9700D19304 /* Dropbox */, 4ACA63F92615FF9700D19304 /* Extensions */, @@ -630,6 +658,7 @@ 4ACA63DA2615FF3B00D19304 /* VaultFormat6 */ = { isa = PBXGroup; children = ( + B322A2BC2BDF7FBE00306F01 /* VaultFormat6BoxIntegrationTests.swift */, 4ACA63CB2615FF0000D19304 /* VaultFormat6DropboxIntegrationTests.swift */, 4ACA63D02615FF1600D19304 /* VaultFormat6GoogleDriveIntegrationTests.swift */, 4ACA63C62615FED700D19304 /* VaultFormat6LocalFileSystemIntegrationTests.swift */, @@ -644,6 +673,7 @@ 4ACA63E12615FF6400D19304 /* VaultFormat7 */ = { isa = PBXGroup; children = ( + B322A2BA2BDF7F9F00306F01 /* VaultFormat7BoxIntegrationTests.swift */, 4ACA63E42615FF6400D19304 /* VaultFormat7DropboxIntegrationTests.swift */, 4ACA63E22615FF6400D19304 /* VaultFormat7GoogleDriveIntegrationTests.swift */, 4ACA63E52615FF6400D19304 /* VaultFormat7LocalFileSystemIntegrationTests.swift */, @@ -968,6 +998,31 @@ path = API; sourceTree = ""; }; + B3D513902BA9A32200DE0D36 /* Box */ = { + isa = PBXGroup; + children = ( + B3D5138D2BA9A32200DE0D36 /* BoxAuthenticator.swift */, + B3FC94A52BA9AA4400D1ECFD /* BoxCloudProvider.swift */, + B3D5138E2BA9A32200DE0D36 /* BoxCredential.swift */, + B3408AC92BCDAA09005271D2 /* BoxError.swift */, + B3FC94A72BA9AEEC00D1ECFD /* BoxIdentifierCache.swift */, + B3FC94A92BA9AEFC00D1ECFD /* BoxItem.swift */, + B3D5138F2BA9A32200DE0D36 /* BoxSetup.swift */, + ); + name = Box; + path = Sources/CryptomatorCloudAccess/Box; + sourceTree = SOURCE_ROOT; + }; + B3D513942BA9A37A00DE0D36 /* Box */ = { + isa = PBXGroup; + children = ( + 748BAD5A2C76146B008534E1 /* BoxAuthenticationMock.swift */, + B3D513952BA9A3BB00DE0D36 /* BoxCloudProviderIntegrationTests.swift */, + B3408AC72BCD32CA005271D2 /* BoxCredentialMock.swift */, + ); + path = Box; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1014,6 +1069,7 @@ 4A75E1C628806FA100952FE6 /* MSGraphClientSDK */, 4A75E1C928806FF000952FE6 /* ObjectiveDropboxOfficial */, 4A8B872E287D7E77002D676E /* CocoaLumberjackSwift */, + B3D620D12BFC7B2E007301C1 /* BoxSdkGen */, ); productName = CloudAccess; productReference = 4A058FF124519FFC008831F9 /* CryptomatorCloudAccess.framework */; @@ -1104,6 +1160,7 @@ 4A75E1C528806FA100952FE6 /* XCRemoteSwiftPackageReference "msgraph-sdk-objc-spm" */, 4A75E1C828806FF000952FE6 /* XCRemoteSwiftPackageReference "dropbox-sdk-obj-c-spm" */, 4A8B872D287D7E77002D676E /* XCRemoteSwiftPackageReference "CocoaLumberjack" */, + B3C72B752BFC798A006F8218 /* XCRemoteSwiftPackageReference "box-swift-sdk-gen" */, ); productRefGroup = 4A058FF224519FFC008831F9 /* Products */; projectDirPath = ""; @@ -1224,9 +1281,12 @@ 74C0FB2729B209B6008EF811 /* S3Authenticator.swift in Sources */, 747A77FF2698577E005E5AD4 /* GTLRDrive_File+CloudItemType.swift in Sources */, 4A1A1183262B078E00DAF62F /* OneDriveError.swift in Sources */, + B3D513922BA9A32200DE0D36 /* BoxCredential.swift in Sources */, 740C144E249B4F2B008CA3E0 /* VaultFormat7ShorteningProviderDecorator.swift in Sources */, 74FD6C4824F6F3AA00C8D3C4 /* VaultFormat6ShorteningProviderDecorator.swift in Sources */, 4A567B222615CA24002C4D82 /* GoogleDriveIdentifierCache.swift in Sources */, + B3D513912BA9A32200DE0D36 /* BoxAuthenticator.swift in Sources */, + B3FC94AA2BA9AEFC00D1ECFD /* BoxItem.swift in Sources */, 4A0421822642B9260033144A /* VaultProviderFactory.swift in Sources */, 4A567B102615C6F3002C4D82 /* DropboxError.swift in Sources */, 4A567AED2615C2D7002C4D82 /* DropboxAuthenticator.swift in Sources */, @@ -1243,6 +1303,7 @@ 7471BDAE24865B6F000D05FC /* LocalFileSystemProvider.swift in Sources */, 7484608729795421009933D8 /* VaultConfigHelper.swift in Sources */, 4ABE9AA32BCAC8FE00675D74 /* Promise+Async.swift in Sources */, + B3FC94A62BA9AA4400D1ECFD /* BoxCloudProvider.swift in Sources */, 74073D1927C9406000A86C9A /* Task+Promises.swift in Sources */, 4A1A1194262EC46E00DAF62F /* OneDriveIdentifierCache.swift in Sources */, 746F091327BC0DA2003FCD9F /* PCloudCredential.swift in Sources */, @@ -1284,6 +1345,7 @@ 4A1A11792629ACD500DAF62F /* OneDriveAuthenticator.swift in Sources */, 748BD4CA24B4B1D50001CA8C /* PropfindResponseParser.swift in Sources */, 4A567B322615CA6E002C4D82 /* GoogleDriveError.swift in Sources */, + B3FC94A82BA9AEEC00D1ECFD /* BoxIdentifierCache.swift in Sources */, 4A0785302859F4FE0015DAE1 /* S3Credential.swift in Sources */, 748A42B824AA231D00DEB6D0 /* WebDAVAuthenticator.swift in Sources */, 74F4AA1525ED3D2A00FDF2C6 /* VaultConfig.swift in Sources */, @@ -1291,7 +1353,9 @@ 4AD55339263ABA4200126046 /* MSGraphDriveItem+CloudItemType.swift in Sources */, 747A77FD269854A6005E5AD4 /* GoogleDriveItem.swift in Sources */, 748A42C024AB424500DEB6D0 /* WebDAVClient.swift in Sources */, + B3D513932BA9A32200DE0D36 /* BoxSetup.swift in Sources */, 4A567B082615C6AF002C4D82 /* DropboxCloudProvider.swift in Sources */, + B3408ACA2BCDAA09005271D2 /* BoxError.swift in Sources */, 74C596E824F022AF00FFD17E /* CloudPath.swift in Sources */, 4A05900C2451A107008831F9 /* CloudProvider.swift in Sources */, 4A567B142615C8B8002C4D82 /* GoogleDriveAuthenticator.swift in Sources */, @@ -1356,6 +1420,7 @@ 4ACA64072615FF9800D19304 /* WebDAVCloudProviderIntegrationTests.swift in Sources */, 4ACA63E72615FF6400D19304 /* VaultFormat7WebDAVIntegrationTests.swift in Sources */, 7470C54D2656A44600E361B8 /* MSALPublicClientApplicationStub.swift in Sources */, + 748BAD5B2C76146B008534E1 /* BoxAuthenticationMock.swift in Sources */, 4ACA63A92615FE5C00D19304 /* IntegrationTestError.swift in Sources */, 4ACA63B12615FE8000D19304 /* CloudAccessIntegrationTestWithAuthentication.swift in Sources */, 4ACA63E92615FF6400D19304 /* VaultFormat7LocalFileSystemIntegrationTests.swift in Sources */, @@ -1370,6 +1435,9 @@ 7467A0D627DF9A8000BCFDF8 /* VaultFormat6PCloudIntegrationTests.swift in Sources */, 4AC75F9C2861A6DE002731FE /* VaultFormat6S3IntegrationTests.swift in Sources */, 4ACA63A02615FE2C00D19304 /* CloudAccessIntegrationTest.swift in Sources */, + B322A2BB2BDF7F9F00306F01 /* VaultFormat7BoxIntegrationTests.swift in Sources */, + B3D513972BA9A44000DE0D36 /* BoxCloudProviderIntegrationTests.swift in Sources */, + B3408AC82BCD32CA005271D2 /* BoxCredentialMock.swift in Sources */, 4ACA64262616054F00D19304 /* IntegrationTestSecrets.swift in Sources */, 4A41D2432641938A00B5D787 /* VaultFormat6OneDriveIntegrationTests.swift in Sources */, 7470C54B26569A7E00E361B8 /* MSAuthenticationProviderMock.swift in Sources */, @@ -1379,6 +1447,7 @@ 4ACA63BB2615FEA600D19304 /* DecoratorFactory.swift in Sources */, 4ACA64042615FF9800D19304 /* GoogleDriveAuthenticatorMock.swift in Sources */, 4ACA64082615FF9800D19304 /* CloudProvider+CreateIntermediateFolderTests.swift in Sources */, + B322A2BD2BDF7FBE00306F01 /* VaultFormat6BoxIntegrationTests.swift in Sources */, 4ACA64022615FF9800D19304 /* DropboxCloudProviderIntegrationTests.swift in Sources */, 4ACA63D62615FF2E00D19304 /* VaultFormat6WebDAVIntegrationTests.swift in Sources */, 4AFC5F67263190BB00744715 /* OneDriveCloudProviderIntegrationTests.swift in Sources */, @@ -1815,6 +1884,14 @@ minimumVersion = 2.3.0; }; }; + B3C72B752BFC798A006F8218 /* XCRemoteSwiftPackageReference "box-swift-sdk-gen" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/box/box-swift-sdk-gen.git"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 0.3.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1893,6 +1970,11 @@ package = 74F93565251F6863001F4ADA /* XCRemoteSwiftPackageReference "promises" */; productName = Promises; }; + B3D620D12BFC7B2E007301C1 /* BoxSdkGen */ = { + isa = XCSwiftPackageProductDependency; + package = B3C72B752BFC798A006F8218 /* XCRemoteSwiftPackageReference "box-swift-sdk-gen" */; + productName = BoxSdkGen; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 4A058FE824519FFC008831F9 /* Project object */; diff --git a/CryptomatorCloudAccess.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CryptomatorCloudAccess.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b2f7e31..70629eb 100644 --- a/CryptomatorCloudAccess.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CryptomatorCloudAccess.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -27,6 +27,15 @@ "version" : "0.9.0" } }, + { + "identity" : "box-swift-sdk-gen", + "kind" : "remoteSourceControl", + "location" : "https://github.com/box/box-swift-sdk-gen.git", + "state" : { + "revision" : "b3fd5bfb78f67639151a6a11ba52bad0587e4d34", + "version" : "0.3.0" + } + }, { "identity" : "cocoalumberjack", "kind" : "remoteSourceControl", diff --git a/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/BoxIntegrationTests.xcscheme b/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/BoxIntegrationTests.xcscheme new file mode 100644 index 0000000..fa130ba --- /dev/null +++ b/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/BoxIntegrationTests.xcscheme @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/DropboxIntegrationTests.xcscheme b/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/DropboxIntegrationTests.xcscheme index bbe328c..d86120b 100644 --- a/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/DropboxIntegrationTests.xcscheme +++ b/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/DropboxIntegrationTests.xcscheme @@ -22,6 +22,9 @@ ReferencedContainer = "container:CryptomatorCloudAccess.xcodeproj"> + + @@ -49,6 +52,9 @@ + + @@ -67,6 +73,9 @@ + + diff --git a/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/GoogleDriveIntegrationTests.xcscheme b/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/GoogleDriveIntegrationTests.xcscheme index 3020a9d..5f7974b 100644 --- a/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/GoogleDriveIntegrationTests.xcscheme +++ b/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/GoogleDriveIntegrationTests.xcscheme @@ -22,6 +22,9 @@ ReferencedContainer = "container:CryptomatorCloudAccess.xcodeproj"> + + @@ -49,6 +52,9 @@ + + @@ -67,6 +73,9 @@ + + diff --git a/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/LocalFileSystemIntegrationTests.xcscheme b/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/LocalFileSystemIntegrationTests.xcscheme index a0e58db..8d4f212 100644 --- a/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/LocalFileSystemIntegrationTests.xcscheme +++ b/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/LocalFileSystemIntegrationTests.xcscheme @@ -22,6 +22,9 @@ ReferencedContainer = "container:CryptomatorCloudAccess.xcodeproj"> + + @@ -49,6 +52,9 @@ + + @@ -67,6 +73,9 @@ + + diff --git a/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/OneDriveIntegrationTests.xcscheme b/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/OneDriveIntegrationTests.xcscheme index 1c78a6a..3c74bd5 100644 --- a/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/OneDriveIntegrationTests.xcscheme +++ b/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/OneDriveIntegrationTests.xcscheme @@ -22,6 +22,9 @@ ReferencedContainer = "container:CryptomatorCloudAccess.xcodeproj"> + + @@ -49,6 +52,9 @@ + + @@ -67,6 +73,9 @@ + + diff --git a/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/PCloudIntegrationTests.xcscheme b/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/PCloudIntegrationTests.xcscheme index c801a2e..4623523 100644 --- a/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/PCloudIntegrationTests.xcscheme +++ b/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/PCloudIntegrationTests.xcscheme @@ -22,6 +22,9 @@ ReferencedContainer = "container:CryptomatorCloudAccess.xcodeproj"> + + @@ -49,6 +52,9 @@ + + @@ -67,6 +73,9 @@ + + diff --git a/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/S3IntegrationTests.xcscheme b/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/S3IntegrationTests.xcscheme index d5f49de..0b75b7d 100644 --- a/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/S3IntegrationTests.xcscheme +++ b/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/S3IntegrationTests.xcscheme @@ -22,6 +22,9 @@ ReferencedContainer = "container:CryptomatorCloudAccess.xcodeproj"> + + @@ -49,6 +52,9 @@ + + @@ -67,6 +73,9 @@ + + diff --git a/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/WebDAVIntegrationTests.xcscheme b/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/WebDAVIntegrationTests.xcscheme index a521130..ee46348 100644 --- a/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/WebDAVIntegrationTests.xcscheme +++ b/CryptomatorCloudAccess.xcodeproj/xcshareddata/xcschemes/WebDAVIntegrationTests.xcscheme @@ -22,6 +22,9 @@ ReferencedContainer = "container:CryptomatorCloudAccess.xcodeproj"> + + @@ -52,6 +55,9 @@ + + @@ -70,6 +76,9 @@ + + diff --git a/Package.resolved b/Package.resolved index 9ad3fb7..13ffe3c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -27,6 +27,15 @@ "version" : "0.9.0" } }, + { + "identity" : "box-swift-sdk-gen", + "kind" : "remoteSourceControl", + "location" : "https://github.com/box/box-swift-sdk-gen.git", + "state" : { + "revision" : "b3fd5bfb78f67639151a6a11ba52bad0587e4d34", + "version" : "0.3.0" + } + }, { "identity" : "cocoalumberjack", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index beacb7c..f619f90 100644 --- a/Package.swift +++ b/Package.swift @@ -14,7 +14,8 @@ let appExtensionUnsafeSources = [ "Dropbox/DropboxAuthenticator.swift", "GoogleDrive/GoogleDriveAuthenticator.swift", "OneDrive/OneDriveAuthenticator.swift", - "PCloud/PCloudAuthenticator.swift" + "PCloud/PCloudAuthenticator.swift", + "Box/BoxAuthenticator.swift" ] let package = Package( @@ -27,9 +28,9 @@ let package = Package( .library(name: "CryptomatorCloudAccessCore", targets: ["CryptomatorCloudAccessCore"]) ], dependencies: [ - .package(url: "https://github.com/tobihagemann/JOSESwift.git", exact: "2.4.1-cryptomator"), .package(url: "https://github.com/AzureAD/microsoft-authentication-library-for-objc.git", .upToNextMinor(from: "1.3.0")), .package(url: "https://github.com/aws-amplify/aws-sdk-ios-spm.git", .upToNextMinor(from: "2.34.0")), + .package(url: "https://github.com/box/box-swift-sdk-gen.git", .upToNextMinor(from: "0.3.0")), .package(url: "https://github.com/cryptomator/cryptolib-swift.git", .upToNextMinor(from: "1.1.0")), .package(url: "https://github.com/CocoaLumberjack/CocoaLumberjack.git", .upToNextMinor(from: "3.8.0")), .package(url: "https://github.com/google/google-api-objectivec-client-for-rest.git", .upToNextMinor(from: "3.4.0")), @@ -41,7 +42,8 @@ let package = Package( .package(url: "https://github.com/pCloud/pcloud-sdk-swift.git", .upToNextMinor(from: "3.2.0")), .package(url: "https://github.com/phil1995/dropbox-sdk-obj-c-spm.git", .upToNextMinor(from: "7.2.0")), .package(url: "https://github.com/phil1995/msgraph-sdk-objc-spm.git", .upToNextMinor(from: "1.0.0")), - .package(url: "https://github.com/phil1995/msgraph-sdk-objc-models-spm.git", .upToNextMinor(from: "1.3.0")) + .package(url: "https://github.com/phil1995/msgraph-sdk-objc-models-spm.git", .upToNextMinor(from: "1.3.0")), + .package(url: "https://github.com/tobihagemann/JOSESwift.git", exact: "2.4.1-cryptomator") ], targets: [ .target( @@ -49,6 +51,7 @@ let package = Package( dependencies: [ .product(name: "AWSCore", package: "aws-sdk-ios-spm"), .product(name: "AWSS3", package: "aws-sdk-ios-spm"), + .product(name: "BoxSdkGen", package: "box-swift-sdk-gen"), .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), .product(name: "CryptomatorCryptoLib", package: "cryptolib-swift"), .product(name: "GoogleAPIClientForREST_Drive", package: "google-api-objectivec-client-for-rest"), diff --git a/README.md b/README.md index ad2e66a..85c7afa 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,43 @@ let cryptoDecorator = try VaultProviderFactory.createLegacyVaultProvider(from: m :warning: This library supports vault version 6 and higher. +### Box + +The following constants must be set once, e.g. in your app delegate: + +```swift +let clientId = ... // your Box client identifier +let clientSecret = ... // your Box client secret +let sharedContainerIdentifier = ... // optional: only needed if you want to create a `BoxCloudProvider` with a background `URLSession` in an app extension +BoxSetup.constants = BoxSetup(clientId: clientId, clientSecret: clientSecret, sharedContainerIdentifier: sharedContainerIdentifier) +``` + +Begin the authentication flow: + +```swift +let tokenStore = BoxTokenStore() +let credential = BoxCredential(tokenStore: tokenStore) +let viewController = ... // the presenting `UIViewController` +BoxAuthenticator.authenticate(credential: credential, from: viewController).then { + // authentication successful +}.catch { error in + // error handling +} +``` + +You can then use the credential to create a Box provider: + +```swift +let provider = BoxCloudProvider(credential: credential) +``` + +Or create a Box provider using a background URLSession: + +```swift +let sessionIdentifier = ... +let provider = BoxCloudProvider.withBackgroundSession(credential: credential, sessionIdentifier: sessionIdentifier) +``` + ### Dropbox Set up the `Info.plist` as described in the [official Dropbox Objective-C SDK](https://github.com/dropbox/dropbox-sdk-obj-c). In addition, the following constants must be set once, e.g. in your app delegate: diff --git a/Sources/CryptomatorCloudAccess/Box/BoxAuthenticator.swift b/Sources/CryptomatorCloudAccess/Box/BoxAuthenticator.swift new file mode 100644 index 0000000..c09bc6e --- /dev/null +++ b/Sources/CryptomatorCloudAccess/Box/BoxAuthenticator.swift @@ -0,0 +1,47 @@ +// +// BoxAuthenticator.swift +// CryptomatorCloudAccess +// +// Created by Majid Achhoud on 18.03.24. +// Copyright © 2024 Skymatic GmbH. All rights reserved. +// + +#if canImport(CryptomatorCloudAccessCore) +import CryptomatorCloudAccessCore +#endif +import AuthenticationServices +import BoxSdkGen +import Promises +import UIKit + +public enum BoxAuthenticatorError: Error { + case authenticationFailed + case invalidContext + case userCanceled +} + +public enum BoxAuthenticator { + public static func authenticate(from viewController: UIViewController, tokenStorage: TokenStorage) -> Promise { + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + guard let context = viewController as? ASWebAuthenticationPresentationContextProviding else { + throw BoxAuthenticatorError.invalidContext + } + let config = OAuthConfig(clientId: BoxSetup.constants.clientId, clientSecret: BoxSetup.constants.clientSecret, tokenStorage: tokenStorage) + let oauth = BoxOAuth(config: config) + try await oauth.runLoginFlow(options: .init(), context: context) // access token is implictly saved in token storage + pendingPromise.fulfill(BoxCredential(tokenStorage: tokenStorage)) + } catch let error as ASWebAuthenticationSessionError { + if error.code == .canceledLogin { + CloudAccessDDLogDebug("BoxAuthenticator: Login flow canceled by the user.") + pendingPromise.reject(BoxAuthenticatorError.userCanceled) + } else { + CloudAccessDDLogDebug("BoxAuthenticator: Authentication failed with error: \(error.localizedDescription).") + pendingPromise.reject(BoxAuthenticatorError.authenticationFailed) + } + } + } + return pendingPromise + } +} diff --git a/Sources/CryptomatorCloudAccess/Box/BoxCloudProvider.swift b/Sources/CryptomatorCloudAccess/Box/BoxCloudProvider.swift new file mode 100644 index 0000000..2bb3ee2 --- /dev/null +++ b/Sources/CryptomatorCloudAccess/Box/BoxCloudProvider.swift @@ -0,0 +1,599 @@ +// +// BoxCloudProvider.swift +// CryptomatorCloudAccess +// +// Created by Majid Achhoud on 19.03.24. +// Copyright © 2024 Skymatic GmbH. All rights reserved. +// + +import BoxSdkGen +import Foundation +import Promises + +public class BoxCloudProvider: CloudProvider { + private let client: BoxClient + private let identifierCache: BoxIdentifierCache + private let maxPageSize: Int + + public init(credential: BoxCredential, maxPageSize: Int = .max, urlSessionConfiguration: URLSessionConfiguration) throws { + let networkSession = NetworkSession(configuration: urlSessionConfiguration) + self.client = BoxClient(auth: credential.auth, networkSession: networkSession) + self.identifierCache = try BoxIdentifierCache() + self.maxPageSize = max(1, min(maxPageSize, 1000)) + } + + public convenience init(credential: BoxCredential, maxPageSize: Int = .max) throws { + try self.init(credential: credential, maxPageSize: maxPageSize, urlSessionConfiguration: .default) + } + + public static func withBackgroundSession(credential: BoxCredential, maxPageSize: Int = .max, sessionIdentifier: String) throws -> BoxCloudProvider { + let configuration = URLSessionConfiguration.background(withIdentifier: sessionIdentifier) + configuration.sharedContainerIdentifier = BoxSetup.constants.sharedContainerIdentifier + return try BoxCloudProvider(credential: credential, maxPageSize: maxPageSize, urlSessionConfiguration: configuration) + } + + public func fetchItemMetadata(at cloudPath: CloudPath) -> Promise { + return resolvePath(forItemAt: cloudPath).then { item in + self.fetchItemMetadata(for: item) + } + } + + public func fetchItemList(forFolderAt cloudPath: CloudPath, withPageToken pageToken: String?) -> Promise { + return resolvePath(forItemAt: cloudPath).then { item in + self.fetchItemList(for: item, pageToken: pageToken) + } + } + + public func downloadFile(from cloudPath: CloudPath, to localURL: URL, onTaskCreation: ((URLSessionDownloadTask?) -> Void)?) -> Promise { + precondition(localURL.isFileURL) + if FileManager.default.fileExists(atPath: localURL.path) { + return Promise(CloudProviderError.itemAlreadyExists) + } + return resolvePath(forItemAt: cloudPath).then { item in + self.downloadFile(for: item, to: localURL) + } + } + + public func uploadFile(from localURL: URL, to cloudPath: CloudPath, replaceExisting: Bool, onTaskCreation: ((URLSessionUploadTask?) -> Void)?) -> Promise { + precondition(localURL.isFileURL) + var isDirectory: ObjCBool = false + let fileExists = FileManager.default.fileExists(atPath: localURL.path, isDirectory: &isDirectory) + if !fileExists { + return Promise(CloudProviderError.itemNotFound) + } + if isDirectory.boolValue { + return Promise(CloudProviderError.itemTypeMismatch) + } + return resolveParentPath(forItemAt: cloudPath).then { parentItem in + return self.uploadFile(for: parentItem, from: localURL, to: cloudPath, replaceExisting: replaceExisting) + } + } + + public func createFolder(at cloudPath: CloudPath) -> Promise { + return checkForItemExistence(at: cloudPath).then { itemExists -> Void in + if itemExists { + throw CloudProviderError.itemAlreadyExists + } + }.then { + return self.resolveParentPath(forItemAt: cloudPath) + }.then { parentItem in + return self.createFolder(for: parentItem, with: cloudPath.lastPathComponent) + } + } + + public func deleteFile(at cloudPath: CloudPath) -> Promise { + return resolvePath(forItemAt: cloudPath).then { item in + self.deleteFile(for: item) + } + } + + public func deleteFolder(at cloudPath: CloudPath) -> Promise { + return resolvePath(forItemAt: cloudPath).then { item in + self.deleteFolder(for: item) + } + } + + public func moveFile(from sourceCloudPath: CloudPath, to targetCloudPath: CloudPath) -> Promise { + return checkForItemExistence(at: targetCloudPath).then { itemExists -> Void in + if itemExists { + throw CloudProviderError.itemAlreadyExists + } + }.then { + return all(self.resolvePath(forItemAt: sourceCloudPath), self.resolveParentPath(forItemAt: targetCloudPath)) + }.then { item, targetParentItem in + return self.moveFile(from: item, toParent: targetParentItem, targetCloudPath: targetCloudPath) + } + } + + public func moveFolder(from sourceCloudPath: CloudPath, to targetCloudPath: CloudPath) -> Promise { + return checkForItemExistence(at: targetCloudPath).then { itemExists -> Void in + if itemExists { + throw CloudProviderError.itemAlreadyExists + } + }.then { + return all(self.resolvePath(forItemAt: sourceCloudPath), self.resolveParentPath(forItemAt: targetCloudPath)) + }.then { item, targetParentItem in + return self.moveFolder(from: item, toParent: targetParentItem, targetCloudPath: targetCloudPath) + } + } + + // MARK: - Operations + + private func fetchItemMetadata(for item: BoxItem) -> Promise { + if item.itemType == .file { + return fetchFileMetadata(for: item) + } else if item.itemType == .folder { + return fetchFolderMetadata(for: item) + } else { + let error = CloudProviderError.itemTypeMismatch + CloudAccessDDLogDebug("BoxCloudProvider: fetchItemMetadata(for: \(item.identifier)) failed with error: \(error)") + return Promise(error) + } + } + + private func fetchFileMetadata(for item: BoxItem) -> Promise { + assert(item.itemType == .file) + CloudAccessDDLogDebug("BoxCloudProvider: fetchFileMetadata(for: \(item)) called") + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + let file = try await client.files.getFileById(fileId: item.identifier) + CloudAccessDDLogDebug("BoxCloudProvider: fetchFileMetadata(for: \(item.identifier)) received file: \(file)") + let cloudItemMetadata = convertToCloudItemMetadata(file, at: item.cloudPath) + pendingPromise.fulfill(cloudItemMetadata) + } catch { + CloudAccessDDLogDebug("BoxCloudProvider: fetchFileMetadata(for: \(item.identifier)) error: \(error.localizedDescription)") + pendingPromise.reject(convertStandardError(error)) + } + } + return pendingPromise + } + + private func fetchFolderMetadata(for item: BoxItem) -> Promise { + assert(item.itemType == .folder) + CloudAccessDDLogDebug("BoxCloudProvider: fetchFolderMetadata(for: \(item.identifier)) called") + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + let folder = try await client.folders.getFolderById(folderId: item.identifier) + CloudAccessDDLogDebug("BoxCloudProvider: fetchFolderMetadata(for: \(item.identifier)) received folder: \(folder)") + let cloudItemMetadata = convertToCloudItemMetadata(folder, at: item.cloudPath) + pendingPromise.fulfill(cloudItemMetadata) + } catch { + CloudAccessDDLogDebug("BoxCloudProvider: fetchFolderMetadata(for: \(item.identifier)) error: \(error.localizedDescription)") + pendingPromise.reject(convertStandardError(error)) + } + } + return pendingPromise + } + + private func fetchItemList(for folderItem: BoxItem, pageToken: String?) -> Promise { + guard folderItem.itemType == .folder else { + return Promise(CloudProviderError.itemTypeMismatch) + } + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + let queryParams = GetFolderItemsQueryParams(fields: ["name", "size", "modified_at"], usemarker: true, marker: pageToken, limit: Int64(self.maxPageSize)) + let items = try await client.folders.getFolderItems(folderId: folderItem.identifier, queryParams: queryParams) + CloudAccessDDLogDebug("BoxCloudProvider: fetchItemList(for: \(folderItem.identifier), pageToken: \(pageToken ?? "nil")) received items: \(items)") + let cloudItemList = try convertToCloudItemList(items, at: folderItem.cloudPath) + pendingPromise.fulfill(cloudItemList) + } catch let error as BoxAPIError where error.responseInfo.statusCode == 400 { + pendingPromise.reject(CloudProviderError.pageTokenInvalid) + } catch { + CloudAccessDDLogDebug("BoxCloudProvider: fetchItemList(for: \(folderItem.identifier), pageToken: \(pageToken ?? "nil")) failed with error: \(error.localizedDescription)") + pendingPromise.reject(convertStandardError(error)) + } + } + return pendingPromise + } + + private func downloadFile(for item: BoxItem, to localURL: URL) -> Promise { + CloudAccessDDLogDebug("BoxCloudProvider: downloadFile(for: \(item.identifier), to: \(localURL)) called") + guard item.itemType == .file else { + return Promise(CloudProviderError.itemTypeMismatch) + } + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + let url = try await client.downloads.downloadFile(fileId: item.identifier, downloadDestinationURL: localURL) + CloudAccessDDLogDebug("BoxCloudProvider: downloadFile(for: \(item.identifier), to: \(localURL)) finished downloading to: \(url)") + pendingPromise.fulfill(()) + } catch { + CloudAccessDDLogDebug("BoxCloudProvider: downloadFile(for: \(item.identifier), to: \(localURL)) failed with error: \(error.localizedDescription)") + pendingPromise.reject(convertStandardError(error)) + } + } + return pendingPromise + } + + private func uploadFile(for parentItem: BoxItem, from localURL: URL, to cloudPath: CloudPath, replaceExisting: Bool) -> Promise { + CloudAccessDDLogDebug("BoxCloudProvider: uploadFile(for: \(parentItem.identifier), from: \(localURL), to: \(cloudPath), replaceExisting: \(replaceExisting)) called") + let attributes: [FileAttributeKey: Any] + do { + attributes = try FileManager.default.attributesOfItem(atPath: localURL.path) + } catch CocoaError.fileReadNoSuchFile { + return Promise(CloudProviderError.itemNotFound) + } catch { + return Promise(error) + } + let fileSize = attributes[FileAttributeKey.size] as? Int ?? 52_428_800 + // Box recommends uploading files over 50 MiB with a chunked upload. + return resolvePath(forItemAt: cloudPath).then { item -> Promise in + if !replaceExisting || (replaceExisting && item.itemType == .folder) { + throw CloudProviderError.itemAlreadyExists + } + if fileSize >= 52_428_800 { + return self.uploadLargeExistingFile(for: item, from: localURL, to: cloudPath, fileSize: fileSize) + } else { + return self.uploadSmallExistingFile(for: item, from: localURL, to: cloudPath) + } + }.recover { error -> Promise in + guard case CloudProviderError.itemNotFound = error else { + throw error + } + if fileSize >= 52_428_800 { + return self.uploadLargeNewFile(for: parentItem, from: localURL, to: cloudPath, fileSize: fileSize) + } else { + return self.uploadSmallNewFile(for: parentItem, from: localURL, to: cloudPath) + } + } + } + + private func uploadSmallNewFile(for parentItem: BoxItem, from localURL: URL, to cloudPath: CloudPath) -> Promise { + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + guard let fileStream = InputStream(url: localURL) else { + throw CloudProviderError.itemNotFound + } + let requestBody = UploadFileRequestBody( + attributes: UploadFileRequestBodyAttributesField( + name: cloudPath.lastPathComponent, + parent: UploadFileRequestBodyAttributesParentField(id: parentItem.identifier) + ), + file: fileStream + ) + let files = try await client.uploads.uploadFile(requestBody: requestBody) + guard let file = files.entries?.first else { + throw CloudProviderError.itemNotFound + } + CloudAccessDDLogDebug("BoxCloudProvider: uploadSmallNewFile(for: \(parentItem.identifier), to: \(cloudPath)) received file: \(file)") + let cloudItemMetadata = convertToCloudItemMetadata(file, at: cloudPath) + pendingPromise.fulfill(cloudItemMetadata) + } catch { + CloudAccessDDLogDebug("BoxCloudProvider: uploadSmallNewFile(for: \(parentItem.identifier), to: \(cloudPath)) failed with error: \(error.localizedDescription)") + pendingPromise.reject(convertStandardError(error)) + } + } + return pendingPromise + } + + private func uploadSmallExistingFile(for existingItem: BoxItem, from localURL: URL, to cloudPath: CloudPath) -> Promise { + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + guard let fileStream = InputStream(url: localURL) else { + throw CloudProviderError.itemNotFound + } + let requestBody = UploadFileVersionRequestBody( + attributes: UploadFileVersionRequestBodyAttributesField(name: cloudPath.lastPathComponent), + file: fileStream + ) + let files = try await client.uploads.uploadFileVersion(fileId: existingItem.identifier, requestBody: requestBody) + guard let file = files.entries?.first else { + throw CloudProviderError.itemNotFound + } + CloudAccessDDLogDebug("BoxCloudProvider: uploadSmallExistingFile(for: \(existingItem.identifier), to: \(cloudPath)) received file: \(file)") + let cloudItemMetadata = convertToCloudItemMetadata(file, at: cloudPath) + pendingPromise.fulfill(cloudItemMetadata) + } catch { + CloudAccessDDLogDebug("BoxCloudProvider: uploadSmallExistingFile(for: \(existingItem.identifier), to: \(cloudPath)) failed with error: \(error.localizedDescription)") + pendingPromise.reject(convertStandardError(error)) + } + } + return pendingPromise + } + + private func uploadLargeNewFile(for parentItem: BoxItem, from localURL: URL, to cloudPath: CloudPath, fileSize: Int) -> Promise { + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + let requestBody = CreateFileUploadSessionRequestBody(folderId: parentItem.identifier, fileSize: Int64(fileSize), fileName: cloudPath.lastPathComponent) + let uploadSession = try await self.client.chunkedUploads.createFileUploadSession(requestBody: requestBody) + let cloudItemMetadata = try await uploadLargeFile(for: uploadSession, from: localURL, to: cloudPath, fileSize: fileSize) + pendingPromise.fulfill(cloudItemMetadata) + } catch { + CloudAccessDDLogDebug("BoxCloudProvider: uploadLargeNewFile(for: \(parentItem.identifier), to: \(cloudPath), fileSize: \(fileSize)) failed with error: \(error.localizedDescription)") + pendingPromise.reject(convertStandardError(error)) + } + } + return pendingPromise + } + + private func uploadLargeExistingFile(for existingItem: BoxItem, from localURL: URL, to cloudPath: CloudPath, fileSize: Int) -> Promise { + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + let requestBody = CreateFileUploadSessionForExistingFileRequestBody(fileSize: Int64(fileSize)) + let uploadSession = try await client.chunkedUploads.createFileUploadSessionForExistingFile(fileId: existingItem.identifier, requestBody: requestBody) + let cloudItemMetadata = try await uploadLargeFile(for: uploadSession, from: localURL, to: cloudPath, fileSize: fileSize) + pendingPromise.fulfill(cloudItemMetadata) + } catch { + CloudAccessDDLogDebug("BoxCloudProvider: uploadLargeExistingFile(for: \(existingItem.identifier), to: \(cloudPath), fileSize: \(fileSize)) failed with error: \(error.localizedDescription)") + pendingPromise.reject(convertStandardError(error)) + } + } + return pendingPromise + } + + private func uploadLargeFile(for uploadSession: UploadSession, from localURL: URL, to cloudPath: CloudPath, fileSize: Int) async throws -> CloudItemMetadata { + guard let fileStream = InputStream(url: localURL) else { + throw CloudProviderError.itemNotFound + } + guard let uploadSessionId = uploadSession.id, let partSize = uploadSession.partSize else { + throw BoxSDKError(message: "Failed to retrieve upload session data") + } + let fileHash = Hash(algorithm: .sha1) + let chunksIterator = Utils.iterateChunks(stream: fileStream, chunkSize: partSize) + let results = try await Utils.reduceIterator(iterator: chunksIterator, reducer: client.chunkedUploads.reducer, initialValue: PartAccumulator(lastIndex: -1, parts: [], fileSize: Int64(fileSize), uploadSessionId: uploadSessionId, fileHash: fileHash)) + let sha1 = await fileHash.digestHash(encoding: "base64") + let digest = "\("sha=")\(sha1)" + let committedSession = try await client.chunkedUploads.createFileUploadSessionCommit(uploadSessionId: uploadSessionId, requestBody: CreateFileUploadSessionCommitRequestBody(parts: results.parts), headers: CreateFileUploadSessionCommitHeaders(digest: digest)) + guard let file = committedSession.entries?.first else { + throw CloudProviderError.itemNotFound + } + CloudAccessDDLogDebug("BoxCloudProvider: uploadLargeFile(for: \(uploadSession), to: \(cloudPath), fileSize: \(fileSize)) received file: \(file)") + return convertToCloudItemMetadata(file, at: cloudPath) + } + + private func createFolder(for parentItem: BoxItem, with name: String) -> Promise { + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + let requestBody = CreateFolderRequestBody(name: name, parent: CreateFolderRequestBodyParentField(id: parentItem.identifier)) + let folder = try await client.folders.createFolder(requestBody: requestBody) + CloudAccessDDLogDebug("BoxCloudProvider: createFolder(for: \(parentItem.identifier), with: \(name)) received folder: \(folder)") + let cloudPath = parentItem.cloudPath.appendingPathComponent(name) + let item = BoxItem(cloudPath: cloudPath, folder: folder) + try self.identifierCache.addOrUpdate(item) + pendingPromise.fulfill(()) + } catch { + CloudAccessDDLogDebug("BoxCloudProvider: createFolder(for: \(parentItem.identifier), with: \(name)) failed with error: \(error.localizedDescription)") + pendingPromise.reject(convertStandardError(error)) + } + } + return pendingPromise + } + + private func deleteFile(for item: BoxItem) -> Promise { + CloudAccessDDLogDebug("BoxCloudProvider: deleteFile(for: \(item.identifier)) called") + guard item.itemType == .file else { + return Promise(CloudProviderError.itemTypeMismatch) + } + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + try await client.files.deleteFileById(fileId: item.identifier) + CloudAccessDDLogDebug("BoxCloudProvider: deleteFile(for: \(item.identifier)) succeeded") + try self.identifierCache.invalidate(item) + pendingPromise.fulfill(()) + } catch let error as BoxAPIError where error.responseInfo.statusCode == 404 { + pendingPromise.reject(CloudProviderError.itemNotFound) + } catch { + CloudAccessDDLogDebug("BoxCloudProvider: deleteFile(for: \(item.identifier)) failed with error: \(error.localizedDescription)") + pendingPromise.reject(convertStandardError(error)) + } + } + return pendingPromise + } + + private func deleteFolder(for item: BoxItem) -> Promise { + CloudAccessDDLogDebug("BoxCloudProvider: deleteFolder(for: \(item.identifier)) called") + guard item.itemType == .folder else { + return Promise(CloudProviderError.itemTypeMismatch) + } + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + let queryParams = DeleteFolderByIdQueryParams(recursive: true) + try await client.folders.deleteFolderById(folderId: item.identifier, queryParams: queryParams) + CloudAccessDDLogDebug("BoxCloudProvider: deleteFolder(for: \(item.identifier)) succeeded") + try self.identifierCache.invalidate(item) + pendingPromise.fulfill(()) + } catch let error as BoxAPIError where error.responseInfo.statusCode == 404 { + pendingPromise.reject(CloudProviderError.itemNotFound) + } catch { + CloudAccessDDLogDebug("BoxCloudProvider: deleteFolder(for: \(item.identifier)) failed with error: \(error.localizedDescription)") + pendingPromise.reject(convertStandardError(error)) + } + } + return pendingPromise + } + + private func moveFile(from sourceItem: BoxItem, toParent targetParentItem: BoxItem, targetCloudPath: CloudPath) -> Promise { + CloudAccessDDLogDebug("BoxCloudProvider: moveFile(from: \(sourceItem.identifier), toParent: \(targetParentItem.identifier), targetCloudPath: \(targetCloudPath.path)) called") + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + let requestBody = UpdateFileByIdRequestBody( + name: targetCloudPath.lastPathComponent, + parent: UpdateFileByIdRequestBodyParentField(id: targetParentItem.identifier) + ) + let file = try await client.files.updateFileById(fileId: sourceItem.identifier, requestBody: requestBody) + CloudAccessDDLogDebug("BoxCloudProvider: moveFile(from: \(sourceItem.identifier), toParent: \(targetParentItem.identifier), targetCloudPath: \(targetCloudPath.path)) received file: \(file)") + try self.identifierCache.invalidate(sourceItem) + let targetItem = BoxItem(cloudPath: targetCloudPath, file: file) + try self.identifierCache.addOrUpdate(targetItem) + pendingPromise.fulfill(()) + } catch { + CloudAccessDDLogDebug("BoxCloudProvider: moveFile(from: \(sourceItem.identifier), toParent: \(targetParentItem.identifier), targetCloudPath: \(targetCloudPath.path)) failed with error: \(error.localizedDescription)") + pendingPromise.reject(convertStandardError(error)) + } + } + return pendingPromise + } + + private func moveFolder(from sourceItem: BoxItem, toParent targetParentItem: BoxItem, targetCloudPath: CloudPath) -> Promise { + CloudAccessDDLogDebug("BoxCloudProvider: moveFolder(from: \(sourceItem.identifier), toParent: \(targetParentItem.identifier), targetCloudPath: \(targetCloudPath.path)) called") + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + let requestBody = UpdateFolderByIdRequestBody( + name: targetCloudPath.lastPathComponent, + parent: UpdateFolderByIdRequestBodyParentField(id: targetParentItem.identifier) + ) + let folder = try await client.folders.updateFolderById(folderId: sourceItem.identifier, requestBody: requestBody) + CloudAccessDDLogDebug("BoxCloudProvider: moveFolder(from: \(sourceItem.identifier), toParent: \(targetParentItem.identifier), targetCloudPath: \(targetCloudPath.path)) received folder: \(folder)") + try self.identifierCache.invalidate(sourceItem) + let newItem = BoxItem(cloudPath: targetCloudPath, folder: folder) + try self.identifierCache.addOrUpdate(newItem) + pendingPromise.fulfill(()) + } catch { + CloudAccessDDLogDebug("BoxCloudProvider: moveFolder(from: \(sourceItem.identifier), toParent: \(targetParentItem.identifier), targetCloudPath: \(targetCloudPath.path)) failed with error: \(error.localizedDescription)") + pendingPromise.reject(convertStandardError(error)) + } + } + return pendingPromise + } + + // MARK: - Resolve Path + + private func resolvePath(forItemAt cloudPath: CloudPath) -> Promise { + var pathToCheckForCache = cloudPath + var cachedItem = identifierCache.get(pathToCheckForCache) + while cachedItem == nil, !pathToCheckForCache.pathComponents.isEmpty { + pathToCheckForCache = pathToCheckForCache.deletingLastPathComponent() + cachedItem = identifierCache.get(pathToCheckForCache) + } + guard let item = cachedItem else { + return Promise(BoxError.inconsistentCache) + } + if pathToCheckForCache != cloudPath { + return traverseThroughPath(from: pathToCheckForCache, to: cloudPath, withStartItem: item) + } + return Promise(item) + } + + private func resolveParentPath(forItemAt cloudPath: CloudPath) -> Promise { + let parentCloudPath = cloudPath.deletingLastPathComponent() + return resolvePath(forItemAt: parentCloudPath).recover { error -> BoxItem in + if case CloudProviderError.itemNotFound = error { + throw CloudProviderError.parentFolderDoesNotExist + } else { + throw error + } + } + } + + private func traverseThroughPath(from startCloudPath: CloudPath, to endCloudPath: CloudPath, withStartItem startItem: BoxItem) -> Promise { + assert(startCloudPath.pathComponents.count < endCloudPath.pathComponents.count) + let startIndex = startCloudPath.pathComponents.count + let endIndex = endCloudPath.pathComponents.count + var currentPath = startCloudPath + var parentItem = startItem + return Promise(on: .global()) { fulfill, _ in + for i in startIndex ..< endIndex { + let itemName = endCloudPath.pathComponents[i] + currentPath = currentPath.appendingPathComponent(itemName) + parentItem = try awaitPromise(self.getBoxItem(for: itemName, withParentItem: parentItem)) + try self.identifierCache.addOrUpdate(parentItem) + } + fulfill(parentItem) + } + } + + func getBoxItem(for name: String, withParentItem parentItem: BoxItem) -> Promise { + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + let foundItem = try await findBoxItem(in: parentItem, withName: name, marker: nil) + CloudAccessDDLogDebug("BoxCloudProvider: getBoxItem(for: \(name), withParentItem: \(parentItem.identifier)) found item: \(foundItem)") + pendingPromise.fulfill(foundItem) + } catch { + CloudAccessDDLogDebug("BoxCloudProvider: getBoxItem(for: \(name), withParentItem: \(parentItem.identifier)) failed with error: \(error.localizedDescription)") + pendingPromise.reject(convertStandardError(error)) + } + } + return pendingPromise + } + + func findBoxItem(in parentItem: BoxItem, withName name: String, marker: String?) async throws -> BoxItem { + let queryParams = GetFolderItemsQueryParams(fields: ["name", "size", "modified_at"], usemarker: true, marker: marker, limit: Int64(maxPageSize)) + let items = try await client.folders.getFolderItems(folderId: parentItem.identifier, queryParams: queryParams) + CloudAccessDDLogDebug("BoxCloudProvider: findBoxItem(in: \(name), withName: \(name), marker: \(marker ?? "nil")) received items: \(items)") + if let foundItem = try await locateBoxItem(in: items, withName: name, parentItem: parentItem) { + return foundItem + } else if let nextMarker = items.nextMarker { + return try await findBoxItem(in: parentItem, withName: name, marker: nextMarker) + } else { + throw CloudProviderError.itemNotFound + } + } + + func locateBoxItem(in items: Items, withName name: String, parentItem: BoxItem) async throws -> BoxItem? { + if let entries = items.entries { + for entry in entries { + switch entry { + case let .fileFull(file) where file.name == name: + return BoxItem(cloudPath: parentItem.cloudPath.appendingPathComponent(name), file: file) + case let .folderMini(folder) where folder.name == name: + return BoxItem(cloudPath: parentItem.cloudPath.appendingPathComponent(name), folder: folder) + case .webLink, .fileFull, .folderMini: + continue + } + } + } + return nil + } + + // MARK: - Helpers + + private func convertToCloudItemMetadata(_ file: File, at cloudPath: CloudPath) -> CloudItemMetadata { + let name = file.name ?? "" + let itemType = CloudItemType.file + let size = file.size.map { Int($0) } + let lastModifiedDate = file.modifiedAt + return CloudItemMetadata(name: name, cloudPath: cloudPath, itemType: itemType, lastModifiedDate: lastModifiedDate, size: size) + } + + private func convertToCloudItemMetadata(_ folder: FolderMini, at cloudPath: CloudPath) -> CloudItemMetadata { + let name = folder.name ?? "" + let itemType = CloudItemType.folder + return CloudItemMetadata(name: name, cloudPath: cloudPath, itemType: itemType, lastModifiedDate: nil, size: nil) + } + + private func convertToCloudItemList(_ folderItems: Items, at cloudPath: CloudPath) throws -> CloudItemList { + var items = [CloudItemMetadata]() + guard let entries = folderItems.entries else { + return CloudItemList(items: []) + } + for entry in entries { + switch entry { + case let .fileFull(file): + let itemCloudPath = cloudPath.appendingPathComponent(file.name ?? "") + let itemMetadata = convertToCloudItemMetadata(file, at: itemCloudPath) + items.append(itemMetadata) + case let .folderMini(folder): + let itemCloudPath = cloudPath.appendingPathComponent(folder.name ?? "") + let itemMetadata = convertToCloudItemMetadata(folder, at: itemCloudPath) + items.append(itemMetadata) + default: + throw BoxError.unexpectedContent + } + } + return CloudItemList(items: items, nextPageToken: folderItems.nextMarker) + } + + private func convertStandardError(_ error: Error) -> Error { + switch error { + case let error as BoxAPIError where error.responseInfo.statusCode == 401: + return CloudProviderError.unauthorized + case let error as BoxAPIError where error.responseInfo.statusCode == 404: + return CloudProviderError.itemNotFound + default: + return error + } + } +} diff --git a/Sources/CryptomatorCloudAccess/Box/BoxCredential.swift b/Sources/CryptomatorCloudAccess/Box/BoxCredential.swift new file mode 100644 index 0000000..dbaf271 --- /dev/null +++ b/Sources/CryptomatorCloudAccess/Box/BoxCredential.swift @@ -0,0 +1,71 @@ +// +// BoxCredential.swift +// CryptomatorCloudAccess +// +// Created by Majid Achhoud on 19.03.24. +// Copyright © 2024 Skymatic GmbH. All rights reserved. +// + +import AuthenticationServices +import BoxSdkGen +import Foundation +import Promises + +public enum BoxCredentialErrors: Error { + case noUsername +} + +public class BoxCredential { + var auth: Authentication + var client: BoxClient + + public init(tokenStorage: TokenStorage) { + let config = OAuthConfig(clientId: BoxSetup.constants.clientId, clientSecret: BoxSetup.constants.clientSecret, tokenStorage: tokenStorage) + self.auth = BoxOAuth(config: config) + self.client = BoxClient(auth: auth) + } + + public func deauthenticate() -> Promise { + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + let networkSession = NetworkSession() + try await self.client.auth.revokeToken(networkSession: networkSession) + pendingPromise.fulfill(()) + } catch { + pendingPromise.reject(error) + } + } + return pendingPromise + } + + public func getUsername() -> Promise { + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + let user = try await client.users.getUserMe() + if let name = user.name { + pendingPromise.fulfill(name) + } else { + pendingPromise.reject(BoxCredentialErrors.noUsername) + } + } catch { + pendingPromise.reject(error) + } + } + return pendingPromise + } + + public func getUserId() -> Promise { + let pendingPromise = Promise.pending() + _Concurrency.Task { + do { + let user = try await client.users.getUserMe() + pendingPromise.fulfill(user.id) + } catch { + pendingPromise.reject(error) + } + } + return pendingPromise + } +} diff --git a/Sources/CryptomatorCloudAccess/Box/BoxError.swift b/Sources/CryptomatorCloudAccess/Box/BoxError.swift new file mode 100644 index 0000000..b657aee --- /dev/null +++ b/Sources/CryptomatorCloudAccess/Box/BoxError.swift @@ -0,0 +1,14 @@ +// +// BoxError.swift +// CryptomatorCloudAccess +// +// Created by Majid Achhoud on 15.04.24. +// Copyright © 2024 Skymatic GmbH. All rights reserved. +// + +import Foundation + +public enum BoxError: Error { + case unexpectedContent + case inconsistentCache +} diff --git a/Sources/CryptomatorCloudAccess/Box/BoxIdentifierCache.swift b/Sources/CryptomatorCloudAccess/Box/BoxIdentifierCache.swift new file mode 100644 index 0000000..05e9b10 --- /dev/null +++ b/Sources/CryptomatorCloudAccess/Box/BoxIdentifierCache.swift @@ -0,0 +1,44 @@ +// +// BoxIdentifierCache.swift +// CryptomatorCloudAccess +// +// Created by Majid Achhoud on 19.03.24. +// Copyright © 2024 Skymatic GmbH. All rights reserved. +// + +import Foundation +import GRDB + +class BoxIdentifierCache { + private let inMemoryDB: DatabaseQueue + + init() throws { + self.inMemoryDB = DatabaseQueue() + try inMemoryDB.write { db in + try db.create(table: BoxItem.databaseTableName) { table in + table.column(BoxItem.cloudPathKey, .text).notNull().primaryKey() + table.column(BoxItem.identifierKey, .text).notNull() + table.column(BoxItem.itemTypeKey, .text).notNull() + } + try BoxItem(cloudPath: CloudPath("/"), identifier: "0", itemType: .folder).save(db) + } + } + + func get(_ cloudPath: CloudPath) -> BoxItem? { + try? inMemoryDB.read { db in + return try BoxItem.fetchOne(db, key: cloudPath) + } + } + + func addOrUpdate(_ item: BoxItem) throws { + try inMemoryDB.write { db in + try item.save(db) + } + } + + func invalidate(_ item: BoxItem) throws { + try inMemoryDB.write { db in + try db.execute(sql: "DELETE FROM \(BoxItem.databaseTableName) WHERE \(BoxItem.cloudPathKey) LIKE ?", arguments: ["\(item.cloudPath.path)%"]) + } + } +} diff --git a/Sources/CryptomatorCloudAccess/Box/BoxItem.swift b/Sources/CryptomatorCloudAccess/Box/BoxItem.swift new file mode 100644 index 0000000..181d364 --- /dev/null +++ b/Sources/CryptomatorCloudAccess/Box/BoxItem.swift @@ -0,0 +1,44 @@ +// +// BoxItem.swift +// CryptomatorCloudAccess +// +// Created by Majid Achhoud on 19.03.24. +// Copyright © 2024 Skymatic GmbH. All rights reserved. +// + +import BoxSdkGen +import Foundation +import GRDB + +struct BoxItem: Decodable, FetchableRecord, TableRecord, Equatable { + static let databaseTableName = "CachedEntries" + static let cloudPathKey = "cloudPath" + static let identifierKey = "identifier" + static let itemTypeKey = "itemType" + + let cloudPath: CloudPath + let identifier: String + let itemType: CloudItemType +} + +extension BoxItem { + init(cloudPath: CloudPath, file: FileBase) { + self.cloudPath = cloudPath + self.identifier = file.id + self.itemType = .file + } + + init(cloudPath: CloudPath, folder: FolderBase) { + self.cloudPath = cloudPath + self.identifier = folder.id + self.itemType = .folder + } +} + +extension BoxItem: PersistableRecord { + func encode(to container: inout PersistenceContainer) { + container[BoxItem.cloudPathKey] = cloudPath + container[BoxItem.identifierKey] = identifier + container[BoxItem.itemTypeKey] = itemType + } +} diff --git a/Sources/CryptomatorCloudAccess/Box/BoxSetup.swift b/Sources/CryptomatorCloudAccess/Box/BoxSetup.swift new file mode 100644 index 0000000..222039f --- /dev/null +++ b/Sources/CryptomatorCloudAccess/Box/BoxSetup.swift @@ -0,0 +1,23 @@ +// +// BoxSetup.swift +// CryptomatorCloudAccess +// +// Created by Majid Achhoud on 18.03.24. +// Copyright © 2024 Skymatic GmbH. All rights reserved. +// + +import Foundation + +public class BoxSetup { + public static var constants: BoxSetup! + + public let clientId: String + public let clientSecret: String + public let sharedContainerIdentifier: String? + + public init(clientId: String, clientSecret: String, sharedContainerIdentifier: String?) { + self.clientId = clientId + self.clientSecret = clientSecret + self.sharedContainerIdentifier = sharedContainerIdentifier + } +} diff --git a/Tests/CryptomatorCloudAccessIntegrationTests/Box/BoxAuthenticationMock.swift b/Tests/CryptomatorCloudAccessIntegrationTests/Box/BoxAuthenticationMock.swift new file mode 100644 index 0000000..476dbab --- /dev/null +++ b/Tests/CryptomatorCloudAccessIntegrationTests/Box/BoxAuthenticationMock.swift @@ -0,0 +1,32 @@ +// +// BoxAuthenticationMock.swift +// CryptomatorCloudAccessIntegrationTests +// +// Created by Tobias Hagemann on 21.08.24. +// Copyright © 2024 Skymatic GmbH. All rights reserved. +// + +import BoxSdkGen +import Foundation + +class BoxAuthenticationMock: Authentication { + func retrieveToken(networkSession: NetworkSession?) async throws -> AccessToken { + return AccessToken() + } + + func refreshToken(networkSession: NetworkSession?) async throws -> AccessToken { + return AccessToken() + } + + func retrieveAuthorizationHeader(networkSession: NetworkSession?) async throws -> String { + return "" + } + + func revokeToken(networkSession: NetworkSession?) async throws { + // do nothing + } + + func downscopeToken(scopes: [String], resource: String?, sharedLink: String?, networkSession: BoxSdkGen.NetworkSession?) async throws -> AccessToken { + return AccessToken() + } +} diff --git a/Tests/CryptomatorCloudAccessIntegrationTests/Box/BoxCloudProviderIntegrationTests.swift b/Tests/CryptomatorCloudAccessIntegrationTests/Box/BoxCloudProviderIntegrationTests.swift new file mode 100644 index 0000000..3d5b212 --- /dev/null +++ b/Tests/CryptomatorCloudAccessIntegrationTests/Box/BoxCloudProviderIntegrationTests.swift @@ -0,0 +1,47 @@ +// +// BoxCloudProviderIntegrationTests.swift +// CryptomatorCloudAccess +// +// Created by Majid Achhoud on 19.03.24. +// Copyright © 2024 Skymatic GmbH. All rights reserved. +// + +#if canImport(CryptomatorCloudAccessCore) +import CryptomatorCloudAccessCore +#else +import CryptomatorCloudAccess +#endif +import Promises +import XCTest + +class BoxCloudProviderIntegrationTests: CloudAccessIntegrationTestWithAuthentication { + override class var defaultTestSuite: XCTestSuite { + return XCTestSuite(forTestCaseClass: BoxCloudProviderIntegrationTests.self) + } + + private let credential = BoxCredentialMock() + + override class func setUp() { + integrationTestParentCloudPath = CloudPath("/iOS-IntegrationTests-Plain") + let credential = BoxCredentialMock() + // swiftlint:disable:next force_try + setUpProvider = try! BoxCloudProvider(credential: credential) + super.setUp() + } + + override func setUpWithError() throws { + try super.setUpWithError() + provider = try BoxCloudProvider(credential: credential) + } + + override func deauthenticate() -> Promise { + let invalidCredential = BoxInvalidCredentialMock() + // swiftlint:disable:next force_try + provider = try! BoxCloudProvider(credential: invalidCredential) + return Promise(()) + } + + override func createLimitedCloudProvider() throws -> CloudProvider { + return try BoxCloudProvider(credential: credential, maxPageSize: maxPageSizeForLimitedCloudProvider) + } +} diff --git a/Tests/CryptomatorCloudAccessIntegrationTests/Box/BoxCredentialMock.swift b/Tests/CryptomatorCloudAccessIntegrationTests/Box/BoxCredentialMock.swift new file mode 100644 index 0000000..76fec35 --- /dev/null +++ b/Tests/CryptomatorCloudAccessIntegrationTests/Box/BoxCredentialMock.swift @@ -0,0 +1,34 @@ +// +// BoxCredentialMock.swift +// CryptomatorCloudAccessIntegrationTests +// +// Created by Majid Achhoud on 15.04.24. +// Copyright © 2024 Skymatic GmbH. All rights reserved. +// + +import BoxSdkGen +import Foundation +#if canImport(CryptomatorCloudAccessCore) +@testable import CryptomatorCloudAccessCore +#else +@testable import CryptomatorCloudAccess +#endif + +class BoxCredentialMock: BoxCredential { + init() { + BoxSetup.constants = BoxSetup(clientId: IntegrationTestSecrets.boxClientId, clientSecret: IntegrationTestSecrets.boxClientSecret, sharedContainerIdentifier: "") + super.init(tokenStorage: InMemoryTokenStorage()) + let config = CCGConfig(clientId: BoxSetup.constants.clientId, clientSecret: BoxSetup.constants.clientSecret, enterpriseId: IntegrationTestSecrets.boxEnterpriseId) + auth = BoxCCGAuth(config: config) + client = BoxClient(auth: auth) + } +} + +class BoxInvalidCredentialMock: BoxCredential { + init() { + BoxSetup.constants = BoxSetup(clientId: IntegrationTestSecrets.boxClientId, clientSecret: IntegrationTestSecrets.boxClientSecret, sharedContainerIdentifier: "") + super.init(tokenStorage: InMemoryTokenStorage()) + auth = BoxAuthenticationMock() + client = BoxClient(auth: auth) + } +} diff --git a/Tests/CryptomatorCloudAccessIntegrationTests/CryptoDecorator/VaultFormat6/VaultFormat6BoxIntegrationTests.swift b/Tests/CryptomatorCloudAccessIntegrationTests/CryptoDecorator/VaultFormat6/VaultFormat6BoxIntegrationTests.swift new file mode 100644 index 0000000..4dd5ca0 --- /dev/null +++ b/Tests/CryptomatorCloudAccessIntegrationTests/CryptoDecorator/VaultFormat6/VaultFormat6BoxIntegrationTests.swift @@ -0,0 +1,74 @@ +// +// VaultFormat6BoxIntegrationTests.swift +// CryptomatorCloudAccessIntegrationTests +// +// Created by Majid Achhoud on 29.04.24. +// Copyright © 2024 Skymatic GmbH. All rights reserved. +// + +import XCTest +#if canImport(CryptomatorCloudAccessCore) +@testable import CryptomatorCloudAccessCore +#else +@testable import CryptomatorCloudAccess +#endif +@testable import Promises + +class VaultFormat6BoxIntegrationTests: CloudAccessIntegrationTest { + override class var defaultTestSuite: XCTestSuite { + return XCTestSuite(forTestCaseClass: VaultFormat6BoxIntegrationTests.self) + } + + private static let credential = BoxCredentialMock() + // swiftlint:disable:next force_try + private static let cloudProvider = try! BoxCloudProvider(credential: credential) + private static let vaultPath = CloudPath("/iOS-IntegrationTests-VaultFormat6") + + override class func setUp() { + integrationTestParentCloudPath = CloudPath("/") + let setUpPromise = cloudProvider.deleteFolderIfExisting(at: vaultPath).then { + DecoratorFactory.createNewVaultFormat6(delegate: cloudProvider, vaultPath: vaultPath, password: "IntegrationTest") + }.then { decorator in + setUpProvider = decorator + } + guard waitForPromises(timeout: 60.0) else { + classSetUpError = IntegrationTestError.oneTimeSetUpTimeout + return + } + if let error = setUpPromise.error { + classSetUpError = error + return + } + super.setUp() + } + + override func setUpWithError() throws { + try super.setUpWithError() + let credential = BoxCredentialMock() + let cloudProvider = try BoxCloudProvider(credential: credential) + let setUpPromise = DecoratorFactory.createFromExistingVaultFormat6(delegate: cloudProvider, vaultPath: VaultFormat6BoxIntegrationTests.vaultPath, password: "IntegrationTest").then { decorator in + self.provider = decorator + } + guard waitForPromises(timeout: 60.0) else { + if let error = setUpPromise.error { + throw error + } + throw IntegrationTestError.setUpTimeout + } + } + + override func createLimitedCloudProvider() throws -> CloudProvider { + let credential = BoxCredentialMock() + let limitedDelegate = try BoxCloudProvider(credential: credential, maxPageSize: maxPageSizeForLimitedCloudProvider) + let setUpPromise = DecoratorFactory.createFromExistingVaultFormat6(delegate: limitedDelegate, vaultPath: VaultFormat6BoxIntegrationTests.vaultPath, password: "IntegrationTest").then { decorator in + self.provider = decorator + } + guard waitForPromises(timeout: 60.0) else { + if let error = setUpPromise.error { + throw error + } + throw IntegrationTestError.setUpTimeout + } + return try XCTUnwrap(setUpPromise.value) + } +} diff --git a/Tests/CryptomatorCloudAccessIntegrationTests/CryptoDecorator/VaultFormat7/VaultFormat7BoxIntegrationTests.swift b/Tests/CryptomatorCloudAccessIntegrationTests/CryptoDecorator/VaultFormat7/VaultFormat7BoxIntegrationTests.swift new file mode 100644 index 0000000..ea817df --- /dev/null +++ b/Tests/CryptomatorCloudAccessIntegrationTests/CryptoDecorator/VaultFormat7/VaultFormat7BoxIntegrationTests.swift @@ -0,0 +1,74 @@ +// +// VaultFormat7BoxIntegrationTests.swift +// CryptomatorCloudAccessIntegrationTests +// +// Created by Majid Achhoud on 29.04.24. +// Copyright © 2024 Skymatic GmbH. All rights reserved. +// + +import XCTest +#if canImport(CryptomatorCloudAccessCore) +@testable import CryptomatorCloudAccessCore +#else +@testable import CryptomatorCloudAccess +#endif +@testable import Promises + +class VaultFormat7BoxIntegrationTests: CloudAccessIntegrationTest { + override class var defaultTestSuite: XCTestSuite { + return XCTestSuite(forTestCaseClass: VaultFormat7BoxIntegrationTests.self) + } + + private static let credential = BoxCredentialMock() + // swiftlint:disable:next force_try + private static let cloudProvider = try! BoxCloudProvider(credential: credential) + private static let vaultPath = CloudPath("/iOS-IntegrationTests-VaultFormat7") + + override class func setUp() { + integrationTestParentCloudPath = CloudPath("/") + let setUpPromise = cloudProvider.deleteFolderIfExisting(at: vaultPath).then { + DecoratorFactory.createNewVaultFormat7(delegate: cloudProvider, vaultPath: vaultPath, password: "IntegrationTest") + }.then { decorator in + setUpProvider = decorator + } + guard waitForPromises(timeout: 60.0) else { + classSetUpError = IntegrationTestError.oneTimeSetUpTimeout + return + } + if let error = setUpPromise.error { + classSetUpError = error + return + } + super.setUp() + } + + override func setUpWithError() throws { + try super.setUpWithError() + let credential = BoxCredentialMock() + let cloudProvider = try BoxCloudProvider(credential: credential) + let setUpPromise = DecoratorFactory.createFromExistingVaultFormat7(delegate: cloudProvider, vaultPath: VaultFormat7BoxIntegrationTests.vaultPath, password: "IntegrationTest").then { decorator in + self.provider = decorator + } + guard waitForPromises(timeout: 60.0) else { + if let error = setUpPromise.error { + throw error + } + throw IntegrationTestError.setUpTimeout + } + } + + override func createLimitedCloudProvider() throws -> CloudProvider { + let credential = BoxCredentialMock() + let limitedDelegate = try BoxCloudProvider(credential: credential, maxPageSize: maxPageSizeForLimitedCloudProvider) + let setUpPromise = DecoratorFactory.createFromExistingVaultFormat7(delegate: limitedDelegate, vaultPath: VaultFormat7BoxIntegrationTests.vaultPath, password: "IntegrationTest").then { decorator in + self.provider = decorator + } + guard waitForPromises(timeout: 60.0) else { + if let error = setUpPromise.error { + throw error + } + throw IntegrationTestError.setUpTimeout + } + return try XCTUnwrap(setUpPromise.value) + } +} diff --git a/Tests/CryptomatorCloudAccessIntegrationTests/Dropbox/DropboxCredentialMock.swift b/Tests/CryptomatorCloudAccessIntegrationTests/Dropbox/DropboxCredentialMock.swift index e5f8481..95205ad 100644 --- a/Tests/CryptomatorCloudAccessIntegrationTests/Dropbox/DropboxCredentialMock.swift +++ b/Tests/CryptomatorCloudAccessIntegrationTests/Dropbox/DropboxCredentialMock.swift @@ -8,7 +8,6 @@ import Foundation import ObjectiveDropboxOfficial -import Promises #if canImport(CryptomatorCloudAccessCore) @testable import CryptomatorCloudAccessCore #else diff --git a/Tests/CryptomatorCloudAccessIntegrationTests/README.md b/Tests/CryptomatorCloudAccessIntegrationTests/README.md index 4588fac..19ac013 100644 --- a/Tests/CryptomatorCloudAccessIntegrationTests/README.md +++ b/Tests/CryptomatorCloudAccessIntegrationTests/README.md @@ -8,6 +8,9 @@ If you would like to run integration tests that require authentication, you have ```sh #!/bin/sh +export BOX_CLIENT_ID=... +export BOX_CLIENT_SECRET=... +export BOX_ENTERPRISE_ID=... export DROPBOX_ACCESS_TOKEN=... export GOOGLE_DRIVE_CLIENT_ID=... export GOOGLE_DRIVE_REFRESH_TOKEN=... diff --git a/create-integration-test-secrets-file.sh b/create-integration-test-secrets-file.sh index 4a8b3bb..4e4a589 100755 --- a/create-integration-test-secrets-file.sh +++ b/create-integration-test-secrets-file.sh @@ -17,6 +17,9 @@ import CryptomatorCloudAccess import Foundation enum IntegrationTestSecrets { + static let boxClientId = "${BOX_CLIENT_ID}" + static let boxClientSecret = "${BOX_CLIENT_SECRET}" + static let boxEnterpriseId = "${BOX_ENTERPRISE_ID}" static let dropboxAccessToken = "${DROPBOX_ACCESS_TOKEN}" static let googleDriveClientId = "${GOOGLE_DRIVE_CLIENT_ID}" static let googleDriveRefreshToken = "${GOOGLE_DRIVE_REFRESH_TOKEN}"