diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index d2b378a1..ec69d9b0 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -17,10 +17,15 @@ 06F0AA3040EBCD8B9229B237 /* RemuxRootModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20F57C8C6D3F75E3E07DDC9 /* RemuxRootModel.swift */; }; 077974905B6ABD611204F949 /* RemuxPreparedTransportCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB62707ACD4905DCE40ECBFA /* RemuxPreparedTransportCoordinator.swift */; }; 0DCAADC05D6444284C9F4CCD /* RemuxSSHExecSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9586DC1E9C276FB8513E59B4 /* RemuxSSHExecSessionTests.swift */; }; + 0F4E56668E1D070DA2549277 /* FileProviderSharedStorageMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7619C21FCE4B90EA93C0B2 /* FileProviderSharedStorageMigrator.swift */; }; + 118AFD1A427E42BA889C1902 /* FileProviderRemoteServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E36D7E11A5C14870D1D35D /* FileProviderRemoteServiceTests.swift */; }; + 126385C505991783E10079F1 /* FileProviderItemIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C317118AE1C94878179FBD /* FileProviderItemIdentity.swift */; }; 12D92BE2A4D3EF5666A6686B /* GhosttySpeechAnalyzerComposerDictationBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F5DFFCFEE03768DB9B6E879 /* GhosttySpeechAnalyzerComposerDictationBackend.swift */; }; 1378681F54924A5F33A2EF8C /* RemuxActiveSessionCollectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01FC67667E36CB7A186EFC68 /* RemuxActiveSessionCollectionTests.swift */; }; 14890862E5A8BFE9BE759DB0 /* GhosttySurfaceKeyEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E84F774C17B5C4DB6B715A9 /* GhosttySurfaceKeyEventTests.swift */; }; 14BCD2766E4ED765F45BA3AD /* GhosttyPanePreviewSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07301EEDA55AAD40D17C26DA /* GhosttyPanePreviewSessionTests.swift */; }; + 14DCE9398FAB6037D54A883E /* RemuxTransportStartupTrace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81CCA16E5131F9DD6F87DE4E /* RemuxTransportStartupTrace.swift */; }; + 15636E0B3091E161F61321C1 /* FileProviderDomainReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B50C6E14DA1AD375C55E17B /* FileProviderDomainReconciler.swift */; }; 1609A6A5CD77508AB0AEA36D /* GhosttyAttachmentNotice.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAEFDFDF747576F43E843FAA /* GhosttyAttachmentNotice.swift */; }; 161FAC78EFC0C0BBE4B67201 /* GhosttyRuntimeSurfaceTopologySnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3FA910C9C3B9A094B8497A /* GhosttyRuntimeSurfaceTopologySnapshot.swift */; }; 163B73B2E847BCE0CE08F664 /* TerminalPreviewCandidate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5B9B46D85CC2D1FEBDBECE /* TerminalPreviewCandidate.swift */; }; @@ -38,17 +43,21 @@ 211A1675301B72D2D15D23A6 /* GhosttyTerminalDebugLatencyProbeControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE43FCF1FB0A2A5205281A9 /* GhosttyTerminalDebugLatencyProbeControllerTests.swift */; }; 21D804DB6768A5474BAB4DA2 /* GhosttySurfaceScrollGestureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451C5235FD50DD80CBC53CCC /* GhosttySurfaceScrollGestureTests.swift */; }; 235F42F79C9BB749EDF48422 /* GhosttyComposerDictationControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DFE39E14E057AACAB31247A /* GhosttyComposerDictationControllerTests.swift */; }; + 236BD3D59A937BA6300DD043 /* FileProviderRemoteItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 795B3162A540A5A29DEBED76 /* FileProviderRemoteItemTests.swift */; }; 245F3E2F2ED1675C0BFC0318 /* TerminalSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08FA18CCB40D2C7746D7D569 /* TerminalSettingsTests.swift */; }; 2521091156DB41132937296A /* GhosttySurfaceScrollGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23F3CE22CD66E574D1A7196E /* GhosttySurfaceScrollGesture.swift */; }; 27A320CC1E03C85158276F1D /* GhosttyKeyboardChromeModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 636A73D3AA225CCADC20F687 /* GhosttyKeyboardChromeModeTests.swift */; }; 281494ACA6BC4E311A642A37 /* GhosttyModifierState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94F9DD0E0F418199C5E8C0F1 /* GhosttyModifierState.swift */; }; 289D7673486A18F986C60788 /* TerminalPreviewStaticHTMLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 642B17B3F845A938C581F1AA /* TerminalPreviewStaticHTMLTests.swift */; }; + 28BED29CE5AE7DFEDF208AA4 /* FileProviderMutationCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 590F8C3561F59A464ADA9CB9 /* FileProviderMutationCoreTests.swift */; }; 2CC93B0F7B15C422B91559A6 /* TmuxSessionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6264B24DDF73BB7A9ACA1D7 /* TmuxSessionController.swift */; }; 2DE24C9556828D7A16BD82A3 /* GhosttyTerminalScreenModeling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8421B86729F6E46ABD20CBD5 /* GhosttyTerminalScreenModeling.swift */; }; 31FA4F4D3F831DE0DEAB770F /* TerminalPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B695C926DA594038E2C6144 /* TerminalPreviewView.swift */; }; 3266F0AE9A1F836618FF115D /* DeterministicTmuxControlTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = E509784BCB04CA957763EB32 /* DeterministicTmuxControlTransport.swift */; }; 338BB72DDA774589D60E8D0D /* RemuxLibrarySSHPrewarmCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7313F7C3049C269572F59B5 /* RemuxLibrarySSHPrewarmCoordinatorTests.swift */; }; 35C7A5E387EAF292214113A2 /* install_authorized_key.sh in Resources */ = {isa = PBXBuildFile; fileRef = F7AB8F43CB771795CF5DA0CB /* install_authorized_key.sh */; }; + 377949211A56AF7C2A97E38E /* FileProviderDomainOperationCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16B659789EF103E965A513C0 /* FileProviderDomainOperationCoordinatorTests.swift */; }; + 381EFAD3AB5D5BC133FAF12D /* FileProviderSDKItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C682B5372D1AC1D7C99D6B2C /* FileProviderSDKItem.swift */; }; 38C6D9FC205E5E2189D61E4C /* GhosttyAttachmentTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8151E185EE3CC5784181D01E /* GhosttyAttachmentTransfer.swift */; }; 3959173AC4F6B2A7849A6A1E /* GhosttyAttachmentStagingStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A9A68C878571836AC7EDA41 /* GhosttyAttachmentStagingStoreTests.swift */; }; 39A58132CBB849E2CDF37BC2 /* DebugConnectionProfileSeeder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 700C6FB2B5DB6D0645B1F5FA /* DebugConnectionProfileSeeder.swift */; }; @@ -76,6 +85,7 @@ 4D354C37600CA12E917B566F /* TerminalThemePreviewRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C826602D9C963622FB26A40 /* TerminalThemePreviewRenderer.swift */; }; 4E1E79EA4EF03C84167E2F1E /* TmuxSessionLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575EDAA61B2B7A0B241A5E4D /* TmuxSessionLink.swift */; }; 4F7DE6169BC5BE0881D9F2F1 /* GhosttyTerminalResponderFocusPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1643BC65F62CA9668005EEF8 /* GhosttyTerminalResponderFocusPolicy.swift */; }; + 4F979ED3B4BBD23818B7D8C6 /* FileProviderRemoteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DB7086AFA61A884FC2D874C /* FileProviderRemoteService.swift */; }; 51455760E7AFD296EE4D3B17 /* GhosttyRuntimeTrace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7001FC8A87E77C3617046DA7 /* GhosttyRuntimeTrace.swift */; }; 521EE36C02EDBE0FB9AD5A69 /* TrustedHostStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C944FBD600E4357E517F2DD3 /* TrustedHostStore.swift */; }; 53A275DBC331E4F5DF34FD86 /* ShortcutPaletteUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05617ECDC542A8A167C52296 /* ShortcutPaletteUITests.swift */; }; @@ -87,12 +97,15 @@ 575CA070FFCBB959FDD810F0 /* GhosttyTerminalSurfaceInteractionOutcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDB40C9C83785C6930E52ACC /* GhosttyTerminalSurfaceInteractionOutcome.swift */; }; 581869632D0A9887CF899DDB /* RemuxSSHRootService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC23D872C331C7574737CCDA /* RemuxSSHRootService.swift */; }; 584C4529515F7796EFE407C9 /* GhosttyTopLevelSurfaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 532E9E62CF3E74FEE31F5C05 /* GhosttyTopLevelSurfaceTests.swift */; }; + 58AFE513F5DB3A622A1DFD2B /* FileProviderErrorMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55622A6FC71B6F2FEA79BCDA /* FileProviderErrorMapper.swift */; }; 59082855C99ADBA1145126DB /* TmuxControlViewport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1984ED86A7C5154746E37 /* TmuxControlViewport.swift */; }; 5AE517D4E1D0873F1126C36C /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CB12DC1EC78CE1C1286BA4E /* GhosttyKit.xcframework */; }; 5C3EEA2035D610A4C44C9EC2 /* RemuxLibrarySSHPrewarmPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F165D54E7110056429A070F7 /* RemuxLibrarySSHPrewarmPlannerTests.swift */; }; 5C4ACC25834E0B2B68A9EA00 /* SSHPrivateKeyInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 820136FA2586D2054D312C3D /* SSHPrivateKeyInspector.swift */; }; 5DE5C6D2008EA128479374F3 /* GhosttySurfaceKeyEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6958616F9B9DFD9E0E38E6B /* GhosttySurfaceKeyEvent.swift */; }; + 5EA4F47B4AD597835C78A229 /* FileProviderSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4369D3BB817295081F553170 /* FileProviderSnapshotStore.swift */; }; 617F465A745990995E23034B /* RemuxPreparedTransportCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6051890D165AEFC762DD5DF4 /* RemuxPreparedTransportCache.swift */; }; + 61A9669449C8D38F4BB549D2 /* FileProviderMutationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5133DB5A0970D94CC2A8241C /* FileProviderMutationRequest.swift */; }; 63982077EBCD656F6570F121 /* TerminalPreviewLiveWeb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4268A9BCEA43005C3CE56AF7 /* TerminalPreviewLiveWeb.swift */; }; 6548541B3AD17F7D70BFE8C0 /* TerminalPreviewLiveWebTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5437829CC09E381C6615908 /* TerminalPreviewLiveWebTests.swift */; }; 68AB6FD8F0F48EBAEDCA1F3D /* ShortcutExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60FE6CB3913B2DD433F57472 /* ShortcutExecutor.swift */; }; @@ -107,19 +120,23 @@ 7586AAB0E733735D89B19DC6 /* GhosttyAttachmentPasteboardSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CCA4A17BA5BEB5643174E99 /* GhosttyAttachmentPasteboardSnapshot.swift */; }; 7726730DFA9B5E99BF5686EA /* GhosttyKeyboardChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F24A0810E927B327E82D59 /* GhosttyKeyboardChrome.swift */; }; 77F42C4A7D6B2327955411D4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 693A617C3861CAF780D14828 /* Assets.xcassets */; }; + 7BA64A5EF27BE1C503CAC3B8 /* FileProviderSharedStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BEAB20D60BA0EDED606847A /* FileProviderSharedStorageTests.swift */; }; 7C99EDFDCE3B0927D26C1029 /* GhosttyAttachmentPasteboardSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DD89E37AC083383B988185 /* GhosttyAttachmentPasteboardSnapshotTests.swift */; }; 7D737693581BDF76436D61D9 /* GhosttyTerminalDisconnectReasonClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99082CEC90EA6D04B5BE5874 /* GhosttyTerminalDisconnectReasonClassifier.swift */; }; 7FB696D68436C55D304387E4 /* TmuxTerminalScreenAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D507C4F64535720BC9A3F665 /* TmuxTerminalScreenAdapter.swift */; }; + 808F5D8AF6DE8AC1A8802B5F /* FileProviderErrorMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 208AD7FB82B0A5E6C853B322 /* FileProviderErrorMapperTests.swift */; }; 842E6402F6F631B4DB19B8DE /* GhosttyTerminalResponderTextInputShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25DBECAA1C7625528DD0831B /* GhosttyTerminalResponderTextInputShim.swift */; }; 8538B4599CD88CEC07452142 /* GhosttyKitRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF11321286DB9076CB3FA983 /* GhosttyKitRuntime.swift */; }; 87A3C40BA75A75592A482867 /* GhosttyComposerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F7D40090293C627F8A1800 /* GhosttyComposerModel.swift */; }; 890ADA9AC6BC01F9D619CAD8 /* PanePreviewLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 603A62637F1E7C43FD9A1370 /* PanePreviewLayoutTests.swift */; }; 8AEEBAE61E93788016C85F9F /* RemuxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947084E6DA45D9EFB946CF51 /* RemuxApp.swift */; }; 8B1B766701C8B598CD3A5480 /* TmuxScreenModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B19046A43739DE9328A3612 /* TmuxScreenModel.swift */; }; + 8DA25F6FE7107A8E292167FE /* FileProviderMutationCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E641D81DB0036E0EDEEC810 /* FileProviderMutationCore.swift */; }; 8E312AEB02A4AD0A312DD610 /* SSHTmuxControlInboundStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74058D995BF7376879755FE /* SSHTmuxControlInboundStream.swift */; }; 8E7597434F30289D0A907FC8 /* GhosttyManagedSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C511196E36EC63A430EB9 /* GhosttyManagedSurface.swift */; }; 8E787177A7A3A043317795F0 /* TmuxTerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8184BF6C8C3A418E9BD893 /* TmuxTerminalSession.swift */; }; 8F75231F2CF7E597F4D0347F /* ConnectionProfileRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28EE543D2B247AE6C6A8C472 /* ConnectionProfileRepositoryTests.swift */; }; + 8F80983038DB0393C3C27087 /* FileProviderRemotePath.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF66E730E8F051A08B20F833 /* FileProviderRemotePath.swift */; }; 8F8D2F699717C9A7E23CF696 /* SSHAuthenticationMethodFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD48AB84F508C3391380701 /* SSHAuthenticationMethodFactory.swift */; }; 8F9982A1BB860E5C556FCF8B /* GhosttyAttachmentPreviewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC5D29DF530DB8A90FA8A60 /* GhosttyAttachmentPreviewStyle.swift */; }; 928FAC8CE4216F6413A7446A /* GhosttyTerminalDebugLatencyProbeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66D856D8B9DEB50622FCB367 /* GhosttyTerminalDebugLatencyProbeController.swift */; }; @@ -130,6 +147,7 @@ 9592BA50118FAF15A3B1A792 /* RemuxActiveSessionRuntimeReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76659C49175055C3E15DB226 /* RemuxActiveSessionRuntimeReducer.swift */; }; 97B5E2FB407BCF998515310C /* TmuxConnectionDraftValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA66578016B8B2B62FF2332F /* TmuxConnectionDraftValidatorTests.swift */; }; 97E03948DDE68AB3A9057BBF /* RemuxCitadelSFTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4564640EE19BD1AF5ED60E2C /* RemuxCitadelSFTPClient.swift */; }; + 989F0AA0CB41BED5F0DB2E01 /* FileProviderCitadelSFTPClientProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850AA4E5683E7E55F09E929C /* FileProviderCitadelSFTPClientProvider.swift */; }; 98F06D0122031E7CF2983E09 /* ShortcutStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94DD2F30DD98DCF438F312FA /* ShortcutStoreTests.swift */; }; 9AE92F88C80E1E5758A9C342 /* RemuxActiveSessionCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E683A3CF94359C176CCEA22A /* RemuxActiveSessionCollection.swift */; }; 9C712FFD570281CAAA1F034C /* SSHPublicKeyInstallCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6571D859285B5A3938DC43B /* SSHPublicKeyInstallCoordinator.swift */; }; @@ -137,6 +155,7 @@ A0BA158C314003057744D8A8 /* TmuxScreenModelForegroundActiveCheckTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3952803B4C0F5E847EEDEB75 /* TmuxScreenModelForegroundActiveCheckTests.swift */; }; A0C7372F57EB2BA980CC0888 /* RemuxLibrarySSHPrewarmPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5AA752EADDB9E6FBA8140A4 /* RemuxLibrarySSHPrewarmPlanner.swift */; }; A0D6CF5A40A4953636A1928F /* TerminalSettingsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AA39774D73AE5B6A77D824C /* TerminalSettingsRepository.swift */; }; + A4C1FADBADE3A0E177D8C130 /* RemuxFileProviderContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C20B4F77DEB78FC989C616 /* RemuxFileProviderContractTests.swift */; }; A7AFC27CD8E0B30AFF1A32BF /* GhosttyAttachmentImageMarkupEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC550DCE031B69C4EDD3C6F5 /* GhosttyAttachmentImageMarkupEditor.swift */; }; A8ABF8A3A082320C5C11BA70 /* RemuxActiveSessionRuntimeReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F3C1F4357FDC042F5B6E5C /* RemuxActiveSessionRuntimeReducerTests.swift */; }; A8D2AAE9BEC8E1557E05FD71 /* TerminalSelectionSheetStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFB3839D169808DACE1125F /* TerminalSelectionSheetStyle.swift */; }; @@ -144,6 +163,7 @@ AB2DBE12494A327FBC54FF9D /* GhosttyScrollDeltaBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C09E9400D2638AE72F1CEBFB /* GhosttyScrollDeltaBudgetTests.swift */; }; B056E6334A9EFBDB7FA305B3 /* TerminalPreviewCandidateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66FB23E143EA43A7AB28A323 /* TerminalPreviewCandidateTests.swift */; }; B3BF6CDA8110A71C8BDF0F85 /* GhosttyTmuxPrefixInputBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A510F3E8FC22231ACA4A8A99 /* GhosttyTmuxPrefixInputBuffer.swift */; }; + B5D5BD309983D0B5A594E79B /* FileProviderPollingLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79E3AAD928222C570371FE3A /* FileProviderPollingLoop.swift */; }; B64AE1F77F8F69A612E230AD /* GhosttyTerminalResponderViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E05107088F3540B7C04D2007 /* GhosttyTerminalResponderViewTests.swift */; }; B6A5A3D0FFED9CD4D416591E /* GhosttyComposeBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49261496FE7B949CF84D913B /* GhosttyComposeBar.swift */; }; B781685DD2926C3C37781ACC /* RemuxSSHExecSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 810D178FA878A5BEA4265D7E /* RemuxSSHExecSession.swift */; }; @@ -151,14 +171,18 @@ B825E6AFCD0D31CF9C88205F /* GhosttyTmuxActionTargetResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAB7FE3E70E32E69462BBA1 /* GhosttyTmuxActionTargetResolver.swift */; }; B873DB650FFEFC4B6291A395 /* Remux.icon in Resources */ = {isa = PBXBuildFile; fileRef = 0F0F72BD893CB5432182C6A6 /* Remux.icon */; }; B87EC6FA04ECF05EE36E1A5D /* SSHTmuxControlTransportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1924BBDC472F66719D23106 /* SSHTmuxControlTransportTests.swift */; }; + BA54398BF907C951435CA359 /* FileProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B338421BEF0BEA78717BB3D5 /* FileProvider.framework */; }; BABB38D7FEF52E7E6AA56A3E /* SSHPublicKeyInstallCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64049B36D501D0932C56A2D7 /* SSHPublicKeyInstallCoordinatorTests.swift */; }; BCFDD04E99F80E73053BE38A /* GhosttyAttachmentImagePreviewDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B657FFBCF25A6A1A77FD231 /* GhosttyAttachmentImagePreviewDataTests.swift */; }; + BDAFCD98A1EC648F04A2C569 /* FileProviderMutationValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1CC46C61A0973AD4282BF1F /* FileProviderMutationValidator.swift */; }; BE129D63D6BDCAA96AC4A8CF /* GhosttyTerminalResponderFocusPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB312A306DE375B66AB05D39 /* GhosttyTerminalResponderFocusPolicyTests.swift */; }; C004B8CB3BEA6B9F5F7C43BF /* Haptic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8926D94B4AAF192302FC58E4 /* Haptic.swift */; }; C0EA4F7A9640DAC31A0B3DCE /* GhosttyKeyboardVisibilityProjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B38F49B9EC49FF6E9791967C /* GhosttyKeyboardVisibilityProjection.swift */; }; C37BEA72EEFDD7041F8E4059 /* GhosttyManagedSurfaceLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0CE2626790CFB617C7B39A /* GhosttyManagedSurfaceLookup.swift */; }; C3A8DE373D7176A8536D9523 /* RemuxAppDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CCD1093451DAA47FF649549 /* RemuxAppDependencies.swift */; }; C5524C488C1300697E572AD5 /* RemuxLibrarySSHPrewarmCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2693DBF11402EDAEC946123A /* RemuxLibrarySSHPrewarmCoordinator.swift */; }; + C5F95736CD435CA56D834B5E /* FileProviderRemoteItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2585CF2DC8CEC5017488892D /* FileProviderRemoteItem.swift */; }; + C90D9F9D9A64855E4FDABF63 /* FileProviderMutationValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3E60D2CC9BE153BDB8F5E3F /* FileProviderMutationValidatorTests.swift */; }; CC4A4379786ED8153C849967 /* SSHTmuxControlCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F094A10EBF0BCD867A6B270C /* SSHTmuxControlCommandBuilder.swift */; }; CD12942E9BEC15344DADB7BC /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBF1D7B0CCEAA8D032C1DDC /* RootView.swift */; }; CD72317822BBD2753878DCA2 /* RemuxSFTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3F9F61A40DD02A7DD80980 /* RemuxSFTPClient.swift */; }; @@ -166,14 +190,18 @@ CFF1208F57BD8CE3BA9507F6 /* TmuxPanePreviewImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5370A2DE2689DAF5E27E57FD /* TmuxPanePreviewImageCache.swift */; }; D139ABC351802768520D8A4F /* TerminalRuntimeStatusPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267386EB1A94C2A6029D90F6 /* TerminalRuntimeStatusPresentation.swift */; }; D3A27BCC972E451CDB6113E1 /* ShortcutCollectionIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4A946B4CBB7E49BCAA7D52B /* ShortcutCollectionIconView.swift */; }; + D4B1DF99E07CE5B888F58CA5 /* FileProviderDomainOperationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5603BFAD5D9D83093B0ED030 /* FileProviderDomainOperationCoordinator.swift */; }; D4D164A1ADCB32103FFDE81E /* RemuxSessionLiveForwardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73C4CC8673D390A85EC1741D /* RemuxSessionLiveForwardTests.swift */; }; D5510B89C4BE9205DC25104C /* GhosttyDebugComposerDictationBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E17D3CFD2103F3148C1EA6 /* GhosttyDebugComposerDictationBackend.swift */; }; D5BC8B5AFF97FD9FDD27D04A /* TmuxTerminalSessionShutdownDrainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18E95DAAAF9312AED56CC0F /* TmuxTerminalSessionShutdownDrainTests.swift */; }; D6B81547DE2C2556B390C9C7 /* GhosttyRemoteAttachmentPathBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F881DB2CF820F05DCDC2768 /* GhosttyRemoteAttachmentPathBuilderTests.swift */; }; D78DD9D2C761CC45DB856D42 /* GhosttyTopLevelSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88095738876B31835109065A /* GhosttyTopLevelSurface.swift */; }; D7E2EF5FBDBD0CC24FC29257 /* ShortcutExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B77D12E9FC8E4FF183EC209 /* ShortcutExecutorTests.swift */; }; + D88DE037082B0056A977CD83 /* FileProviderEnumeratorCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A845252140A8277612A99297 /* FileProviderEnumeratorCore.swift */; }; D90BFAB652BCFFB9901C5E31 /* GhosttyPanePreviewSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = F45C3C2EE05C5EE81A6CC979 /* GhosttyPanePreviewSession.swift */; }; + DB3525FC134F5E53968FBB63 /* FileProviderSharedStorageMigratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC00AAA8C4518CE1F9530C00 /* FileProviderSharedStorageMigratorTests.swift */; }; DBC5F67F490AAC96F4918650 /* GhosttyKitControlSurfaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF4BB00F78EE94D58937F98A /* GhosttyKitControlSurfaceTests.swift */; }; + DE5BB5328EF5F61261C1B842 /* FileProviderDomainReconcilerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC73E66BB7BA53B5E6E31B31 /* FileProviderDomainReconcilerTests.swift */; }; DEC0360F7ED54DE71B345B60 /* GhosttyTerminalPresentationProjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D407500AB027C206B65EE7C /* GhosttyTerminalPresentationProjector.swift */; }; DED4088C1635F130FC578C91 /* GhosttyTmuxPrefixInputBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A9F4B4B0260131778AB2BA5 /* GhosttyTmuxPrefixInputBufferTests.swift */; }; E0787109BFFDE0C211C26BFB /* GhosttyScrollPhysicsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 216C560F444E51EAEA88D748 /* GhosttyScrollPhysicsView.swift */; }; @@ -184,9 +212,11 @@ E35A4C6758E1D06E01EE5826 /* ConnectionProfileRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48AC2CE228C16D7E9DCD09BE /* ConnectionProfileRepository.swift */; }; E4567A58B784F0F52B31505F /* TmuxPaneSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78CD307268384537F5CF1219 /* TmuxPaneSurface.swift */; }; E5127955E3EE44561EEFFB58 /* GhosttyTerminalViewportCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B1BE024B39B56C24D7F7A2 /* GhosttyTerminalViewportCoordinatorTests.swift */; }; + E5127CF95462516FEB047174 /* RemuxSFTPReadOnlyClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A833C1613C1CD45D1EB5B6AC /* RemuxSFTPReadOnlyClientTests.swift */; }; E6AB349F8A2D7ED9336D8052 /* GhosttyKeyboardCursorTrackpadHUD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233453E510348B0E5F5B8C2C /* GhosttyKeyboardCursorTrackpadHUD.swift */; }; E7197DF29F93624F83D17240 /* SSHPublicKeyInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23798EF07D4A939CF330686D /* SSHPublicKeyInstaller.swift */; }; EBBDE3E254A58C4423E9D1E6 /* SSHTmuxControlTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E957489B11B107A9340622 /* SSHTmuxControlTransport.swift */; }; + ECCFAC45E7876FF36745C9FC /* FileProviderSnapshotStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39CC72773E8432EBC3126E4 /* FileProviderSnapshotStoreTests.swift */; }; ECD481A59F4FDC83965842A7 /* GhosttyKitControlSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92A3F2240841EA1962403A0 /* GhosttyKitControlSurface.swift */; }; ED6093ABA6E192BEB2219157 /* GhosttyComposerSubmissionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C034CA49D392AB2574E8403 /* GhosttyComposerSubmissionController.swift */; }; ED9FB6945BB0CB3D6CDCB3E3 /* ShortcutsSettingsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE401B861C30DA18E8BE1C9 /* ShortcutsSettingsSheet.swift */; }; @@ -205,6 +235,7 @@ F4CB602D5DC2C8F2BA4FFF12 /* GhosttyModifierStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED11F0B6BF0469948945A509 /* GhosttyModifierStateTests.swift */; }; F54B4A3EDFFB92FE150328AD /* GhosttyComposerSubmissionControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73A45AC69F71555066A656AE /* GhosttyComposerSubmissionControllerTests.swift */; }; F5C80E284501AD304B477EA5 /* GhosttyAttachmentPreviewSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4149F2629A01104A454FA9EB /* GhosttyAttachmentPreviewSheet.swift */; }; + F696F748589435EFB7894157 /* FileProviderItemProjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85CEC108FE540369F1F15BF4 /* FileProviderItemProjection.swift */; }; F803023604D68C15385E45AB /* TerminalSettingsRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7303BA60EDF97F8E9F3BFB26 /* TerminalSettingsRepositoryTests.swift */; }; FA246B72DCA300C012BBB7DF /* SSHPublicKeyInstallSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3946EC0FFAF1FDB73056471C /* SSHPublicKeyInstallSheet.swift */; }; FCD018AF0D5D80970AC22AC4 /* SSHCredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD50E6815BED6CB2E3B793C9 /* SSHCredentialStore.swift */; }; @@ -242,19 +273,22 @@ 0CB12DC1EC78CE1C1286BA4E /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GhosttyKit.xcframework; path = "../ghostty-remux-upstream-rebuild/macos/GhosttyKit.xcframework"; sourceTree = ""; }; 0CBF1D7B0CCEAA8D032C1DDC /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 0DA264E3527C3F2FC33AF435 /* GhosttyTerminalInputCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalInputCoordinatorTests.swift; sourceTree = ""; }; - 0F0F72BD893CB5432182C6A6 /* Remux.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = Remux.icon; sourceTree = ""; }; + 0F0F72BD893CB5432182C6A6 /* Remux.icon */ = {isa = PBXFileReference; path = Remux.icon; sourceTree = ""; }; 12398BF9373F42BEB01C099E /* TmuxIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxIdentity.swift; sourceTree = ""; }; 1643BC65F62CA9668005EEF8 /* GhosttyTerminalResponderFocusPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalResponderFocusPolicy.swift; sourceTree = ""; }; + 16B659789EF103E965A513C0 /* FileProviderDomainOperationCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderDomainOperationCoordinatorTests.swift; sourceTree = ""; }; 1970DDCD2CEF7C163B953BAC /* SSHAuthResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHAuthResolver.swift; sourceTree = ""; }; 1C5B9B46D85CC2D1FEBDBECE /* TerminalPreviewCandidate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPreviewCandidate.swift; sourceTree = ""; }; 1CCFEA70AB6AEF95B946201D /* RemuxTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = RemuxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 1DFA7B573241258BA93FCF29 /* GhosttyKeyboardCursorTrackpadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardCursorTrackpadTests.swift; sourceTree = ""; }; 1F5DFFCFEE03768DB9B6E879 /* GhosttySpeechAnalyzerComposerDictationBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySpeechAnalyzerComposerDictationBackend.swift; sourceTree = ""; }; + 208AD7FB82B0A5E6C853B322 /* FileProviderErrorMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderErrorMapperTests.swift; sourceTree = ""; }; 216C560F444E51EAEA88D748 /* GhosttyScrollPhysicsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyScrollPhysicsView.swift; sourceTree = ""; }; 21B59E540592993567162928 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 233453E510348B0E5F5B8C2C /* GhosttyKeyboardCursorTrackpadHUD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardCursorTrackpadHUD.swift; sourceTree = ""; }; 23798EF07D4A939CF330686D /* SSHPublicKeyInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPublicKeyInstaller.swift; sourceTree = ""; }; 23F3CE22CD66E574D1A7196E /* GhosttySurfaceScrollGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceScrollGesture.swift; sourceTree = ""; }; + 2585CF2DC8CEC5017488892D /* FileProviderRemoteItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderRemoteItem.swift; sourceTree = ""; }; 25DBECAA1C7625528DD0831B /* GhosttyTerminalResponderTextInputShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalResponderTextInputShim.swift; sourceTree = ""; }; 25F0019F1714AE6BD9F41209 /* RemuxAppUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxAppUITests.swift; sourceTree = ""; }; 267386EB1A94C2A6029D90F6 /* TerminalRuntimeStatusPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRuntimeStatusPresentation.swift; sourceTree = ""; }; @@ -269,6 +303,7 @@ 2DE43FCF1FB0A2A5205281A9 /* GhosttyTerminalDebugLatencyProbeControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalDebugLatencyProbeControllerTests.swift; sourceTree = ""; }; 31CDE08ADC3CEED179BF9A2D /* GhosttyAttachmentImagePreviewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAttachmentImagePreviewData.swift; sourceTree = ""; }; 375579FD92E3B9A605C7E9EC /* GhosttyTerminalRuntimeStateReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalRuntimeStateReporter.swift; sourceTree = ""; }; + 37C20B4F77DEB78FC989C616 /* RemuxFileProviderContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxFileProviderContractTests.swift; sourceTree = ""; }; 3946EC0FFAF1FDB73056471C /* SSHPublicKeyInstallSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPublicKeyInstallSheet.swift; sourceTree = ""; }; 3952803B4C0F5E847EEDEB75 /* TmuxScreenModelForegroundActiveCheckTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxScreenModelForegroundActiveCheckTests.swift; sourceTree = ""; }; 3B1C511196E36EC63A430EB9 /* GhosttyManagedSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyManagedSurface.swift; sourceTree = ""; }; @@ -277,12 +312,14 @@ 3FA2520FAE4E2D228D25CADB /* GhosttySurfaceSelectionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceSelectionSheet.swift; sourceTree = ""; }; 4149F2629A01104A454FA9EB /* GhosttyAttachmentPreviewSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAttachmentPreviewSheet.swift; sourceTree = ""; }; 4268A9BCEA43005C3CE56AF7 /* TerminalPreviewLiveWeb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPreviewLiveWeb.swift; sourceTree = ""; }; + 4369D3BB817295081F553170 /* FileProviderSnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderSnapshotStore.swift; sourceTree = ""; }; 43E94EB8EAE8524275106704 /* GhosttySurfaceStatusOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceStatusOverlay.swift; sourceTree = ""; }; 44349A06B1D86FBF3C2CB578 /* TerminalPreviewSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPreviewSession.swift; sourceTree = ""; }; 4494B99A635444622AEC6970 /* TerminalPreviewSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPreviewSessionTests.swift; sourceTree = ""; }; 451C5235FD50DD80CBC53CCC /* GhosttySurfaceScrollGestureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceScrollGestureTests.swift; sourceTree = ""; }; 4564640EE19BD1AF5ED60E2C /* RemuxCitadelSFTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxCitadelSFTPClient.swift; sourceTree = ""; }; 4671F22C07201C31912267E9 /* GhosttyTerminalPresentationProjectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalPresentationProjectorTests.swift; sourceTree = ""; }; + 46C317118AE1C94878179FBD /* FileProviderItemIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderItemIdentity.swift; sourceTree = ""; }; 486F770F3A47F522406131B7 /* SSHPublicKeyRemoteInstallerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPublicKeyRemoteInstallerTests.swift; sourceTree = ""; }; 48AC2CE228C16D7E9DCD09BE /* ConnectionProfileRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionProfileRepository.swift; sourceTree = ""; }; 49261496FE7B949CF84D913B /* GhosttyComposeBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyComposeBar.swift; sourceTree = ""; }; @@ -291,12 +328,17 @@ 4D407500AB027C206B65EE7C /* GhosttyTerminalPresentationProjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalPresentationProjector.swift; sourceTree = ""; }; 4E84F774C17B5C4DB6B715A9 /* GhosttySurfaceKeyEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceKeyEventTests.swift; sourceTree = ""; }; 5087B7872EECF17538D23AF9 /* ConnectionPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionPresentation.swift; sourceTree = ""; }; + 5133DB5A0970D94CC2A8241C /* FileProviderMutationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderMutationRequest.swift; sourceTree = ""; }; 532E9E62CF3E74FEE31F5C05 /* GhosttyTopLevelSurfaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTopLevelSurfaceTests.swift; sourceTree = ""; }; 5370A2DE2689DAF5E27E57FD /* TmuxPanePreviewImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxPanePreviewImageCache.swift; sourceTree = ""; }; + 55622A6FC71B6F2FEA79BCDA /* FileProviderErrorMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderErrorMapper.swift; sourceTree = ""; }; + 5603BFAD5D9D83093B0ED030 /* FileProviderDomainOperationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderDomainOperationCoordinator.swift; sourceTree = ""; }; 575EDAA61B2B7A0B241A5E4D /* TmuxSessionLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxSessionLink.swift; sourceTree = ""; }; + 590F8C3561F59A464ADA9CB9 /* FileProviderMutationCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderMutationCoreTests.swift; sourceTree = ""; }; 5A245FF40A1998FA618E6370 /* RemuxPreparedTransportCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxPreparedTransportCacheTests.swift; sourceTree = ""; }; 5A9A68C878571836AC7EDA41 /* GhosttyAttachmentStagingStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAttachmentStagingStoreTests.swift; sourceTree = ""; }; 5BE8D13A42F5D190C93DF02D /* SSHAuthResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHAuthResolverTests.swift; sourceTree = ""; }; + 5DB7086AFA61A884FC2D874C /* FileProviderRemoteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderRemoteService.swift; sourceTree = ""; }; 5EFEF4C2C61B013DA831F7D9 /* Shortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shortcut.swift; sourceTree = ""; }; 603A62637F1E7C43FD9A1370 /* PanePreviewLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanePreviewLayoutTests.swift; sourceTree = ""; }; 6051890D165AEFC762DD5DF4 /* RemuxPreparedTransportCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxPreparedTransportCache.swift; sourceTree = ""; }; @@ -314,6 +356,7 @@ 6AA39774D73AE5B6A77D824C /* TerminalSettingsRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSettingsRepository.swift; sourceTree = ""; }; 6B19046A43739DE9328A3612 /* TmuxScreenModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxScreenModel.swift; sourceTree = ""; }; 6B657FFBCF25A6A1A77FD231 /* GhosttyAttachmentImagePreviewDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAttachmentImagePreviewDataTests.swift; sourceTree = ""; }; + 6BEAB20D60BA0EDED606847A /* FileProviderSharedStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderSharedStorageTests.swift; sourceTree = ""; }; 6DF29AE5BA0A3DD1E7A9E9C5 /* ActiveSessionSwitcherProjectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionSwitcherProjectionTests.swift; sourceTree = ""; }; 6DFE39E14E057AACAB31247A /* GhosttyComposerDictationControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyComposerDictationControllerTests.swift; sourceTree = ""; }; 7001FC8A87E77C3617046DA7 /* GhosttyRuntimeTrace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyRuntimeTrace.swift; sourceTree = ""; }; @@ -326,13 +369,20 @@ 76659C49175055C3E15DB226 /* RemuxActiveSessionRuntimeReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxActiveSessionRuntimeReducer.swift; sourceTree = ""; }; 771B767054F02C52F17EB92B /* StarterShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarterShortcuts.swift; sourceTree = ""; }; 78CD307268384537F5CF1219 /* TmuxPaneSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxPaneSurface.swift; sourceTree = ""; }; + 795B3162A540A5A29DEBED76 /* FileProviderRemoteItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderRemoteItemTests.swift; sourceTree = ""; }; + 79E3AAD928222C570371FE3A /* FileProviderPollingLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderPollingLoop.swift; sourceTree = ""; }; 7C826602D9C963622FB26A40 /* TerminalThemePreviewRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalThemePreviewRenderer.swift; sourceTree = ""; }; + 7E641D81DB0036E0EDEEC810 /* FileProviderMutationCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderMutationCore.swift; sourceTree = ""; }; + 7F7619C21FCE4B90EA93C0B2 /* FileProviderSharedStorageMigrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderSharedStorageMigrator.swift; sourceTree = ""; }; 810D178FA878A5BEA4265D7E /* RemuxSSHExecSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxSSHExecSession.swift; sourceTree = ""; }; 8151E185EE3CC5784181D01E /* GhosttyAttachmentTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAttachmentTransfer.swift; sourceTree = ""; }; + 81CCA16E5131F9DD6F87DE4E /* RemuxTransportStartupTrace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxTransportStartupTrace.swift; sourceTree = ""; }; 820136FA2586D2054D312C3D /* SSHPrivateKeyInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPrivateKeyInspector.swift; sourceTree = ""; }; 8421B86729F6E46ABD20CBD5 /* GhosttyTerminalScreenModeling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalScreenModeling.swift; sourceTree = ""; }; 84DD89E37AC083383B988185 /* GhosttyAttachmentPasteboardSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAttachmentPasteboardSnapshotTests.swift; sourceTree = ""; }; + 850AA4E5683E7E55F09E929C /* FileProviderCitadelSFTPClientProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderCitadelSFTPClientProvider.swift; sourceTree = ""; }; 85665F143AC61F871841ED90 /* TmuxConnectionTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxConnectionTarget.swift; sourceTree = ""; }; + 85CEC108FE540369F1F15BF4 /* FileProviderItemProjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderItemProjection.swift; sourceTree = ""; }; 87358B27991E5D2ECE199ECC /* RemuxSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxSmokeTests.swift; sourceTree = ""; }; 88095738876B31835109065A /* GhosttyTopLevelSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTopLevelSurface.swift; sourceTree = ""; }; 8926D94B4AAF192302FC58E4 /* Haptic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptic.swift; sourceTree = ""; }; @@ -350,22 +400,29 @@ 99082CEC90EA6D04B5BE5874 /* GhosttyTerminalDisconnectReasonClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalDisconnectReasonClassifier.swift; sourceTree = ""; }; 9AC92D96015D360B52AA23FB /* GhosttyTerminalRuntimeStateReporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalRuntimeStateReporterTests.swift; sourceTree = ""; }; 9AFA16C96FE00BC33898C788 /* GhosttyAttachmentStagingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAttachmentStagingStore.swift; sourceTree = ""; }; + 9B50C6E14DA1AD375C55E17B /* FileProviderDomainReconciler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderDomainReconciler.swift; sourceTree = ""; }; 9B695C926DA594038E2C6144 /* TerminalPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPreviewView.swift; sourceTree = ""; }; 9C76A9A6D14624AC5E1155D8 /* RemuxRootModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxRootModelTests.swift; sourceTree = ""; }; 9CCA4A17BA5BEB5643174E99 /* GhosttyAttachmentPasteboardSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAttachmentPasteboardSnapshot.swift; sourceTree = ""; }; 9CD6734AD2260C5731B081A1 /* TerminalThemePreviewRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalThemePreviewRendererTests.swift; sourceTree = ""; }; A1886BE69E5F2201A5629E0C /* GhosttyTerminalSurfaceInteractionOutcomeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalSurfaceInteractionOutcomeTests.swift; sourceTree = ""; }; + A39CC72773E8432EBC3126E4 /* FileProviderSnapshotStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderSnapshotStoreTests.swift; sourceTree = ""; }; A510F3E8FC22231ACA4A8A99 /* GhosttyTmuxPrefixInputBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTmuxPrefixInputBuffer.swift; sourceTree = ""; }; A6E17D3CFD2103F3148C1EA6 /* GhosttyDebugComposerDictationBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyDebugComposerDictationBackend.swift; sourceTree = ""; }; A76BB5CC730EB26907A34E42 /* GhosttyRuntimeSurfaceTopologySnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyRuntimeSurfaceTopologySnapshotTests.swift; sourceTree = ""; }; A7D47826A3796D23B6B88804 /* GhosttyIOSurfaceFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyIOSurfaceFrame.swift; sourceTree = ""; }; + A833C1613C1CD45D1EB5B6AC /* RemuxSFTPReadOnlyClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxSFTPReadOnlyClientTests.swift; sourceTree = ""; }; + A845252140A8277612A99297 /* FileProviderEnumeratorCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderEnumeratorCore.swift; sourceTree = ""; }; A88FBFD7D50B9817DC09FED6 /* GhosttySurfaceMouseEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceMouseEventTests.swift; sourceTree = ""; }; A9BEEA4655420D5DCEDE5C23 /* GhosttyPhoneChromeLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyPhoneChromeLayoutTests.swift; sourceTree = ""; }; AC550DCE031B69C4EDD3C6F5 /* GhosttyAttachmentImageMarkupEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAttachmentImageMarkupEditor.swift; sourceTree = ""; }; + AC73E66BB7BA53B5E6E31B31 /* FileProviderDomainReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderDomainReconcilerTests.swift; sourceTree = ""; }; AD50E6815BED6CB2E3B793C9 /* SSHCredentialStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHCredentialStore.swift; sourceTree = ""; }; AE5B67646A0F14933B3FD8D8 /* ApplicationStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationStorage.swift; sourceTree = ""; }; B1861094273B802BF30CB94A /* TmuxTerminalScreenAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxTerminalScreenAdapterTests.swift; sourceTree = ""; }; B1AAF15C6761B24466DD78D6 /* TerminalRuntimeStatusPresentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRuntimeStatusPresentationTests.swift; sourceTree = ""; }; + B1CC46C61A0973AD4282BF1F /* FileProviderMutationValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderMutationValidator.swift; sourceTree = ""; }; + B338421BEF0BEA78717BB3D5 /* FileProvider.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = FileProvider.framework; path = System/Library/Frameworks/FileProvider.framework; sourceTree = SDKROOT; }; B38F49B9EC49FF6E9791967C /* GhosttyKeyboardVisibilityProjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardVisibilityProjection.swift; sourceTree = ""; }; B4C7136948AF9803FAAD6F82 /* GhosttyKeyboardCursorTrackpad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardCursorTrackpad.swift; sourceTree = ""; }; B6571D859285B5A3938DC43B /* SSHPublicKeyInstallCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPublicKeyInstallCoordinator.swift; sourceTree = ""; }; @@ -379,6 +436,7 @@ C18E95DAAAF9312AED56CC0F /* TmuxTerminalSessionShutdownDrainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxTerminalSessionShutdownDrainTests.swift; sourceTree = ""; }; C278E39F3479557BA84AEF81 /* SSHTmuxControlChannelRequestTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHTmuxControlChannelRequestTracker.swift; sourceTree = ""; }; C6233E6291A38BAAE86C727D /* Remux.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Remux.app; sourceTree = BUILT_PRODUCTS_DIR; }; + C682B5372D1AC1D7C99D6B2C /* FileProviderSDKItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderSDKItem.swift; sourceTree = ""; }; C6958616F9B9DFD9E0E38E6B /* GhosttySurfaceKeyEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceKeyEvent.swift; sourceTree = ""; }; C7313F7C3049C269572F59B5 /* RemuxLibrarySSHPrewarmCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxLibrarySSHPrewarmCoordinatorTests.swift; sourceTree = ""; }; C802E6FAF0F2D5CE9F8BCA34 /* ActiveSessionSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionSwitcherView.swift; sourceTree = ""; }; @@ -389,6 +447,7 @@ CFE401B861C30DA18E8BE1C9 /* ShortcutsSettingsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsSettingsSheet.swift; sourceTree = ""; }; D047E23F9EFC609398779342 /* RemuxSessionLiveForward.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxSessionLiveForward.swift; sourceTree = ""; }; D20F57C8C6D3F75E3E07DDC9 /* RemuxRootModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxRootModel.swift; sourceTree = ""; }; + D3E60D2CC9BE153BDB8F5E3F /* FileProviderMutationValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderMutationValidatorTests.swift; sourceTree = ""; }; D4FB03D3718F47ECDF15AEC3 /* GhosttyQuickLookPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyQuickLookPreview.swift; sourceTree = ""; }; D507C4F64535720BC9A3F665 /* TmuxTerminalScreenAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxTerminalScreenAdapter.swift; sourceTree = ""; }; D5437829CC09E381C6615908 /* TerminalPreviewLiveWebTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPreviewLiveWebTests.swift; sourceTree = ""; }; @@ -410,12 +469,14 @@ E3A4228D3F3885A93C87FD75 /* GhosttyAttachmentInteractiveImagePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAttachmentInteractiveImagePreview.swift; sourceTree = ""; }; E4A946B4CBB7E49BCAA7D52B /* ShortcutCollectionIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutCollectionIconView.swift; sourceTree = ""; }; E509784BCB04CA957763EB32 /* DeterministicTmuxControlTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterministicTmuxControlTransport.swift; sourceTree = ""; }; + E5E36D7E11A5C14870D1D35D /* FileProviderRemoteServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderRemoteServiceTests.swift; sourceTree = ""; }; E6264B24DDF73BB7A9ACA1D7 /* TmuxSessionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxSessionController.swift; sourceTree = ""; }; E683A3CF94359C176CCEA22A /* RemuxActiveSessionCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxActiveSessionCollection.swift; sourceTree = ""; }; E7C60C2A339E149300CA7177 /* TerminalPreviewFileLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPreviewFileLoader.swift; sourceTree = ""; }; EAEFDFDF747576F43E843FAA /* GhosttyAttachmentNotice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAttachmentNotice.swift; sourceTree = ""; }; EB312A306DE375B66AB05D39 /* GhosttyTerminalResponderFocusPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalResponderFocusPolicyTests.swift; sourceTree = ""; }; EB7372853BCE26F35945D24A /* GhosttyTerminalResponderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalResponderView.swift; sourceTree = ""; }; + EC00AAA8C4518CE1F9530C00 /* FileProviderSharedStorageMigratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderSharedStorageMigratorTests.swift; sourceTree = ""; }; ED11F0B6BF0469948945A509 /* GhosttyModifierStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyModifierStateTests.swift; sourceTree = ""; }; EDB40C9C83785C6930E52ACC /* GhosttyTerminalSurfaceInteractionOutcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalSurfaceInteractionOutcome.swift; sourceTree = ""; }; EF4BB00F78EE94D58937F98A /* GhosttyKitControlSurfaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKitControlSurfaceTests.swift; sourceTree = ""; }; @@ -431,6 +492,7 @@ FC3F9F61A40DD02A7DD80980 /* RemuxSFTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxSFTPClient.swift; sourceTree = ""; }; FEDEA02EF430A8D0066B6B42 /* TmuxSessionLinkWriteFailureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxSessionLinkWriteFailureTests.swift; sourceTree = ""; }; FF11321286DB9076CB3FA983 /* GhosttyKitRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKitRuntime.swift; sourceTree = ""; }; + FF66E730E8F051A08B20F833 /* FileProviderRemotePath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderRemotePath.swift; sourceTree = ""; }; FFD16711790E09A2358816EA /* SSHPublicKeyRemoteInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPublicKeyRemoteInstaller.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -441,6 +503,7 @@ files = ( E312086F6A463AC00BC93C8E /* GhosttyKit.xcframework in Frameworks */, EE0A712F52F48C1AB61D7AEC /* Citadel in Frameworks */, + BA54398BF907C951435CA359 /* FileProvider.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -511,6 +574,16 @@ 6DF29AE5BA0A3DD1E7A9E9C5 /* ActiveSessionSwitcherProjectionTests.swift */, 28EE543D2B247AE6C6A8C472 /* ConnectionProfileRepositoryTests.swift */, DFD9AF0371D15A892B000530 /* DebugConnectionProfileSeederTests.swift */, + 16B659789EF103E965A513C0 /* FileProviderDomainOperationCoordinatorTests.swift */, + AC73E66BB7BA53B5E6E31B31 /* FileProviderDomainReconcilerTests.swift */, + 208AD7FB82B0A5E6C853B322 /* FileProviderErrorMapperTests.swift */, + 590F8C3561F59A464ADA9CB9 /* FileProviderMutationCoreTests.swift */, + D3E60D2CC9BE153BDB8F5E3F /* FileProviderMutationValidatorTests.swift */, + 795B3162A540A5A29DEBED76 /* FileProviderRemoteItemTests.swift */, + E5E36D7E11A5C14870D1D35D /* FileProviderRemoteServiceTests.swift */, + EC00AAA8C4518CE1F9530C00 /* FileProviderSharedStorageMigratorTests.swift */, + 6BEAB20D60BA0EDED606847A /* FileProviderSharedStorageTests.swift */, + A39CC72773E8432EBC3126E4 /* FileProviderSnapshotStoreTests.swift */, 2A1CB6877AF169792137FA56 /* GhosttyAttachmentImageMarkupRendererTests.swift */, 6B657FFBCF25A6A1A77FD231 /* GhosttyAttachmentImagePreviewDataTests.swift */, 84DD89E37AC083383B988185 /* GhosttyAttachmentPasteboardSnapshotTests.swift */, @@ -547,12 +620,14 @@ 603A62637F1E7C43FD9A1370 /* PanePreviewLayoutTests.swift */, 01FC67667E36CB7A186EFC68 /* RemuxActiveSessionCollectionTests.swift */, 03F3C1F4357FDC042F5B6E5C /* RemuxActiveSessionRuntimeReducerTests.swift */, + 37C20B4F77DEB78FC989C616 /* RemuxFileProviderContractTests.swift */, C7313F7C3049C269572F59B5 /* RemuxLibrarySSHPrewarmCoordinatorTests.swift */, F165D54E7110056429A070F7 /* RemuxLibrarySSHPrewarmPlannerTests.swift */, 5A245FF40A1998FA618E6370 /* RemuxPreparedTransportCacheTests.swift */, 4B3782AB19C138622144EE40 /* RemuxPreparedTransportCoordinatorTests.swift */, 9C76A9A6D14624AC5E1155D8 /* RemuxRootModelTests.swift */, 73C4CC8673D390A85EC1741D /* RemuxSessionLiveForwardTests.swift */, + A833C1613C1CD45D1EB5B6AC /* RemuxSFTPReadOnlyClientTests.swift */, 87358B27991E5D2ECE199ECC /* RemuxSmokeTests.swift */, 9586DC1E9C276FB8513E59B4 /* RemuxSSHExecSessionTests.swift */, 8B77D12E9FC8E4FF183EC209 /* ShortcutExecutorTests.swift */, @@ -590,6 +665,7 @@ FC3F9F61A40DD02A7DD80980 /* RemuxSFTPClient.swift */, 810D178FA878A5BEA4265D7E /* RemuxSSHExecSession.swift */, CC23D872C331C7574737CCDA /* RemuxSSHRootService.swift */, + 81CCA16E5131F9DD6F87DE4E /* RemuxTransportStartupTrace.swift */, 4AD48AB84F508C3391380701 /* SSHAuthenticationMethodFactory.swift */, 23798EF07D4A939CF330686D /* SSHPublicKeyInstaller.swift */, FFD16711790E09A2358816EA /* SSHPublicKeyRemoteInstaller.swift */, @@ -609,6 +685,7 @@ 3AB596FFF4943E6A358B8717 /* Frameworks */ = { isa = PBXGroup; children = ( + B338421BEF0BEA78717BB3D5 /* FileProvider.framework */, 0CB12DC1EC78CE1C1286BA4E /* GhosttyKit.xcframework */, ); name = Frameworks; @@ -619,6 +696,7 @@ children = ( AE5B67646A0F14933B3FD8D8 /* ApplicationStorage.swift */, 48AC2CE228C16D7E9DCD09BE /* ConnectionProfileRepository.swift */, + 7F7619C21FCE4B90EA93C0B2 /* FileProviderSharedStorageMigrator.swift */, 0C7EBCE00319FF2879275BDD /* JSONFileStore.swift */, DA9987EA1F7B5FDC3A50ECEA /* ShortcutStore.swift */, AD50E6815BED6CB2E3B793C9 /* SSHCredentialStore.swift */, @@ -638,6 +716,29 @@ path = RemuxApp; sourceTree = ""; }; + 8114E55BD2CD34EEF8661EFC /* FileProvider */ = { + isa = PBXGroup; + children = ( + 850AA4E5683E7E55F09E929C /* FileProviderCitadelSFTPClientProvider.swift */, + 5603BFAD5D9D83093B0ED030 /* FileProviderDomainOperationCoordinator.swift */, + 9B50C6E14DA1AD375C55E17B /* FileProviderDomainReconciler.swift */, + A845252140A8277612A99297 /* FileProviderEnumeratorCore.swift */, + 55622A6FC71B6F2FEA79BCDA /* FileProviderErrorMapper.swift */, + 46C317118AE1C94878179FBD /* FileProviderItemIdentity.swift */, + 85CEC108FE540369F1F15BF4 /* FileProviderItemProjection.swift */, + 7E641D81DB0036E0EDEEC810 /* FileProviderMutationCore.swift */, + 5133DB5A0970D94CC2A8241C /* FileProviderMutationRequest.swift */, + B1CC46C61A0973AD4282BF1F /* FileProviderMutationValidator.swift */, + 79E3AAD928222C570371FE3A /* FileProviderPollingLoop.swift */, + 2585CF2DC8CEC5017488892D /* FileProviderRemoteItem.swift */, + FF66E730E8F051A08B20F833 /* FileProviderRemotePath.swift */, + 5DB7086AFA61A884FC2D874C /* FileProviderRemoteService.swift */, + C682B5372D1AC1D7C99D6B2C /* FileProviderSDKItem.swift */, + 4369D3BB817295081F553170 /* FileProviderSnapshotStore.swift */, + ); + path = FileProvider; + sourceTree = ""; + }; 8940332584E6E630AC566417 /* RemuxAppUITests */ = { isa = PBXGroup; children = ( @@ -665,6 +766,7 @@ children = ( 32334451C65B7C4F00E30040 /* App */, D9A30ED1878BFD3CD31E92C7 /* Domain */, + 8114E55BD2CD34EEF8661EFC /* FileProvider */, ECBBAB0FEBB89BD77EBA3C07 /* Ghostty */, 5533378BF561DDFC4C936F16 /* Persistence */, 8FABD5271C2C8D005DA63033 /* Preview */, @@ -854,6 +956,7 @@ }; }; buildConfigurationList = F118F8E44563891091219E31 /* Build configuration list for PBXProject "Remux" */; + compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -866,7 +969,6 @@ 63D5E4632C8CA0BBCE0D90A0 /* XCRemoteSwiftPackageReference "Citadel" */, ); preferredProjectObjectVersion = 77; - productRefGroup = F94500072A3763A6590AF480 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -924,6 +1026,23 @@ E35A4C6758E1D06E01EE5826 /* ConnectionProfileRepository.swift in Sources */, 39A58132CBB849E2CDF37BC2 /* DebugConnectionProfileSeeder.swift in Sources */, 3266F0AE9A1F836618FF115D /* DeterministicTmuxControlTransport.swift in Sources */, + 989F0AA0CB41BED5F0DB2E01 /* FileProviderCitadelSFTPClientProvider.swift in Sources */, + D4B1DF99E07CE5B888F58CA5 /* FileProviderDomainOperationCoordinator.swift in Sources */, + 15636E0B3091E161F61321C1 /* FileProviderDomainReconciler.swift in Sources */, + D88DE037082B0056A977CD83 /* FileProviderEnumeratorCore.swift in Sources */, + 58AFE513F5DB3A622A1DFD2B /* FileProviderErrorMapper.swift in Sources */, + 126385C505991783E10079F1 /* FileProviderItemIdentity.swift in Sources */, + F696F748589435EFB7894157 /* FileProviderItemProjection.swift in Sources */, + 8DA25F6FE7107A8E292167FE /* FileProviderMutationCore.swift in Sources */, + 61A9669449C8D38F4BB549D2 /* FileProviderMutationRequest.swift in Sources */, + BDAFCD98A1EC648F04A2C569 /* FileProviderMutationValidator.swift in Sources */, + B5D5BD309983D0B5A594E79B /* FileProviderPollingLoop.swift in Sources */, + C5F95736CD435CA56D834B5E /* FileProviderRemoteItem.swift in Sources */, + 8F80983038DB0393C3C27087 /* FileProviderRemotePath.swift in Sources */, + 4F979ED3B4BBD23818B7D8C6 /* FileProviderRemoteService.swift in Sources */, + 381EFAD3AB5D5BC133FAF12D /* FileProviderSDKItem.swift in Sources */, + 0F4E56668E1D070DA2549277 /* FileProviderSharedStorageMigrator.swift in Sources */, + 5EA4F47B4AD597835C78A229 /* FileProviderSnapshotStore.swift in Sources */, A7AFC27CD8E0B30AFF1A32BF /* GhosttyAttachmentImageMarkupEditor.swift in Sources */, 4C8C5E176A192D50E70F86FE /* GhosttyAttachmentImagePreviewData.swift in Sources */, 5588618C234196DDFB933E26 /* GhosttyAttachmentInteractiveImagePreview.swift in Sources */, @@ -996,6 +1115,7 @@ B781685DD2926C3C37781ACC /* RemuxSSHExecSession.swift in Sources */, 581869632D0A9887CF899DDB /* RemuxSSHRootService.swift in Sources */, CE34427B2F8F5EB5AF5762AE /* RemuxSessionLiveForward.swift in Sources */, + 14DCE9398FAB6037D54A883E /* RemuxTransportStartupTrace.swift in Sources */, CD12942E9BEC15344DADB7BC /* RootView.swift in Sources */, 19D54D3EA504F5B1763D4DC7 /* SSHAuthResolver.swift in Sources */, 8F8D2F699717C9A7E23CF696 /* SSHAuthenticationMethodFactory.swift in Sources */, @@ -1060,6 +1180,16 @@ 00D89A7D58890CC2E420D6E6 /* ActiveSessionSwitcherProjectionTests.swift in Sources */, 8F75231F2CF7E597F4D0347F /* ConnectionProfileRepositoryTests.swift in Sources */, 434C420D221F1CAF2A633602 /* DebugConnectionProfileSeederTests.swift in Sources */, + 377949211A56AF7C2A97E38E /* FileProviderDomainOperationCoordinatorTests.swift in Sources */, + DE5BB5328EF5F61261C1B842 /* FileProviderDomainReconcilerTests.swift in Sources */, + 808F5D8AF6DE8AC1A8802B5F /* FileProviderErrorMapperTests.swift in Sources */, + 28BED29CE5AE7DFEDF208AA4 /* FileProviderMutationCoreTests.swift in Sources */, + C90D9F9D9A64855E4FDABF63 /* FileProviderMutationValidatorTests.swift in Sources */, + 236BD3D59A937BA6300DD043 /* FileProviderRemoteItemTests.swift in Sources */, + 118AFD1A427E42BA889C1902 /* FileProviderRemoteServiceTests.swift in Sources */, + DB3525FC134F5E53968FBB63 /* FileProviderSharedStorageMigratorTests.swift in Sources */, + 7BA64A5EF27BE1C503CAC3B8 /* FileProviderSharedStorageTests.swift in Sources */, + ECCFAC45E7876FF36745C9FC /* FileProviderSnapshotStoreTests.swift in Sources */, EE488E8B043F3160004A3C20 /* GhosttyAttachmentImageMarkupRendererTests.swift in Sources */, BCFDD04E99F80E73053BE38A /* GhosttyAttachmentImagePreviewDataTests.swift in Sources */, 7C99EDFDCE3B0927D26C1029 /* GhosttyAttachmentPasteboardSnapshotTests.swift in Sources */, @@ -1096,11 +1226,13 @@ 890ADA9AC6BC01F9D619CAD8 /* PanePreviewLayoutTests.swift in Sources */, 1378681F54924A5F33A2EF8C /* RemuxActiveSessionCollectionTests.swift in Sources */, A8ABF8A3A082320C5C11BA70 /* RemuxActiveSessionRuntimeReducerTests.swift in Sources */, + A4C1FADBADE3A0E177D8C130 /* RemuxFileProviderContractTests.swift in Sources */, 338BB72DDA774589D60E8D0D /* RemuxLibrarySSHPrewarmCoordinatorTests.swift in Sources */, 5C3EEA2035D610A4C44C9EC2 /* RemuxLibrarySSHPrewarmPlannerTests.swift in Sources */, 3F60EA4DB041B4296572C2AB /* RemuxPreparedTransportCacheTests.swift in Sources */, 4168590B5BB4933AA075E636 /* RemuxPreparedTransportCoordinatorTests.swift in Sources */, 1E4598594269BC31B1B28C21 /* RemuxRootModelTests.swift in Sources */, + E5127CF95462516FEB047174 /* RemuxSFTPReadOnlyClientTests.swift in Sources */, 0DCAADC05D6444284C9F4CCD /* RemuxSSHExecSessionTests.swift in Sources */, D4D164A1ADCB32103FFDE81E /* RemuxSessionLiveForwardTests.swift in Sources */, 44CA2BB4387DB7CC530AC84C /* RemuxSmokeTests.swift in Sources */, @@ -1355,6 +1487,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = Remux; + CODE_SIGN_ENTITLEMENTS = RemuxApp/Remux.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -1396,6 +1529,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = Remux; + CODE_SIGN_ENTITLEMENTS = RemuxApp/Remux.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", diff --git a/RemuxApp/Info.plist b/RemuxApp/Info.plist index 0fe903b2..006ec384 100644 --- a/RemuxApp/Info.plist +++ b/RemuxApp/Info.plist @@ -26,6 +26,10 @@ Remux uses the microphone so you can dictate messages. NSSpeechRecognitionUsageDescription Remux uses on-device speech recognition to turn your dictation into text. + RemuxApplicationKeychainAccessGroup + $(AppIdentifierPrefix)dev.remux.app + RemuxSharedKeychainAccessGroup + $(AppIdentifierPrefix)dev.remux.shared UIApplicationSupportsIndirectInputEvents UILaunchScreen diff --git a/RemuxApp/Remux.entitlements b/RemuxApp/Remux.entitlements new file mode 100644 index 00000000..3c323755 --- /dev/null +++ b/RemuxApp/Remux.entitlements @@ -0,0 +1,15 @@ + + + + + com.apple.security.application-groups + + group.dev.remux + + keychain-access-groups + + $(AppIdentifierPrefix)dev.remux.app + $(AppIdentifierPrefix)dev.remux.shared + + + diff --git a/RemuxApp/Sources/App/RemuxAppDependencies.swift b/RemuxApp/Sources/App/RemuxAppDependencies.swift index a1860389..8d25e16c 100644 --- a/RemuxApp/Sources/App/RemuxAppDependencies.swift +++ b/RemuxApp/Sources/App/RemuxAppDependencies.swift @@ -146,6 +146,29 @@ struct RemuxAppDependencies: Sendable { } } + static func fileProviderCredentialStores( + infoDictionary: [String: Any] = Bundle.main.infoDictionary ?? [:], + service: String = KeychainSSHCredentialStore.defaultService + ) throws -> ( + application: KeychainSSHCredentialStore, + shared: KeychainSSHCredentialStore + ) { + ( + application: KeychainSSHCredentialStore( + service: service, + accessGroup: try FileProviderSharedConfiguration.applicationKeychainAccessGroup( + infoDictionary: infoDictionary + ) + ), + shared: KeychainSSHCredentialStore( + service: service, + accessGroup: try FileProviderSharedConfiguration.keychainAccessGroup( + infoDictionary: infoDictionary + ) + ) + ) + } + func makeTransport(for target: TmuxConnectionTarget) -> any TmuxControlTransport { transportFactory(target, trustedHostStore, sshRootService) } diff --git a/RemuxApp/Sources/FileProvider/FileProviderCitadelSFTPClientProvider.swift b/RemuxApp/Sources/FileProvider/FileProviderCitadelSFTPClientProvider.swift new file mode 100644 index 00000000..6b5ef6fb --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderCitadelSFTPClientProvider.swift @@ -0,0 +1,61 @@ +import Foundation +import NIOCore + +struct FileProviderCitadelSFTPClientProvider: FileProviderSFTPClientProviding { + private let sshRootService: RemuxSSHRootService + private let trustedHosts: TrustedHostStore + private let connectTimeout: TimeAmount + private let operationTimeout: TimeAmount + + init( + sshRootService: RemuxSSHRootService, + trustedHosts: TrustedHostStore, + connectTimeout: TimeAmount = .seconds(15), + operationTimeout: TimeAmount = .seconds(15) + ) { + self.sshRootService = sshRootService + self.trustedHosts = trustedHosts + self.connectTimeout = connectTimeout + self.operationTimeout = operationTimeout + } + + func withClient( + server: SavedServer, + authentication: ResolvedSSHAuth, + operation: @Sendable (any RemuxSFTPFileProviderClient) async throws -> Value + ) async throws -> Value { + let credential: SSHCredential + switch authentication.credential { + case .password(let password): + credential = .password(password) + case .privateKey(let privateKey): + credential = .privateKey(privateKey) + } + + let provider = RemuxCitadelSFTPClientProvider( + sshRootService: sshRootService, + rootKey: RemuxSSHRootKey(server: server, auth: authentication), + rootConfiguration: RemuxSSHRootConfiguration( + host: server.host, + port: server.port, + authenticationMethod: { + try SSHAuthenticationMethodFactory.make( + username: authentication.username, + credential: credential + ) + }, + hostKeyValidator: trustedHosts.validator(for: server), + connectTimeout: connectTimeout + ), + operationTimeout: operationTimeout + ) + + return try await provider.withClient { client in + try await operation(client) + } + } + + func closeIdleConnections(forServerID serverID: SavedServer.ID) async { + await sshRootService.closeIdleConnections(forServerID: serverID) + } +} diff --git a/RemuxApp/Sources/FileProvider/FileProviderDomainOperationCoordinator.swift b/RemuxApp/Sources/FileProvider/FileProviderDomainOperationCoordinator.swift new file mode 100644 index 00000000..8253363c --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderDomainOperationCoordinator.swift @@ -0,0 +1,83 @@ +import Foundation + +actor FileProviderDomainOperationCoordinator { + private final class Request: Hashable, Sendable { + static func == (lhs: Request, rhs: Request) -> Bool { + lhs === rhs + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } + } + + private struct Waiter { + let request: Request + let continuation: CheckedContinuation + } + + private var activeRequest: Request? + private var waiters: [Waiter] = [] + private var registeredRequests: Set = [] + private var cancelledBeforeEnqueue: Set = [] + + func perform( + _ operation: @escaping @Sendable () async throws -> Value + ) async throws -> Value { + let request = Request() + registeredRequests.insert(request) + return try await withTaskCancellationHandler { + defer { complete(request) } + try await acquire(request) + try Task.checkCancellation() + return try await operation() + } onCancel: { + Task { await self.cancel(request) } + } + } + + private func acquire(_ request: Request) async throws { + guard registeredRequests.remove(request) != nil else { + throw CancellationError() + } + guard cancelledBeforeEnqueue.remove(request) == nil else { + throw CancellationError() + } + guard activeRequest != nil else { + activeRequest = request + return + } + try await withCheckedThrowingContinuation { continuation in + waiters.append(Waiter(request: request, continuation: continuation)) + } + } + + private func cancel(_ request: Request) { + guard activeRequest !== request else { return } + guard let index = waiters.firstIndex(where: { $0.request === request }) else { + guard registeredRequests.contains(request) else { return } + cancelledBeforeEnqueue.insert(request) + return + } + waiters.remove(at: index).continuation.resume( + throwing: CancellationError() + ) + } + + private func complete(_ request: Request) { + registeredRequests.remove(request) + cancelledBeforeEnqueue.remove(request) + release(request) + } + + private func release(_ request: Request) { + guard activeRequest === request else { return } + guard !waiters.isEmpty else { + activeRequest = nil + return + } + let next = waiters.removeFirst() + activeRequest = next.request + next.continuation.resume() + } +} diff --git a/RemuxApp/Sources/FileProvider/FileProviderDomainReconciler.swift b/RemuxApp/Sources/FileProvider/FileProviderDomainReconciler.swift new file mode 100644 index 00000000..cbe0e69c --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderDomainReconciler.swift @@ -0,0 +1,148 @@ +import FileProvider +import Foundation + +struct FileProviderDomainRecord: Equatable, Sendable { + let serverID: SavedServer.ID + let displayName: String + + fileprivate var rawIdentifier: String { + serverID.uuidString.lowercased() + } +} + +protocol FileProviderDomainRegistering: Sendable { + func records() async throws -> [FileProviderDomainRecord] + func add(_ record: FileProviderDomainRecord) async throws + func remove(serverID: SavedServer.ID) async throws +} + +protocol FileProviderDomainReconciling: Sendable { + func reconcile() async throws +} + +actor FileProviderDomainReconciler: FileProviderDomainReconciling { + private let profiles: any ConnectionProfileRepository + private let credentials: any SSHCredentialStore + private let trust: TrustedHostStore + private let registry: any FileProviderDomainRegistering + private var reconciliationTask: Task? + + init( + profiles: any ConnectionProfileRepository, + credentials: any SSHCredentialStore, + trust: TrustedHostStore, + registry: any FileProviderDomainRegistering + ) { + self.profiles = profiles + self.credentials = credentials + self.trust = trust + self.registry = registry + } + + func reconcile() async throws { + if let reconciliationTask { + try await reconciliationTask.value + return + } + + let task = Task { + try await self.reconcileDomains() + } + reconciliationTask = task + + do { + try await task.value + reconciliationTask = nil + } catch { + reconciliationTask = nil + throw error + } + } + + private func reconcileDomains() async throws { + let snapshot = try await profiles.loadSnapshot() + let trustedIdentities = try trust.loadIdentities() + var desiredRecords: [FileProviderDomainRecord] = [] + + for server in snapshot.servers where trustedIdentities.contains(where: { + $0.serverID == server.id && $0.host == server.host + }) { + guard try await credentials.loadCredential(identityID: server.identityID) != nil else { + continue + } + + desiredRecords.append( + FileProviderDomainRecord(serverID: server.id, displayName: server.displayName) + ) + } + + let existingRecords = try await registry.records() + + for record in existingRecords + where desiredRecords.first(where: { $0.serverID == record.serverID }) != record { + try await registry.remove(serverID: record.serverID) + } + + for record in desiredRecords + where existingRecords.first(where: { $0.serverID == record.serverID }) != record { + try await registry.add(record) + } + } +} + +final class NSFileProviderDomainRegistry: FileProviderDomainRegistering, @unchecked Sendable { + func records() async throws -> [FileProviderDomainRecord] { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[FileProviderDomainRecord], Error>) in + NSFileProviderManager.getDomainsWithCompletionHandler { domains, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: domains.compactMap { domain in + guard let serverID = UUID(uuidString: domain.identifier.rawValue) else { + return nil + } + + return FileProviderDomainRecord( + serverID: serverID, + displayName: domain.displayName + ) + }) + } + } + } + } + + func add(_ record: FileProviderDomainRecord) async throws { + let domain = NSFileProviderDomain( + identifier: NSFileProviderDomainIdentifier(rawValue: record.rawIdentifier), + displayName: record.displayName + ) + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + NSFileProviderManager.add(domain) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } + } + } + + func remove(serverID: SavedServer.ID) async throws { + let domain = NSFileProviderDomain( + identifier: NSFileProviderDomainIdentifier(rawValue: serverID.uuidString.lowercased()), + displayName: "" + ) + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + NSFileProviderManager.remove(domain) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } + } + } +} diff --git a/RemuxApp/Sources/FileProvider/FileProviderEnumeratorCore.swift b/RemuxApp/Sources/FileProvider/FileProviderEnumeratorCore.swift new file mode 100644 index 00000000..b27d6c5e --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderEnumeratorCore.swift @@ -0,0 +1,132 @@ +import FileProvider +import Foundation + +struct FileProviderEnumerationPage: Sendable { + let items: [FileProviderIdentifiedItem] + let nextPage: NSFileProviderPage? +} + +struct FileProviderEnumerationChanges: Sendable { + let updated: [FileProviderIdentifiedItem] + let deleted: [NSFileProviderItemIdentifier] + let moreComing: Bool + let anchor: NSFileProviderSyncAnchor +} + +protocol FileProviderEnumeratorSignaling: Sendable { + func signalEnumerator( + for identifier: NSFileProviderItemIdentifier + ) async throws +} + +enum FileProviderEnumeratorScope: Equatable, Sendable { + case directory(FileProviderRemotePath) + case workingSet +} + +actor FileProviderEnumeratorCore { + private let scope: FileProviderEnumeratorScope + private let service: any FileProviderRemoteServicing + private let snapshots: FileProviderSnapshotStore + private let coordinator: FileProviderDomainOperationCoordinator + private let signaler: any FileProviderEnumeratorSignaling + + init( + scope: FileProviderEnumeratorScope, + service: any FileProviderRemoteServicing, + snapshots: FileProviderSnapshotStore, + coordinator: FileProviderDomainOperationCoordinator, + signaler: any FileProviderEnumeratorSignaling + ) { + self.scope = scope + self.service = service + self.snapshots = snapshots + self.coordinator = coordinator + self.signaler = signaler + } + + func enumerateItems() async throws -> FileProviderEnumerationPage { + switch scope { + case .directory(let directory): + let items = try await refresh(directory: directory) + try Task.checkCancellation() + return FileProviderEnumerationPage( + items: items, + nextPage: nil + ) + case .workingSet: + let snapshot = try await snapshots.workingSetSnapshot() + try Task.checkCancellation() + return FileProviderEnumerationPage( + items: snapshot.items, + nextPage: nil + ) + } + } + + func currentSyncAnchor() async throws -> NSFileProviderSyncAnchor? { + switch scope { + case .directory: + try await snapshots.currentAnchor() + case .workingSet: + try await snapshots.workingSetSnapshot().anchor + } + } + + func enumerateChanges( + from anchor: NSFileProviderSyncAnchor + ) async throws -> FileProviderEnumerationChanges { + let changes: ( + anchor: NSFileProviderSyncAnchor, + delta: FileProviderSnapshotDelta + ) + switch scope { + case .directory(let directory): + changes = try await snapshots.delta( + directory: directory, + from: anchor + ) + case .workingSet: + changes = try await snapshots.workingSetDelta(from: anchor) + } + return FileProviderEnumerationChanges( + updated: changes.delta.updated, + deleted: changes.delta.deleted, + moreComing: false, + anchor: changes.anchor + ) + } + + func refreshAndSignalChanges() async throws { + if case .directory(let directory) = scope { + _ = try await refresh(directory: directory) + } + try Task.checkCancellation() + guard let pendingGeneration = try await snapshots + .pendingWorkingSetSignalGeneration() + else { + return + } + + try await signaler.signalEnumerator(for: .workingSet) + try Task.checkCancellation() + try await snapshots.acknowledgeWorkingSetSignal( + generation: pendingGeneration + ) + } + + private func refresh( + directory: FileProviderRemotePath + ) async throws -> [FileProviderIdentifiedItem] { + try await coordinator.perform { + let remoteItems = try await self.service.list(directory: directory) + try Task.checkCancellation() + let identifiedItems = try await self.snapshots.record( + directory: directory, + items: remoteItems + ) + try Task.checkCancellation() + return identifiedItems + } + } +} diff --git a/RemuxApp/Sources/FileProvider/FileProviderErrorMapper.swift b/RemuxApp/Sources/FileProvider/FileProviderErrorMapper.swift new file mode 100644 index 00000000..4f32e3ce --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderErrorMapper.swift @@ -0,0 +1,155 @@ +@preconcurrency import Citadel +import FileProvider +import Foundation +import NIOCore +import NIOPosix + +enum FileProviderErrorMapper { + static func map(_ error: Error) -> NSError { + if case .noSuchFile = error as? RemuxSFTPClientError { + return sanitizedError() + } + return map(error, itemIdentifier: nil) + } + + static func map( + _ error: Error, + itemIdentifier: NSFileProviderItemIdentifier + ) -> NSError { + map(error, itemIdentifier: Optional(itemIdentifier)) + } + + private static func map( + _ error: Error, + itemIdentifier: NSFileProviderItemIdentifier? + ) -> NSError { + if error is CancellationError { + return NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError) + } + + if error is SSHAuthResolverError || error is TrustedHostStoreError { + return fileProviderError(.notAuthenticated) + } + + if let sshClientError = error as? SSHClientError { + switch sshClientError { + case .allAuthenticationOptionsFailed, + .unsupportedPasswordAuthentication, + .unsupportedPrivateKeyAuthentication: + return fileProviderError(.notAuthenticated) + case .unsupportedHostBasedAuthentication, .channelCreationFailed: + break + } + } + + if let snapshotError = error as? FileProviderSnapshotStoreError { + if case .syncAnchorExpired = snapshotError { + return fileProviderError(.syncAnchorExpired) + } + if case .itemIdentityNotFound = snapshotError, + let itemIdentifier + { + return NSError( + domain: NSFileProviderErrorDomain, + code: NSFileProviderError.noSuchItem.rawValue, + userInfo: [ + NSFileProviderErrorNonExistentItemIdentifierKey: itemIdentifier, + ] + ) + } + } + + if let sftpError = error as? RemuxSFTPClientError { + switch sftpError { + case .noSuchFile: + var userInfo: [String: Any] = [:] + if let itemIdentifier { + userInfo[NSFileProviderErrorItemKey] = itemIdentifier + } + return NSError( + domain: NSFileProviderErrorDomain, + code: NSFileProviderError.noSuchItem.rawValue, + userInfo: userInfo + ) + case .operationTimedOut, .sessionUnavailable: + return fileProviderError(.serverUnreachable) + case .permissionDenied: + return writePermission + case .invalidReadLength, .oversizedReadResult, .unsupportedMutation: + break + } + } + + if error is NIOConnectionError || isNetworkChannelError(error) || isNetworkURL(error) { + return fileProviderError(.serverUnreachable) + } + + return sanitizedError() + } + + static var writePermission: NSError { + NSError(domain: NSCocoaErrorDomain, code: NSFileWriteNoPermissionError) + } + + static func filenameCollision(existingItem: FileProviderSDKItem) -> NSError { + NSError.fileProviderErrorForCollision(with: existingItem) + } + + static func deletionRejected(updatedItem: FileProviderSDKItem) -> NSError { + NSError.fileProviderErrorForRejectedDeletion(of: updatedItem) + } + + static var directoryNotEmpty: NSError { + fileProviderError(.directoryNotEmpty) + } + + static var cannotSynchronize: NSError { + fileProviderError(.cannotSynchronize) + } + + static var localVersionConflictingWithServer: NSError { + NSError(domain: NSFileProviderErrorDomain, code: -2015) + } + + private static func fileProviderError(_ code: NSFileProviderError.Code) -> NSError { + NSError(domain: NSFileProviderErrorDomain, code: code.rawValue) + } + + private static func sanitizedError() -> NSError { + NSError(domain: NSCocoaErrorDomain, code: NSXPCConnectionReplyInvalid) + } + + private static func isNetworkChannelError(_ error: Error) -> Bool { + guard let error = error as? ChannelError else { return false } + + switch error { + case .connectTimeout, + .eof, + .ioOnClosedChannel, + .alreadyClosed, + .inputClosed, + .outputClosed: + return true + default: + return false + } + } + + private static func isNetworkURL(_ error: Error) -> Bool { + guard let error = error as? URLError else { return false } + + return switch error.code { + case .badServerResponse, + .cannotConnectToHost, + .cannotFindHost, + .dnsLookupFailed, + .internationalRoamingOff, + .networkConnectionLost, + .notConnectedToInternet, + .timedOut: + true + default: + false + } + } +} diff --git a/RemuxApp/Sources/FileProvider/FileProviderItemIdentity.swift b/RemuxApp/Sources/FileProvider/FileProviderItemIdentity.swift new file mode 100644 index 00000000..70c95c47 --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderItemIdentity.swift @@ -0,0 +1,51 @@ +import FileProvider +import Foundation + +enum FileProviderItemIdentity: Hashable, Codable, Sendable { + case root + case item(UUID) + + var itemIdentifier: NSFileProviderItemIdentifier { + FileProviderItemIdentifierCodec().identifier(for: self) + } +} + +struct FileProviderIdentifiedItem: Equatable, Codable, Sendable { + let identity: FileProviderItemIdentity + let parentIdentity: FileProviderItemIdentity + let remoteItem: FileProviderRemoteItem + + var itemIdentifier: NSFileProviderItemIdentifier { + identity.itemIdentifier + } +} + +struct FileProviderCreateAlias: Sendable { + let templateIdentifier: String + let identity: FileProviderItemIdentity +} + +struct FileProviderSnapshotLocalMutation: Sendable { + struct DirectoryRefresh: Sendable { + let directory: FileProviderRemotePath + let items: [FileProviderRemoteItem] + } + + struct IdentityRelocation: Sendable { + let identity: FileProviderItemIdentity + let from: FileProviderRemotePath + let to: FileProviderRemotePath + } + + struct IdentityReservation: Sendable { + let identity: FileProviderItemIdentity + let path: FileProviderRemotePath + } + + let refreshedDirectories: [DirectoryRefresh] + var identityReservations: [IdentityReservation] = [] + var relocations: [IdentityRelocation] = [] + var deletedIdentities: Set = [] + var createAlias: FileProviderCreateAlias? + var queuesWorkingSetSignal = false +} diff --git a/RemuxApp/Sources/FileProvider/FileProviderItemProjection.swift b/RemuxApp/Sources/FileProvider/FileProviderItemProjection.swift new file mode 100644 index 00000000..dd230ba3 --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderItemProjection.swift @@ -0,0 +1,73 @@ +import FileProvider +import Foundation +import UniformTypeIdentifiers + +struct FileProviderItemProjection: @unchecked Sendable { + let itemIdentifier: NSFileProviderItemIdentifier + let parentItemIdentifier: NSFileProviderItemIdentifier + let filename: String + let contentType: UTType + let documentSize: NSNumber? + let contentModificationDate: Date? + let capabilities: NSFileProviderItemCapabilities + let itemVersion: NSFileProviderItemVersion + let symlinkTargetPath: String? + + init(item: FileProviderIdentifiedItem, rootDisplayName: String) { + let remoteItem = item.remoteItem + self.itemIdentifier = item.itemIdentifier + self.parentItemIdentifier = item.parentIdentity.itemIdentifier + self.filename = remoteItem.path == .root ? rootDisplayName : remoteItem.name + self.contentType = Self.contentType(for: remoteItem) + self.documentSize = remoteItem.size.map(NSNumber.init(value:)) + self.contentModificationDate = remoteItem.modificationDate + self.capabilities = Self.capabilities(for: remoteItem) + self.itemVersion = NSFileProviderItemVersion( + contentVersion: remoteItem.contentVersion, + metadataVersion: remoteItem.metadataVersion + ) + self.symlinkTargetPath = remoteItem.symlinkTargetRelativePath + } + + private static func contentType(for item: FileProviderRemoteItem) -> UTType { + switch item.type { + case .directory: + .folder + case .regular: + fileType(for: item.name) + case .symbolicLink: + .symbolicLink + case .other: + .data + } + } + + private static func capabilities( + for item: FileProviderRemoteItem + ) -> NSFileProviderItemCapabilities { + switch item.type { + case .directory where item.path == .root: + [.allowsReading, .allowsWriting, .allowsContentEnumerating, .allowsAddingSubItems] + case .directory: + [ + .allowsReading, .allowsWriting, .allowsContentEnumerating, + .allowsAddingSubItems, .allowsRenaming, .allowsReparenting, + .allowsDeleting, + ] + case .regular: + [.allowsReading, .allowsWriting, .allowsRenaming, .allowsReparenting, .allowsDeleting] + case .symbolicLink, .other: + [.allowsReading] + } + } + + private static func fileType(for filename: String) -> UTType { + let pathExtension = (filename as NSString).pathExtension + guard !pathExtension.isEmpty, + let type = UTType(filenameExtension: pathExtension) + else { + return .data + } + return type + } +} diff --git a/RemuxApp/Sources/FileProvider/FileProviderMutationCore.swift b/RemuxApp/Sources/FileProvider/FileProviderMutationCore.swift new file mode 100644 index 00000000..f83882b7 --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderMutationCore.swift @@ -0,0 +1,185 @@ +import FileProvider +import Foundation + +struct FileProviderMutationResult: Sendable { + let item: FileProviderIdentifiedItem + let stillPendingFields: NSFileProviderItemFields + let shouldFetchContent: Bool +} + +enum FileProviderCreateMutationError: Error, Sendable { + case collision(existing: FileProviderIdentifiedItem) +} + +actor FileProviderMutationCore { + private let remote: any FileProviderRemoteServicing + private let snapshots: FileProviderSnapshotStore + private let coordinator: FileProviderDomainOperationCoordinator + private let validator: FileProviderMutationValidator + private let nonce: @Sendable () -> UUID + private let identity: @Sendable () -> UUID + + init( + remote: any FileProviderRemoteServicing, + snapshots: FileProviderSnapshotStore, + coordinator: FileProviderDomainOperationCoordinator, + validator: FileProviderMutationValidator = FileProviderMutationValidator(), + nonce: @escaping @Sendable () -> UUID = UUID.init, + identity: @escaping @Sendable () -> UUID = UUID.init + ) { + self.remote = remote + self.snapshots = snapshots + self.coordinator = coordinator + self.validator = validator + self.nonce = nonce + self.identity = identity + } + + func create( + request: FileProviderCreateRequest, + progress: @escaping @Sendable (Int64) async -> Void + ) async throws -> FileProviderMutationResult { + let key = FileProviderMutationReplayKey.create( + templateIdentifier: request.templateIdentifier.rawValue + ) + if let receipt = try await snapshots.receipt(for: key) { + return try Self.replayedResult(from: receipt) + } + + return try await coordinator.performMutation { [remote, snapshots, validator, nonce, identity] in + if let receipt = try await snapshots.receipt(for: key) { + return try Self.replayedResult(from: receipt) + } + try validator.validateMutation(of: request.type) + try validator.validateChildName(request.filename) + try validator.validateContents( + supplied: request.contentsURL != nil, + for: request.type + ) + + let parentPath = try await snapshots.path(for: request.parentIdentifier) + let parentIdentity = try FileProviderItemIdentifierCodec().identity( + for: request.parentIdentifier + ) + let destination = try FileProviderRemotePath( + relative: parentPath.relative.isEmpty + ? request.filename + : parentPath.relative + "/" + request.filename + ) + let temporary = try FileProviderRemotePath( + relative: parentPath.relative.isEmpty + ? ".remux-upload-\(nonce().uuidString.lowercased())" + : parentPath.relative + "/.remux-upload-\(nonce().uuidString.lowercased())" + ) + let reservedIdentity = FileProviderItemIdentity.item(identity()) + + return try await remote.withMutationAccess { access in + let parent = try await access.item(at: parentPath) + try validator.validateParent(exists: parent.type == .directory) + let existing = try await access.list(directory: parentPath) + do { + try validator.validateDestination( + destination, + occupiedPaths: existing.map(\.path) + ) + } catch FileProviderMutationValidationError.destinationOccupied { + guard let remoteItem = existing.first(where: { $0.path == destination }) else { + throw FileProviderMutationValidationError.destinationOccupied + } + let snapshotsItems = try await snapshots.items(directory: parentPath) + let existingIdentity = snapshotsItems + .first { $0.remoteItem.path == destination }?.identity ?? .item(identity()) + throw FileProviderCreateMutationError.collision( + existing: FileProviderIdentifiedItem( + identity: existingIdentity, + parentIdentity: parentIdentity, + remoteItem: remoteItem + ) + ) + } + + var renamed = false + do { + switch request.type { + case .directory: + try Task.checkCancellation() + try await access.createDirectory(at: destination) + case .regular: + let localURL = try request.contentsURL ?? emptyFileURL() + try await access.uploadFile( + from: localURL, + to: temporary, + progress: progress + ) + try Task.checkCancellation() + try await access.renameItem(from: temporary, to: destination) + renamed = true + case .symbolicLink, .other: + throw FileProviderMutationValidationError.unsupportedFileType + } + } catch { + if request.type == .regular, !renamed { + try? await access.removeFile(at: temporary) + } + throw error + } + + return try await finishCommittedMutation { + let item = try await access.item(at: destination) + let parentItems = try await access.list(directory: parentPath) + let identified = FileProviderIdentifiedItem( + identity: reservedIdentity, + parentIdentity: parentIdentity, + remoteItem: item + ) + _ = try await snapshots.commit( + localMutation: FileProviderSnapshotLocalMutation( + refreshedDirectories: [ + .init(directory: parentPath, items: parentItems), + ], + identityReservations: [ + .init(identity: reservedIdentity, path: destination), + ], + receipt: .item(key: key, item: identified), + queuesWorkingSetSignal: true + ) + ) + return FileProviderMutationResult( + item: identified, + stillPendingFields: request.fields.subtracting([ + .contents, + .filename, + .parentItemIdentifier, + ]), + shouldFetchContent: false + ) + } + } + } + } + + private static func replayedResult( + from receipt: FileProviderMutationReceipt + ) throws -> FileProviderMutationResult { + guard case .item(_, let item) = receipt else { + throw FileProviderSnapshotStoreError.itemIdentityNotFound + } + return FileProviderMutationResult( + item: item, + stillPendingFields: [], + shouldFetchContent: false + ) + } +} + +private func finishCommittedMutation( + _ operation: @escaping @Sendable () async throws -> Value +) async throws -> Value { + try await Task.detached(operation: operation).value +} + +private func emptyFileURL() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data().write(to: url) + return url +} diff --git a/RemuxApp/Sources/FileProvider/FileProviderMutationRequest.swift b/RemuxApp/Sources/FileProvider/FileProviderMutationRequest.swift new file mode 100644 index 00000000..45c870f8 --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderMutationRequest.swift @@ -0,0 +1,27 @@ +import FileProvider +import Foundation + +struct FileProviderCreateRequest: @unchecked Sendable { + let templateIdentifier: NSFileProviderItemIdentifier + let parentIdentifier: NSFileProviderItemIdentifier + let filename: String + let type: RemuxSFTPFileType + let fields: NSFileProviderItemFields + let contentsURL: URL? + let options: NSFileProviderCreateItemOptions +} + +struct FileProviderModifyRequest: @unchecked Sendable { + let identifier: NSFileProviderItemIdentifier + let parentIdentifier: NSFileProviderItemIdentifier + let filename: String + let baseVersion: NSFileProviderItemVersion + let changedFields: NSFileProviderItemFields + let contentsURL: URL? + let options: NSFileProviderModifyItemOptions +} + +struct FileProviderDeleteRequest: @unchecked Sendable { + let identifier: NSFileProviderItemIdentifier + let baseVersion: NSFileProviderItemVersion +} diff --git a/RemuxApp/Sources/FileProvider/FileProviderMutationValidator.swift b/RemuxApp/Sources/FileProvider/FileProviderMutationValidator.swift new file mode 100644 index 00000000..6c9531bd --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderMutationValidator.swift @@ -0,0 +1,110 @@ +import FileProvider +import Foundation + +enum FileProviderBaseVersionValidation: Equatable, Sendable { + case matches + case conflict(current: FileProviderIdentifiedItem) +} + +enum FileProviderMutationValidationError: Error, Equatable, Sendable { + case unsupportedFileType + case symbolicLinkMutation + case invalidChildName + case parentNotFound + case rootMutation + case directoryContents + case directoryCycle + case destinationOccupied +} + +struct FileProviderMutationValidator: Sendable { + func validateBaseVersion( + requested: NSFileProviderItemVersion, + current: FileProviderIdentifiedItem + ) -> FileProviderBaseVersionValidation { + let currentVersion = NSFileProviderItemVersion( + contentVersion: current.remoteItem.contentVersion, + metadataVersion: current.remoteItem.metadataVersion + ) + return requested == currentVersion ? .matches : .conflict(current: current) + } + + func validateMutation(of type: RemuxSFTPFileType) throws { + switch type { + case .directory, .regular: + return + case .symbolicLink: + throw FileProviderMutationValidationError.symbolicLinkMutation + case .other: + throw FileProviderMutationValidationError.unsupportedFileType + } + } + + func validateDestination( + _ destination: FileProviderRemotePath, + occupiedPaths: some Sequence + ) throws { + for occupiedPath in occupiedPaths where pathsMatchIgnoringCase(destination, occupiedPath) { + throw FileProviderMutationValidationError.destinationOccupied + } + } + + func validateChildName(_ name: String) throws { + guard !name.isEmpty, + name != ".", + name != "..", + !name.contains("/"), + !name.contains("\0") + else { + throw FileProviderMutationValidationError.invalidChildName + } + } + + func validateParent(exists: Bool) throws { + guard exists else { + throw FileProviderMutationValidationError.parentNotFound + } + } + + func validateMutablePath(_ path: FileProviderRemotePath) throws { + guard path != .root else { + throw FileProviderMutationValidationError.rootMutation + } + } + + func validateContents(supplied: Bool, for type: RemuxSFTPFileType) throws { + guard !(supplied && type == .directory) else { + throw FileProviderMutationValidationError.directoryContents + } + } + + func validateMove( + source: FileProviderRemotePath, + destination: FileProviderRemotePath, + sourceType: RemuxSFTPFileType + ) throws { + try validateMutation(of: sourceType) + guard sourceType != .directory || !isDescendantOrSame(destination, of: source) else { + throw FileProviderMutationValidationError.directoryCycle + } + } + + private func pathsMatchIgnoringCase( + _ lhs: FileProviderRemotePath, + _ rhs: FileProviderRemotePath + ) -> Bool { + lhs.relative.compare( + rhs.relative, + options: .caseInsensitive, + range: nil, + locale: Locale(identifier: "en_US_POSIX") + ) == .orderedSame + } + + private func isDescendantOrSame( + _ candidate: FileProviderRemotePath, + of ancestor: FileProviderRemotePath + ) -> Bool { + candidate == ancestor || candidate.relative.hasPrefix(ancestor.relative + "/") + } +} diff --git a/RemuxApp/Sources/FileProvider/FileProviderPollingLoop.swift b/RemuxApp/Sources/FileProvider/FileProviderPollingLoop.swift new file mode 100644 index 00000000..7c9f49d1 --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderPollingLoop.swift @@ -0,0 +1,74 @@ +import Foundation + +protocol FileProviderPollingClock: Sendable { + func sleep(for duration: Duration) async throws +} + +struct ContinuousFileProviderPollingClock: FileProviderPollingClock { + func sleep(for duration: Duration) async throws { + try await Task.sleep(for: duration) + } +} + +final class FileProviderPollingLoop: @unchecked Sendable { + private static let interval: Duration = .seconds(5) + + private let clock: any FileProviderPollingClock + private let refresh: @Sendable () async throws -> Void + private let lock = NSLock() + private var task: Task? + private var isInvalidated = false + + init( + clock: any FileProviderPollingClock = ContinuousFileProviderPollingClock(), + refresh: @escaping @Sendable () async throws -> Void + ) { + self.clock = clock + self.refresh = refresh + } + + func start() { + let clock = clock + let refresh = refresh + lock.withLock { + guard task == nil, !isInvalidated else { return } + task = Task { + while !Task.isCancelled { + do { + try await clock.sleep(for: Self.interval) + } catch is CancellationError { + return + } catch { + continue + } + + do { + try await refresh() + } catch is CancellationError { + return + } catch { + } + } + } + } + } + + func invalidate() { + let task = lock.withLock { + isInvalidated = true + return self.task + } + task?.cancel() + } + + func waitUntilInvalidated() async { + let task = lock.withLock { + self.task + } + await task?.value + } + + deinit { + task?.cancel() + } +} diff --git a/RemuxApp/Sources/FileProvider/FileProviderRemoteItem.swift b/RemuxApp/Sources/FileProvider/FileProviderRemoteItem.swift new file mode 100644 index 00000000..859c016d --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderRemoteItem.swift @@ -0,0 +1,174 @@ +import CryptoKit +import Foundation + +struct FileProviderRemoteItem: Equatable, Codable, Sendable { + let path: FileProviderRemotePath + let parent: FileProviderRemotePath + let name: String + let type: RemuxSFTPFileType + let size: UInt64? + let permissions: UInt32? + let modificationDate: Date? + let symlinkTargetRelativePath: String? + + var contentVersion: Data { + var data = Data() + data.appendString(type.rawValue) + data.appendOptional(size) + data.appendOptional(modificationDate.map { Int64($0.timeIntervalSince1970) }) + return data + } + + var metadataVersion: Data { + var data = contentVersion + data.appendString(path.relative) + data.appendString(name) + data.appendOptional(permissions) + return Data(SHA256.hash(data: data)) + } + + init( + path: FileProviderRemotePath, + metadata: RemuxSFTPFileMetadata, + symlinkTargetRelativePath: String? = nil + ) throws { + let parent = try Self.parent(of: path) + self.path = path + self.parent = parent + self.name = path.relative.split(separator: "/").last.map(String.init) ?? "" + self.type = metadata.type + self.size = metadata.size + self.permissions = metadata.permissions + self.modificationDate = metadata.modificationDate + self.symlinkTargetRelativePath = try symlinkTargetRelativePath.map { + try Self.validatedSymlinkTarget($0, relativeTo: parent) + } + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let path = try container.decode(FileProviderRemotePath.self, forKey: .path) + let metadata = RemuxSFTPFileMetadata( + size: try container.decodeIfPresent(UInt64.self, forKey: .size), + permissions: try container.decodeIfPresent(UInt32.self, forKey: .permissions), + modificationDate: try container.decodeIfPresent(Date.self, forKey: .modificationDate), + type: try container.decode(RemuxSFTPFileType.self, forKey: .type) + ) + self = try Self( + path: path, + metadata: metadata, + symlinkTargetRelativePath: try container.decodeIfPresent(String.self, forKey: .symlinkTargetRelativePath) + ) + } + + private static func parent(of path: FileProviderRemotePath) throws -> FileProviderRemotePath { + guard let separator = path.relative.lastIndex(of: "/") else { + return .root + } + return try FileProviderRemotePath(relative: String(path.relative[.. String { + guard !target.isEmpty, !target.hasPrefix("/"), !target.contains("\0") else { + throw FileProviderRemotePathError.invalidRelativePath + } + guard target != "." else { return target } + + let components = target.split(separator: "/", omittingEmptySubsequences: false) + guard components.allSatisfy({ !$0.isEmpty && $0 != "." }) else { + throw FileProviderRemotePathError.invalidRelativePath + } + + var remainingParentComponents = parent.relative.isEmpty + ? 0 + : parent.relative.split(separator: "/").count + var encounteredDestinationComponent = false + for component in components { + if component == ".." { + guard !encounteredDestinationComponent, remainingParentComponents > 0 else { + throw FileProviderRemotePathError.invalidRelativePath + } + remainingParentComponents -= 1 + } else { + encounteredDestinationComponent = true + } + } + return target + } +} + +struct FileProviderSafeLinkResolver: Sendable { + func resolve( + _ canonicalTarget: String, + home canonicalHome: String, + for symlinkPath: FileProviderRemotePath + ) throws -> String { + let targetComponents = try containedTargetComponents( + canonicalTarget, + home: canonicalHome + ) + var parentComponents = symlinkPath.relative.split(separator: "/").map(String.init) + if !parentComponents.isEmpty { + parentComponents.removeLast() + } + + let sharedComponentCount = zip(parentComponents, targetComponents) + .prefix { pair in pair.0 == pair.1 } + .count + let upwardComponents = Array( + repeating: "..", + count: parentComponents.count - sharedComponentCount + ) + let downwardComponents = Array(targetComponents.dropFirst(sharedComponentCount)) + let projectedComponents = upwardComponents + downwardComponents + return projectedComponents.isEmpty ? "." : projectedComponents.joined(separator: "/") + } + + func ensureContained(_ canonicalTarget: String, home canonicalHome: String) throws { + _ = try containedTargetComponents(canonicalTarget, home: canonicalHome) + } + + private func containedTargetComponents( + _ canonicalTarget: String, + home canonicalHome: String + ) throws -> [String] { + try FileProviderPathValidation.validateCanonicalAbsolute(canonicalTarget) + try FileProviderPathValidation.validateCanonicalAbsolute(canonicalHome) + + if canonicalHome == "/" { + return canonicalTarget.split(separator: "/").map(String.init) + } + + guard canonicalTarget == canonicalHome || canonicalTarget.hasPrefix(canonicalHome + "/") else { + throw FileProviderRemotePathError.unsafeLinkTarget + } + guard canonicalTarget != canonicalHome else { return [] } + return canonicalTarget.dropFirst(canonicalHome.count + 1) + .split(separator: "/") + .map(String.init) + } +} + +private extension Data { + mutating func appendString(_ value: String) { + appendInteger(UInt64(value.utf8.count)) + append(contentsOf: value.utf8) + } + + mutating func appendOptional(_ value: T?) { + append(value == nil ? 0 : 1) + if let value { + appendInteger(value) + } + } + + mutating func appendInteger(_ value: T) { + var bigEndian = value.bigEndian + Swift.withUnsafeBytes(of: &bigEndian) { bytes in + append(contentsOf: bytes) + } + } +} diff --git a/RemuxApp/Sources/FileProvider/FileProviderRemotePath.swift b/RemuxApp/Sources/FileProvider/FileProviderRemotePath.swift new file mode 100644 index 00000000..4f097dc3 --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderRemotePath.swift @@ -0,0 +1,105 @@ +import FileProvider +import Foundation + +enum FileProviderRemotePathError: Error, Equatable { + case invalidRelativePath + case invalidCanonicalPath + case invalidItemIdentifier + case unsafeLinkTarget +} + +struct FileProviderRemotePath: Hashable, Codable, Sendable { + static let root = FileProviderRemotePath(validatedRelative: "") + + let relative: String + + init(relative: String) throws { + guard !relative.contains("\0"), !relative.hasPrefix("/") else { + throw FileProviderRemotePathError.invalidRelativePath + } + + var components: [Substring] = [] + for component in relative.split(separator: "/", omittingEmptySubsequences: false) { + switch component { + case "", ".": + continue + case "..": + throw FileProviderRemotePathError.invalidRelativePath + default: + components.append(component) + } + } + + self.init(validatedRelative: components.joined(separator: "/")) + } + + func remotePath(beneath canonicalHome: String) throws -> String { + try FileProviderPathValidation.validateCanonicalAbsolute(canonicalHome) + + guard !relative.isEmpty else { return canonicalHome } + guard canonicalHome != "/" else { return "/\(relative)" } + return "\(canonicalHome)/\(relative)" + } + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + try self.init(relative: container.decode(String.self)) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(relative) + } + + private init(validatedRelative: String) { + self.relative = validatedRelative + } +} + +struct FileProviderItemIdentifierCodec: Sendable { + private static let itemPrefix = "i:" + + func identifier( + for identity: FileProviderItemIdentity + ) -> NSFileProviderItemIdentifier { + switch identity { + case .root: + return .rootContainer + case .item(let id): + return NSFileProviderItemIdentifier( + rawValue: Self.itemPrefix + id.uuidString.lowercased() + ) + } + } + + func identity( + for identifier: NSFileProviderItemIdentifier + ) throws -> FileProviderItemIdentity { + guard identifier != .rootContainer else { return .root } + let raw = identifier.rawValue + guard raw.hasPrefix(Self.itemPrefix), + let id = UUID(uuidString: String(raw.dropFirst(2))) + else { + throw FileProviderRemotePathError.invalidItemIdentifier + } + return .item(id) + } +} + +enum FileProviderPathValidation { + static func validateCanonicalAbsolute(_ path: String) throws { + guard path.hasPrefix("/"), !path.contains("\0") else { + throw FileProviderRemotePathError.invalidCanonicalPath + } + + guard path != "/" else { return } + let components = path.split(separator: "/", omittingEmptySubsequences: false) + guard components.first == "", + components.dropFirst().allSatisfy({ component in + !component.isEmpty && component != "." && component != ".." + }) + else { + throw FileProviderRemotePathError.invalidCanonicalPath + } + } +} diff --git a/RemuxApp/Sources/FileProvider/FileProviderRemoteService.swift b/RemuxApp/Sources/FileProvider/FileProviderRemoteService.swift new file mode 100644 index 00000000..f69d4ff1 --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderRemoteService.swift @@ -0,0 +1,391 @@ +import Foundation + +enum FileProviderRemoteServiceError: Error, Equatable, Sendable { + case invalidDomainIdentifier + case serverNotFound +} + +protocol FileProviderSFTPClientProviding: Sendable { + func withClient( + server: SavedServer, + authentication: ResolvedSSHAuth, + operation: @Sendable (any RemuxSFTPFileProviderClient) async throws -> Value + ) async throws -> Value + + func closeIdleConnections(forServerID serverID: SavedServer.ID) async +} + +struct FileProviderRemoteFetchProgress: Equatable, Sendable { + let totalByteCount: Int64 + let completedByteCount: Int64 +} + +protocol FileProviderRemoteMutationAccess: Sendable { + func item(at path: FileProviderRemotePath) async throws -> FileProviderRemoteItem + func list(directory: FileProviderRemotePath) async throws -> [FileProviderRemoteItem] + func createDirectory(at path: FileProviderRemotePath) async throws + func uploadFile( + from localURL: URL, + to path: FileProviderRemotePath, + progress: @escaping @Sendable (Int64) async -> Void + ) async throws + func renameItem( + from source: FileProviderRemotePath, + to destination: FileProviderRemotePath + ) async throws + func removeFile(at path: FileProviderRemotePath) async throws + func removeEmptyDirectory(at path: FileProviderRemotePath) async throws +} + +protocol FileProviderRemoteServicing: Sendable { + func item(at path: FileProviderRemotePath) async throws -> FileProviderRemoteItem + func list(directory: FileProviderRemotePath) async throws -> [FileProviderRemoteItem] + func fetch( + path: FileProviderRemotePath, + to localURL: URL, + progress: @escaping @Sendable (FileProviderRemoteFetchProgress) async -> Void + ) async throws -> FileProviderRemoteItem + func withMutationAccess( + _ operation: @Sendable (any FileProviderRemoteMutationAccess) async throws -> Value + ) async throws -> Value + func invalidate() async +} + +struct FileProviderRemoteService: FileProviderRemoteServicing { + private let domainIdentifier: String + private let profiles: any ConnectionProfileRepository + private let credentials: any SSHCredentialStore + private let clientProvider: any FileProviderSFTPClientProviding + + init( + domainIdentifier: String, + profiles: any ConnectionProfileRepository, + credentials: any SSHCredentialStore, + clientProvider: any FileProviderSFTPClientProviding + ) { + self.domainIdentifier = domainIdentifier + self.profiles = profiles + self.credentials = credentials + self.clientProvider = clientProvider + } + + func list(directory: FileProviderRemotePath) async throws -> [FileProviderRemoteItem] { + try await withAccess { access in + try await access.list(directory: directory) + } + } + + func item(at path: FileProviderRemotePath) async throws -> FileProviderRemoteItem { + try await withAccess { access in + try await access.item(at: path) + } + } + + func fetch( + path: FileProviderRemotePath, + to localURL: URL, + progress: @escaping @Sendable (FileProviderRemoteFetchProgress) async -> Void + ) async throws -> FileProviderRemoteItem { + try await withAccess { access in + try await access.fetch(path: path, to: localURL, progress: progress) + } + } + + func withMutationAccess( + _ operation: @Sendable (any FileProviderRemoteMutationAccess) async throws -> Value + ) async throws -> Value { + try await withAccess { access in + try await operation(access) + } + } + + func invalidate() async { + guard let serverID = UUID(uuidString: domainIdentifier) else { + return + } + await clientProvider.closeIdleConnections(forServerID: serverID) + } + + private func withAccess( + _ operation: @Sendable ( + FileProviderSFTPOperation + ) async throws -> Value + ) async throws -> Value { + try await withClient { client in + try Task.checkCancellation() + let canonicalHome = try await client.realPath(atPath: ".") + let access = try FileProviderSFTPOperation( + client: client, + canonicalHome: canonicalHome + ) + return try await operation(access) + } + } + + private func withClient( + _ operation: @Sendable (any RemuxSFTPFileProviderClient) async throws -> Value + ) async throws -> Value { + let snapshot = try await profiles.loadSnapshot() + guard let serverID = UUID(uuidString: domainIdentifier) else { + throw FileProviderRemoteServiceError.invalidDomainIdentifier + } + guard let server = snapshot.servers.first(where: { $0.id == serverID }) else { + throw FileProviderRemoteServiceError.serverNotFound + } + let authentication = try await SSHAuthResolver(credentialStore: credentials) + .resolve(server: server, in: snapshot) + return try await clientProvider.withClient( + server: server, + authentication: authentication, + operation: operation + ) + } +} + +private struct FileProviderSFTPOperation: FileProviderRemoteMutationAccess { + private let client: any RemuxSFTPFileProviderClient + private let canonicalHome: String + private let safeLinkResolver = FileProviderSafeLinkResolver() + + init( + client: any RemuxSFTPFileProviderClient, + canonicalHome: String + ) throws { + try FileProviderPathValidation.validateCanonicalAbsolute(canonicalHome) + self.client = client + self.canonicalHome = canonicalHome + } + + func item(at path: FileProviderRemotePath) async throws -> FileProviderRemoteItem { + try Task.checkCancellation() + let entry = try await existingPath(for: path) + let metadata = if path == .root { + try await client.metadata(atPath: entry) + } else { + try await client.linkMetadata(atPath: entry) + } + + switch metadata.type { + case .regular, .directory: + return try FileProviderRemoteItem(path: path, metadata: metadata) + case .symbolicLink: + do { + let canonicalTarget = try await client.realPath(atPath: entry) + let relativeTarget = try safeLinkResolver.resolve( + canonicalTarget, + home: canonicalHome, + for: path + ) + return try FileProviderRemoteItem( + path: path, + metadata: metadata, + symlinkTargetRelativePath: relativeTarget + ) + } catch { + throw RemuxSFTPClientError.noSuchFile(path.relative) + } + case .other: + throw RemuxSFTPClientError.noSuchFile(path.relative) + } + } + + func list(directory: FileProviderRemotePath) async throws -> [FileProviderRemoteItem] { + try Task.checkCancellation() + let canonicalDirectory = try await canonicalDirectory(for: directory) + let entries = try await client.listDirectory(atPath: canonicalDirectory) + var items: [FileProviderRemoteItem] = [] + + for entry in entries { + guard let path = childPath(named: entry.name, beneath: directory) else { + continue + } + + switch entry.metadata.type { + case .other: + continue + case .symbolicLink: + let canonicalEntry = try append( + component: entry.name, + to: canonicalDirectory + ) + guard let canonicalTarget = try? await client.realPath(atPath: canonicalEntry), + let relativeTarget = try? safeLinkResolver.resolve( + canonicalTarget, + home: canonicalHome, + for: path + ) + else { + continue + } + items.append( + try FileProviderRemoteItem( + path: path, + metadata: entry.metadata, + symlinkTargetRelativePath: relativeTarget + ) + ) + case .regular, .directory: + items.append(try FileProviderRemoteItem(path: path, metadata: entry.metadata)) + } + } + + return items + } + + func fetch( + path: FileProviderRemotePath, + to localURL: URL, + progress: @escaping @Sendable (FileProviderRemoteFetchProgress) async -> Void + ) async throws -> FileProviderRemoteItem { + try Task.checkCancellation() + let entry = try await existingPath(for: path) + let metadata = try await client.linkMetadata(atPath: entry) + guard metadata.type == .regular else { + throw RemuxSFTPClientError.noSuchFile(path.relative) + } + let totalByteCount = metadata.size.map(Int64.init(clamping:)) ?? -1 + await progress( + FileProviderRemoteFetchProgress( + totalByteCount: totalByteCount, + completedByteCount: 0 + ) + ) + let canonicalEntry = try await client.realPath(atPath: entry) + try safeLinkResolver.ensureContained( + canonicalEntry, + home: canonicalHome + ) + try await client.downloadFile( + atPath: canonicalEntry, + to: localURL, + progress: { completedByteCount in + await progress( + FileProviderRemoteFetchProgress( + totalByteCount: totalByteCount, + completedByteCount: completedByteCount + ) + ) + } + ) + return try FileProviderRemoteItem(path: path, metadata: metadata) + } + + func createDirectory(at path: FileProviderRemotePath) async throws { + try Task.checkCancellation() + let destination = try await destinationPath(for: path) + try Task.checkCancellation() + try await client.createDirectory(atPath: destination) + } + + func uploadFile( + from localURL: URL, + to path: FileProviderRemotePath, + progress: @escaping @Sendable (Int64) async -> Void + ) async throws { + try Task.checkCancellation() + let destination = try await destinationPath(for: path) + try Task.checkCancellation() + try await client.uploadFile( + from: localURL, + to: destination, + progress: progress + ) + } + + func renameItem( + from source: FileProviderRemotePath, + to destination: FileProviderRemotePath + ) async throws { + try Task.checkCancellation() + let sourcePath = try await mutationSourcePath(for: source) + let destinationPath = try await destinationPath(for: destination) + try Task.checkCancellation() + try await client.renameItem( + from: sourcePath, + to: destinationPath + ) + } + + func removeFile(at path: FileProviderRemotePath) async throws { + try Task.checkCancellation() + let sourcePath = try await mutationSourcePath(for: path) + try Task.checkCancellation() + try await client.removeFile(atPath: sourcePath) + } + + func removeEmptyDirectory(at path: FileProviderRemotePath) async throws { + try Task.checkCancellation() + let sourcePath = try await mutationSourcePath(for: path) + try Task.checkCancellation() + try await client.removeEmptyDirectory(atPath: sourcePath) + } + + private func destinationPath(for path: FileProviderRemotePath) async throws -> String { + guard path != .root, + let name = path.relative.split(separator: "/").last.map(String.init) + else { + throw FileProviderRemotePathError.invalidRelativePath + } + let parent = try FileProviderRemotePath( + relative: (path.relative as NSString).deletingLastPathComponent + ) + return try append(component: name, to: try await canonicalDirectory(for: parent)) + } + + private func mutationSourcePath(for path: FileProviderRemotePath) async throws -> String { + guard path != .root else { + throw FileProviderRemotePathError.invalidRelativePath + } + let entry = try await existingPath(for: path) + let metadata = try await client.linkMetadata(atPath: entry) + guard metadata.type != .symbolicLink, metadata.type != .other else { + throw RemuxSFTPClientError.noSuchFile(path.relative) + } + return entry + } + + private func existingPath(for path: FileProviderRemotePath) async throws -> String { + guard path != .root, + let name = path.relative.split(separator: "/").last.map(String.init) + else { + return canonicalHome + } + let parent = try FileProviderRemotePath( + relative: (path.relative as NSString).deletingLastPathComponent + ) + return try append(component: name, to: try await canonicalDirectory(for: parent)) + } + + private func canonicalDirectory(for directory: FileProviderRemotePath) async throws -> String { + guard directory != .root else { return canonicalHome } + let requested = try directory.remotePath(beneath: canonicalHome) + let canonical = try await client.realPath(atPath: requested) + try safeLinkResolver.ensureContained(canonical, home: canonicalHome) + return canonical + } + + private func append(component: String, to directory: String) throws -> String { + try FileProviderPathValidation.validateCanonicalAbsolute(directory) + guard isValidChildName(component) else { + throw FileProviderRemotePathError.invalidRelativePath + } + return directory == "/" ? "/\(component)" : "\(directory)/\(component)" + } + + private func childPath( + named name: String, + beneath directory: FileProviderRemotePath + ) -> FileProviderRemotePath? { + guard isValidChildName(name) else { return nil } + let relative = directory.relative.isEmpty ? name : "\(directory.relative)/\(name)" + return try? FileProviderRemotePath(relative: relative) + } + + private func isValidChildName(_ name: String) -> Bool { + !name.isEmpty + && name != "." + && name != ".." + && !name.contains("/") + && !name.contains("\0") + } +} diff --git a/RemuxApp/Sources/FileProvider/FileProviderSDKItem.swift b/RemuxApp/Sources/FileProvider/FileProviderSDKItem.swift new file mode 100644 index 00000000..dff02574 --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderSDKItem.swift @@ -0,0 +1,37 @@ +import FileProvider +import Foundation +import UniformTypeIdentifiers + +final class FileProviderSDKItem: NSObject, NSFileProviderItem { + let itemIdentifier: NSFileProviderItemIdentifier + let parentItemIdentifier: NSFileProviderItemIdentifier + let filename: String + let contentType: UTType + let documentSize: NSNumber? + let contentModificationDate: Date? + let capabilities: NSFileProviderItemCapabilities + let itemVersion: NSFileProviderItemVersion + let symlinkTargetPath: String? + + init(projection: FileProviderItemProjection) { + self.itemIdentifier = projection.itemIdentifier + self.parentItemIdentifier = projection.parentItemIdentifier + self.filename = projection.filename + self.contentType = projection.contentType + self.documentSize = projection.documentSize + self.contentModificationDate = projection.contentModificationDate + self.capabilities = projection.capabilities + self.itemVersion = projection.itemVersion + self.symlinkTargetPath = projection.symlinkTargetPath + super.init() + } + + convenience init(item: FileProviderIdentifiedItem, rootDisplayName: String) { + self.init( + projection: FileProviderItemProjection( + item: item, + rootDisplayName: rootDisplayName + ) + ) + } +} diff --git a/RemuxApp/Sources/FileProvider/FileProviderSnapshotStore.swift b/RemuxApp/Sources/FileProvider/FileProviderSnapshotStore.swift new file mode 100644 index 00000000..3e9e4c11 --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderSnapshotStore.swift @@ -0,0 +1,683 @@ +import FileProvider +import Foundation + +struct FileProviderSnapshotDelta: Equatable, Sendable { + let updated: [FileProviderIdentifiedItem] + let deleted: [NSFileProviderItemIdentifier] +} + +enum FileProviderSnapshotStoreError: Error, Equatable, Sendable { + case syncAnchorExpired + case generationExhausted + case duplicatePath + case itemIdentityNotFound +} + +actor FileProviderSnapshotStore { + private static let stateFilename = "snapshot-generations-v3.json" + private static let namespaceByteCount = 16 + private static let generationByteCount = MemoryLayout.size + + private let stateURL: URL + private let retainedGenerationCount: Int + private let fileManager: FileManager + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private let identifierCodec = FileProviderItemIdentifierCodec() + private let identityGenerator: @Sendable () -> UUID + + init( + rootURL: URL, + retainedGenerationCount: Int = 8, + fileManager: FileManager = .default, + identityGenerator: @escaping @Sendable () -> UUID = UUID.init + ) { + precondition(retainedGenerationCount > 0) + + self.stateURL = rootURL.appendingPathComponent(Self.stateFilename) + self.retainedGenerationCount = retainedGenerationCount + self.fileManager = fileManager + self.encoder = JSONEncoder() + self.decoder = JSONDecoder() + self.identityGenerator = identityGenerator + encoder.outputFormatting = [.sortedKeys] + } + + func record( + directory: FileProviderRemotePath, + items: [FileProviderRemoteItem] + ) throws -> [FileProviderIdentifiedItem] { + try Task.checkCancellation() + var state = try loadState() + try Task.checkCancellation() + try validateUniquePaths(in: items) + let items = items.sorted { $0.path.relative < $1.path.relative } + let previousItems = state.generations.last?.items(for: directory) ?? [] + let previousRemoteItems = previousItems.map(\.remoteItem) + + if state.generations.last != nil, items == previousRemoteItems { + return previousItems + } + + let hadPreviousGeneration = state.generations.last != nil + let nextGeneration = try generation(after: state.generations.last?.generation) + var directories = state.generations.last?.directories ?? [] + let previousByPath = Dictionary( + uniqueKeysWithValues: previousItems.map { ($0.remoteItem.path, $0.identity) } + ) + let identitiesByPath = Dictionary( + uniqueKeysWithValues: directories + .flatMap(\.items) + .map { ($0.remoteItem.path, $0.identity) } + ) + let identifiedItems = try items.map { remote in + FileProviderIdentifiedItem( + identity: previousByPath[remote.path] ?? .item(identityGenerator()), + parentIdentity: try parentIdentity( + for: remote, + identitiesByPath: identitiesByPath + ), + remoteItem: remote + ) + } + if let index = directories.firstIndex(where: { $0.path == directory }) { + directories[index] = PersistedDirectory(path: directory, items: identifiedItems) + } else { + directories.append(PersistedDirectory(path: directory, items: identifiedItems)) + } + pruneTrackedSubtrees( + from: &directories, + previousItems: previousRemoteItems, + currentItems: items + ) + directories.sort { $0.path.relative < $1.path.relative } + let currentItems = try workingSetItems(in: directories) + pruneCreateAliases(in: &state, currentItems: currentItems) + + let latest = PersistedGeneration( + generation: nextGeneration, + directories: directories + ) + state.generations.append(latest) + state.generations = Array(state.generations.suffix(retainedGenerationCount)) + if hadPreviousGeneration { + state.pendingWorkingSetSignalGeneration = nextGeneration + } + try Task.checkCancellation() + try save(state) + + return identifiedItems + } + + func commit( + localMutation: FileProviderSnapshotLocalMutation + ) throws { + try Task.checkCancellation() + var state = try loadState() + try Task.checkCancellation() + var directories = state.generations.last?.directories ?? [] + pruneDeletedIdentities( + localMutation.deletedIdentities, + from: &directories + ) + try apply( + localMutation.relocations, + to: &directories + ) + _ = try workingSetItems(in: directories) + try validateIdentityReservations( + localMutation.identityReservations, + in: directories + ) + + var identitiesByPath = Dictionary( + uniqueKeysWithValues: directories + .flatMap(\.items) + .map { ($0.remoteItem.path, $0.identity) } + ) + for reservation in localMutation.identityReservations { + identitiesByPath[reservation.path] = reservation.identity + } + + for refresh in localMutation.refreshedDirectories { + try validateUniquePaths(in: refresh.items) + let previousDirectoryItems = directories + .first(where: { $0.path == refresh.directory })? + .items + .map(\.remoteItem) ?? [] + let identifiedItems = try refresh.items + .sorted { $0.path.relative < $1.path.relative } + .map { remote in + FileProviderIdentifiedItem( + identity: identitiesByPath[remote.path] ?? .item(identityGenerator()), + parentIdentity: try parentIdentity( + for: remote, + identitiesByPath: identitiesByPath + ), + remoteItem: remote + ) + } + if let index = directories.firstIndex(where: { $0.path == refresh.directory }) { + directories[index] = PersistedDirectory( + path: refresh.directory, + items: identifiedItems + ) + } else { + directories.append(PersistedDirectory( + path: refresh.directory, + items: identifiedItems + )) + } + pruneTrackedSubtrees( + from: &directories, + previousItems: previousDirectoryItems, + currentItems: refresh.items + ) + for item in identifiedItems { + identitiesByPath[item.remoteItem.path] = item.identity + } + } + + directories.sort { $0.path.relative < $1.path.relative } + let items = try workingSetItems(in: directories) + if let createAlias = localMutation.createAlias { + state.createAliases[createAlias.templateIdentifier] = createAlias.identity + } + pruneCreateAliases(in: &state, currentItems: items) + let nextGeneration = try generation(after: state.generations.last?.generation) + let latest = PersistedGeneration( + generation: nextGeneration, + directories: directories + ) + state.generations.append(latest) + state.generations = Array(state.generations.suffix(retainedGenerationCount)) + if localMutation.queuesWorkingSetSignal { + state.pendingWorkingSetSignalGeneration = nextGeneration + } + try Task.checkCancellation() + try save(state) + + } + + func pendingWorkingSetSignalGeneration() throws -> UInt64? { + try loadState().pendingWorkingSetSignalGeneration + } + + func acknowledgeWorkingSetSignal( + generation: UInt64 + ) throws { + var state = try loadState() + guard let pending = state.pendingWorkingSetSignalGeneration, + pending <= generation + else { + return + } + state.pendingWorkingSetSignalGeneration = nil + try save(state) + } + + func items(directory: FileProviderRemotePath) throws -> [FileProviderIdentifiedItem] { + try loadState().generations.last?.items(for: directory) ?? [] + } + + func path( + for identifier: NSFileProviderItemIdentifier + ) throws -> FileProviderRemotePath { + try Self.path(in: loadState(), for: identifier) + } + + nonisolated func pathSynchronously( + for identifier: NSFileProviderItemIdentifier + ) throws -> FileProviderRemotePath { + guard identifier != .rootContainer else { return .root } + let stateURL = stateURL + let decoder = JSONDecoder() + guard FileManager.default.fileExists(atPath: stateURL.path) else { + throw FileProviderSnapshotStoreError.itemIdentityNotFound + } + let state = try decoder.decode(PersistedState.self, from: Data(contentsOf: stateURL)) + return try Self.path(in: state, for: identifier) + } + + func item( + for identifier: NSFileProviderItemIdentifier + ) throws -> FileProviderIdentifiedItem? { + let state = try loadState() + let identity = try identifierCodec.identity(for: identifier) + guard identity != .root else { return nil } + return state.generations.last?.directories + .flatMap(\.items) + .first(where: { $0.identity == identity }) + } + + func item( + forCreateTemplateIdentifier identifier: String + ) throws -> FileProviderIdentifiedItem? { + let state = try loadState() + guard let identity = state.createAliases[identifier] else { + return nil + } + return try state.generations.last?.workingSetItems() + .first(where: { $0.identity == identity }) + } + + func currentAnchor() throws -> NSFileProviderSyncAnchor? { + let state = try loadState() + guard let latest = state.generations.last else { + return nil + } + return makeAnchor(namespace: state.namespace, generation: latest.generation) + } + + func workingSetSnapshot() throws -> ( + anchor: NSFileProviderSyncAnchor, + items: [FileProviderIdentifiedItem] + ) { + let state = try loadState() + if let latest = state.generations.last { + return ( + anchor: makeAnchor( + namespace: state.namespace, + generation: latest.generation + ), + items: try latest.workingSetItems() + ) + } + + let items = try record(directory: .root, items: []) + let initializedState = try loadState() + let latest = initializedState.generations.last! + return ( + anchor: makeAnchor( + namespace: initializedState.namespace, + generation: latest.generation + ), + items: items + ) + } + + func delta( + directory: FileProviderRemotePath, + from anchor: NSFileProviderSyncAnchor + ) throws -> (anchor: NSFileProviderSyncAnchor, delta: FileProviderSnapshotDelta) { + let state = try loadState() + let requestedAnchor = try parse(anchor) + guard requestedAnchor.namespace == state.namespace, + let requested = state.generations.first(where: { $0.generation == requestedAnchor.generation }), + let latest = state.generations.last + else { + throw FileProviderSnapshotStoreError.syncAnchorExpired + } + + return ( + anchor: makeAnchor(namespace: state.namespace, generation: latest.generation), + delta: makeDelta( + from: requested.items(for: directory), + to: latest.items(for: directory) + ) + ) + } + + func workingSetDelta( + from anchor: NSFileProviderSyncAnchor + ) throws -> (anchor: NSFileProviderSyncAnchor, delta: FileProviderSnapshotDelta) { + let state = try loadState() + let requestedAnchor = try parse(anchor) + guard requestedAnchor.namespace == state.namespace, + let requested = state.generations.first(where: { + $0.generation == requestedAnchor.generation + }), + let latest = state.generations.last + else { + throw FileProviderSnapshotStoreError.syncAnchorExpired + } + + return ( + anchor: makeAnchor( + namespace: state.namespace, + generation: latest.generation + ), + delta: makeDelta( + from: try requested.workingSetItems(), + to: try latest.workingSetItems() + ) + ) + } + + private func loadState() throws -> PersistedState { + guard fileManager.fileExists(atPath: stateURL.path) else { + return PersistedState( + namespace: UUID(), + generations: [], + createAliases: [:], + pendingWorkingSetSignalGeneration: nil + ) + } + let state = try decoder.decode(PersistedState.self, from: Data(contentsOf: stateURL)) + try validate(state) + return state + } + + private func save(_ state: PersistedState) throws { + try fileManager.createDirectory( + at: stateURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try encoder.encode(state).write(to: stateURL, options: .atomic) + } + + private func generation(after generation: UInt64?) throws -> UInt64 { + guard generation != UInt64.max else { + throw FileProviderSnapshotStoreError.generationExhausted + } + return (generation ?? 0) + 1 + } + + private func makeAnchor( + namespace: UUID, + generation: UInt64 + ) -> NSFileProviderSyncAnchor { + var namespaceBytes = namespace.uuid + var bigEndian = generation.bigEndian + var data = Data(bytes: &namespaceBytes, count: Self.namespaceByteCount) + data.append(Data(bytes: &bigEndian, count: Self.generationByteCount)) + return NSFileProviderSyncAnchor( + rawValue: data + ) + } + + private func parse(_ anchor: NSFileProviderSyncAnchor) throws -> (namespace: UUID, generation: UInt64) { + let data = anchor.rawValue + guard data.count == Self.namespaceByteCount + Self.generationByteCount else { + throw FileProviderSnapshotStoreError.syncAnchorExpired + } + let bytes = [UInt8](data) + let namespace = UUID(uuid: ( + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], + bytes[12], bytes[13], bytes[14], bytes[15] + )) + let generation = bytes.suffix(Self.generationByteCount) + .reduce(0) { ($0 << 8) | UInt64($1) } + return (namespace, generation) + } + + private func validate(_ state: PersistedState) throws { + guard Set(state.generations.map(\.generation)).count == state.generations.count else { + throw FileProviderSnapshotStoreError.duplicatePath + } + + for generation in state.generations { + guard Set(generation.directories.map(\.path)).count == generation.directories.count else { + throw FileProviderSnapshotStoreError.duplicatePath + } + for directory in generation.directories { + try validateUniquePaths(in: directory.items) + } + } + } + + private func validateUniquePaths(in items: [FileProviderRemoteItem]) throws { + guard Set(items.map(\.path)).count == items.count else { + throw FileProviderSnapshotStoreError.duplicatePath + } + } + + private func validateUniquePaths(in items: [FileProviderIdentifiedItem]) throws { + guard Set(items.map(\.remoteItem.path)).count == items.count, + Set(items.map(\.identity)).count == items.count + else { + throw FileProviderSnapshotStoreError.duplicatePath + } + } + + private func pruneTrackedSubtrees( + from directories: inout [PersistedDirectory], + previousItems: [FileProviderRemoteItem], + currentItems: [FileProviderRemoteItem] + ) { + let currentDirectoryPaths = Set( + currentItems + .filter { $0.type == .directory } + .map(\.path) + ) + let removedDirectoryPaths = previousItems.compactMap { item in + item.type == .directory && !currentDirectoryPaths.contains(item.path) + ? item.path + : nil + } + + directories.removeAll { directory in + removedDirectoryPaths.contains { removedPath in + directory.path == removedPath + || directory.path.relative.hasPrefix(removedPath.relative + "/") + } + } + } + + private func apply( + _ relocations: [FileProviderSnapshotLocalMutation.IdentityRelocation], + to directories: inout [PersistedDirectory] + ) throws { + for relocation in relocations { + guard let movedItem = directories + .flatMap(\.items) + .first(where: { $0.identity == relocation.identity }), + movedItem.remoteItem.path == relocation.from + else { + throw FileProviderSnapshotStoreError.itemIdentityNotFound + } + directories = try directories.map { directory in + PersistedDirectory( + path: try relocatedPath( + directory.path, + from: relocation.from, + to: relocation.to + ), + items: try directory.items.map { item in + FileProviderIdentifiedItem( + identity: item.identity, + parentIdentity: item.parentIdentity, + remoteItem: try relocatedRemoteItem( + item.remoteItem, + from: relocation.from, + to: relocation.to + ) + ) + } + ) + } + } + } + + private func validateIdentityReservations( + _ reservations: [FileProviderSnapshotLocalMutation.IdentityReservation], + in directories: [PersistedDirectory] + ) throws { + let items = try workingSetItems(in: directories) + guard Set(reservations.map(\.identity)).count == reservations.count, + Set(reservations.map(\.path)).count == reservations.count + else { + throw FileProviderSnapshotStoreError.duplicatePath + } + let identitiesByPath = Dictionary( + uniqueKeysWithValues: items.map { ($0.remoteItem.path, $0.identity) } + ) + let pathsByIdentity = Dictionary( + uniqueKeysWithValues: items.map { ($0.identity, $0.remoteItem.path) } + ) + for reservation in reservations { + guard identitiesByPath[reservation.path] == nil + || identitiesByPath[reservation.path] == reservation.identity, + pathsByIdentity[reservation.identity] == nil + || pathsByIdentity[reservation.identity] == reservation.path + else { + throw FileProviderSnapshotStoreError.duplicatePath + } + } + } + + private func workingSetItems( + in directories: [PersistedDirectory] + ) throws -> [FileProviderIdentifiedItem] { + let items = directories + .flatMap(\.items) + .sorted { $0.remoteItem.path.relative < $1.remoteItem.path.relative } + guard Set(items.map(\.remoteItem.path)).count == items.count, + Set(items.map(\.identity)).count == items.count + else { + throw FileProviderSnapshotStoreError.duplicatePath + } + return items + } + + private func pruneDeletedIdentities( + _ deletedIdentities: Set, + from directories: inout [PersistedDirectory] + ) { + let deletedPaths = directories + .flatMap(\.items) + .filter { deletedIdentities.contains($0.identity) } + .map(\.remoteItem.path) + directories.removeAll { directory in + deletedPaths.contains { path in + directory.path == path + || directory.path.relative.hasPrefix(path.relative + "/") + } + } + directories = directories.compactMap { directory in + let items = directory.items.filter { item in + !deletedPaths.contains { path in + item.remoteItem.path == path + || item.remoteItem.path.relative.hasPrefix(path.relative + "/") + } + } + return PersistedDirectory(path: directory.path, items: items) + } + } + + private func pruneCreateAliases( + in state: inout PersistedState, + currentItems: [FileProviderIdentifiedItem] + ) { + let currentIdentities = Set(currentItems.map(\.identity)) + state.createAliases = state.createAliases.filter { + currentIdentities.contains($0.value) + } + } + + private func relocatedPath( + _ path: FileProviderRemotePath, + from: FileProviderRemotePath, + to: FileProviderRemotePath + ) throws -> FileProviderRemotePath { + guard from != .root else { return path } + guard path == from || path.relative.hasPrefix(from.relative + "/") else { + return path + } + let suffix = String(path.relative.dropFirst(from.relative.count)) + return try FileProviderRemotePath(relative: to.relative + suffix) + } + + private func relocatedRemoteItem( + _ item: FileProviderRemoteItem, + from: FileProviderRemotePath, + to: FileProviderRemotePath + ) throws -> FileProviderRemoteItem { + let path = try relocatedPath(item.path, from: from, to: to) + guard path != item.path else { return item } + return try FileProviderRemoteItem( + path: path, + metadata: RemuxSFTPFileMetadata( + size: item.size, + permissions: item.permissions, + modificationDate: item.modificationDate, + type: item.type + ), + symlinkTargetRelativePath: item.symlinkTargetRelativePath + ) + } + + private func makeDelta( + from previousItems: [FileProviderIdentifiedItem], + to currentItems: [FileProviderIdentifiedItem] + ) -> FileProviderSnapshotDelta { + let previousByPath = Dictionary( + uniqueKeysWithValues: previousItems.map { ($0.remoteItem.path, $0) } + ) + let currentByPath = Dictionary( + uniqueKeysWithValues: currentItems.map { ($0.remoteItem.path, $0) } + ) + + let updated = currentItems.filter { + currentByPath[$0.remoteItem.path]?.remoteItem + != previousByPath[$0.remoteItem.path]?.remoteItem + } + let deleted = previousByPath.keys + .filter { currentByPath[$0] == nil } + .compactMap { previousByPath[$0]?.itemIdentifier } + .sorted { $0.rawValue < $1.rawValue } + + return FileProviderSnapshotDelta(updated: updated, deleted: deleted) + } + + private func parentIdentity( + for remoteItem: FileProviderRemoteItem, + identitiesByPath: [FileProviderRemotePath: FileProviderItemIdentity] + ) throws -> FileProviderItemIdentity { + guard remoteItem.parent != .root else { return .root } + guard let parentIdentity = identitiesByPath[remoteItem.parent] else { + throw FileProviderSnapshotStoreError.itemIdentityNotFound + } + return parentIdentity + } + + private nonisolated static func path( + in state: PersistedState, + for identifier: NSFileProviderItemIdentifier + ) throws -> FileProviderRemotePath { + let identity = try FileProviderItemIdentifierCodec().identity(for: identifier) + guard identity != .root else { return .root } + guard let item = state.generations.last?.directories + .flatMap(\.items) + .first(where: { $0.identity == identity }) + else { + throw FileProviderSnapshotStoreError.itemIdentityNotFound + } + return item.remoteItem.path + } +} + +private struct PersistedState: Codable { + let namespace: UUID + var generations: [PersistedGeneration] + var createAliases: [String: FileProviderItemIdentity] + var pendingWorkingSetSignalGeneration: UInt64? +} + +private struct PersistedGeneration: Codable { + let generation: UInt64 + let directories: [PersistedDirectory] + + func items(for directory: FileProviderRemotePath) -> [FileProviderIdentifiedItem] { + directories.first(where: { $0.path == directory })?.items ?? [] + } + + func workingSetItems() throws -> [FileProviderIdentifiedItem] { + let items = directories + .flatMap(\.items) + .sorted { $0.remoteItem.path.relative < $1.remoteItem.path.relative } + guard Set(items.map(\.remoteItem.path)).count == items.count, + Set(items.map(\.identity)).count == items.count + else { + throw FileProviderSnapshotStoreError.duplicatePath + } + return items + } +} + +private struct PersistedDirectory: Codable { + let path: FileProviderRemotePath + let items: [FileProviderIdentifiedItem] +} diff --git a/RemuxApp/Sources/Persistence/ApplicationStorage.swift b/RemuxApp/Sources/Persistence/ApplicationStorage.swift index 3d61809d..121938e0 100644 --- a/RemuxApp/Sources/Persistence/ApplicationStorage.swift +++ b/RemuxApp/Sources/Persistence/ApplicationStorage.swift @@ -1,5 +1,36 @@ import Foundation +enum FileProviderSharedConfigurationError: Error, Sendable { + case missingSharedContainer + case missingApplicationKeychainAccessGroup + case missingKeychainAccessGroup +} + +enum FileProviderSharedConfiguration { + static let appGroupIdentifier = "group.dev.remux" + static let credentialService = "dev.remux.ssh-credentials" + + static func applicationKeychainAccessGroup( + infoDictionary: [String: Any] = Bundle.main.infoDictionary ?? [:] + ) throws -> String { + guard let value = infoDictionary["RemuxApplicationKeychainAccessGroup"] as? String, + !value.isEmpty else { + throw FileProviderSharedConfigurationError.missingApplicationKeychainAccessGroup + } + return value + } + + static func keychainAccessGroup( + infoDictionary: [String: Any] = Bundle.main.infoDictionary ?? [:] + ) throws -> String { + guard let value = infoDictionary["RemuxSharedKeychainAccessGroup"] as? String, + !value.isEmpty else { + throw FileProviderSharedConfigurationError.missingKeychainAccessGroup + } + return value + } +} + enum ApplicationStorage { static func remuxRoot( overridePath: String? = nil, @@ -22,4 +53,20 @@ enum ApplicationStorage { try fileManager.createDirectory(at: root, withIntermediateDirectories: true) return root } + + static func sharedRemuxRoot( + appGroupIdentifier: String = FileProviderSharedConfiguration.appGroupIdentifier, + fileManager: FileManager = .default, + containerURL: @Sendable (String) -> URL? = { + FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: $0 + ) + } + ) throws -> URL { + guard let containerURL = containerURL(appGroupIdentifier) else { + throw FileProviderSharedConfigurationError.missingSharedContainer + } + + return containerURL.appendingPathComponent("Remux", isDirectory: true) + } } diff --git a/RemuxApp/Sources/Persistence/FileProviderSharedStorageMigrator.swift b/RemuxApp/Sources/Persistence/FileProviderSharedStorageMigrator.swift new file mode 100644 index 00000000..b8836af8 --- /dev/null +++ b/RemuxApp/Sources/Persistence/FileProviderSharedStorageMigrator.swift @@ -0,0 +1,83 @@ +import Foundation + +protocol FileProviderSharedStorageMigrating: Sendable { + func migrateIfNeeded() async throws +} + +enum FileProviderSharedStorageMigrationError: Error, Equatable, Sendable { + case credentialVerificationFailed(SSHIdentity.ID) + case verificationFailed +} + +actor FileProviderSharedStorageMigrator: FileProviderSharedStorageMigrating { + private let legacyProfiles: any ConnectionProfileRepository + private let legacyCredentials: any SSHCredentialStore + private let legacyTrust: TrustedHostStore + private let sharedProfiles: any ConnectionProfileRepository + private let sharedCredentials: any SSHCredentialStore + private let sharedTrust: TrustedHostStore + private let markerURL: URL + private let fileManager: FileManager + + init( + legacyProfiles: any ConnectionProfileRepository, + legacyCredentials: any SSHCredentialStore, + legacyTrust: TrustedHostStore, + sharedProfiles: any ConnectionProfileRepository, + sharedCredentials: any SSHCredentialStore, + sharedTrust: TrustedHostStore, + markerURL: URL, + fileManager: FileManager = .default + ) { + self.legacyProfiles = legacyProfiles + self.legacyCredentials = legacyCredentials + self.legacyTrust = legacyTrust + self.sharedProfiles = sharedProfiles + self.sharedCredentials = sharedCredentials + self.sharedTrust = sharedTrust + self.markerURL = markerURL + self.fileManager = fileManager + } + + func migrateIfNeeded() async throws { + guard !fileManager.fileExists(atPath: markerURL.path) else { + return + } + + let snapshot = try await legacyProfiles.loadSnapshot() + let trustedIdentities = try legacyTrust.loadIdentities() + + for identity in snapshot.identities { + try await sharedProfiles.saveIdentity(identity) + + if let credential = try await legacyCredentials.loadCredential(identityID: identity.id) { + try await sharedCredentials.saveCredential(credential, identityID: identity.id) + + guard try await sharedCredentials.loadCredential(identityID: identity.id) == credential else { + throw FileProviderSharedStorageMigrationError.credentialVerificationFailed(identity.id) + } + } + } + + for server in snapshot.servers { + try await sharedProfiles.saveServer(server) + } + + for workspace in snapshot.workspaces { + try await sharedProfiles.saveWorkspace(workspace) + } + + try sharedTrust.replaceIdentities(trustedIdentities) + + guard try await sharedProfiles.loadSnapshot() == snapshot, + try sharedTrust.loadIdentities() == trustedIdentities else { + throw FileProviderSharedStorageMigrationError.verificationFailed + } + + try fileManager.createDirectory( + at: markerURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("1".utf8).write(to: markerURL, options: .atomic) + } +} diff --git a/RemuxApp/Sources/Persistence/SSHCredentialStore.swift b/RemuxApp/Sources/Persistence/SSHCredentialStore.swift index f090ebc5..e958bfb6 100644 --- a/RemuxApp/Sources/Persistence/SSHCredentialStore.swift +++ b/RemuxApp/Sources/Persistence/SSHCredentialStore.swift @@ -95,14 +95,19 @@ enum KeychainSSHCredentialStoreError: LocalizedError, Sendable { } actor KeychainSSHCredentialStore: SSHCredentialStore { - static let defaultService = "dev.remux.ssh-credentials" + static let defaultService = FileProviderSharedConfiguration.credentialService private let service: String + private let accessGroup: String? private let encoder = JSONEncoder() private let decoder = JSONDecoder() - init(service: String = KeychainSSHCredentialStore.defaultService) { + init( + service: String = KeychainSSHCredentialStore.defaultService, + accessGroup: String? = nil + ) { self.service = service + self.accessGroup = accessGroup } func loadCredential(identityID: SSHIdentity.ID) async throws -> SSHCredential? { @@ -158,13 +163,22 @@ actor KeychainSSHCredentialStore: SSHCredentialStore { } } - private func baseQuery(for identityID: SSHIdentity.ID, returnData: Bool) -> [CFString: Any] { + static func query( + service: String, + accessGroup: String?, + identityID: SSHIdentity.ID, + returnData: Bool + ) -> [CFString: Any] { var query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrService: service, kSecAttrAccount: identityID.uuidString, ] + if let accessGroup { + query[kSecAttrAccessGroup] = accessGroup + } + if returnData { query[kSecReturnData] = true query[kSecMatchLimit] = kSecMatchLimitOne @@ -172,4 +186,13 @@ actor KeychainSSHCredentialStore: SSHCredentialStore { return query } + + private func baseQuery(for identityID: SSHIdentity.ID, returnData: Bool) -> [CFString: Any] { + Self.query( + service: service, + accessGroup: accessGroup, + identityID: identityID, + returnData: returnData + ) + } } diff --git a/RemuxApp/Sources/Persistence/TrustedHostStore.swift b/RemuxApp/Sources/Persistence/TrustedHostStore.swift index 3dbe19ae..75accda4 100644 --- a/RemuxApp/Sources/Persistence/TrustedHostStore.swift +++ b/RemuxApp/Sources/Persistence/TrustedHostStore.swift @@ -79,6 +79,18 @@ final class TrustedHostStore: @unchecked Sendable { } } + func loadIdentities() throws -> [TrustedHostIdentity] { + try lock.withLock { + try loadLocked() + } + } + + func replaceIdentities(_ identities: [TrustedHostIdentity]) throws { + try lock.withLock { + try saveLocked(identities) + } + } + func deleteIdentity(for serverID: SavedServer.ID) throws { try lock.withLock { let identities = try loadLocked().filter { $0.serverID != serverID } @@ -92,7 +104,8 @@ final class TrustedHostStore: @unchecked Sendable { let trustedIdentity = Self.identity(challenge: challenge) if let index = identities.firstIndex(where: { $0.serverID == challenge.serverID }) { - if identities[index].openSSHPublicKey == challenge.receivedOpenSSHPublicKey { + if identities[index].host == challenge.host, + identities[index].openSSHPublicKey == challenge.receivedOpenSSHPublicKey { return } @@ -122,7 +135,8 @@ final class TrustedHostStore: @unchecked Sendable { try lock.withLock { let identities = try loadLocked() if let existing = identities.first(where: { $0.serverID == server.id }) { - guard existing.openSSHPublicKey == identity.openSSHPublicKey else { + guard existing.host == identity.host, + existing.openSSHPublicKey == identity.openSSHPublicKey else { throw TrustedHostStoreError.hostKeyTrustRequired( Self.challenge(kind: .changed, trusted: existing, received: identity) ) diff --git a/RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift b/RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift index 762298a8..d62caea1 100644 --- a/RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift +++ b/RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift @@ -2,10 +2,131 @@ import Foundation import NIO -struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { +struct RemuxCitadelSFTPAttributes: Sendable { + let size: UInt64? + let permissions: UInt32? + let modificationDate: Date? + + init( + size: UInt64?, + permissions: UInt32?, + modificationDate: Date? + ) { + self.size = size + self.permissions = permissions + self.modificationDate = modificationDate + } + + init(_ attributes: SFTPFileAttributes) { + self.init( + size: attributes.size, + permissions: attributes.permissions, + modificationDate: attributes.accessModificationTime?.modificationTime + ) + } +} + +struct RemuxCitadelSFTPDirectoryComponent: Sendable { + let filename: String + let attributes: RemuxCitadelSFTPAttributes +} + +struct RemuxCitadelSFTPDirectoryResponse: Sendable { + let components: [RemuxCitadelSFTPDirectoryComponent] +} + +protocol RemuxCitadelSFTPFile: Sendable { + func readData(from offset: UInt64, length: UInt32) async throws -> Data + func writeDataPipelined( + _ data: Data, + at offset: UInt64, + maxInFlight: Int + ) async throws + func close() async throws +} + +protocol RemuxCitadelSFTPConnection: Sendable { + func remuxRealPath(atPath path: String) async throws -> String + func remuxListDirectory( + atPath path: String + ) async throws -> [RemuxCitadelSFTPDirectoryResponse] + func remuxGetAttributes(atPath path: String) async throws -> RemuxCitadelSFTPAttributes + func remuxOpenFileForReading(atPath path: String) async throws -> any RemuxCitadelSFTPFile + func remuxOpenFileForWriting( + atPath path: String, + flags: SFTPOpenFileFlags + ) async throws -> any RemuxCitadelSFTPFile + func remuxCreateDirectory(atPath path: String) async throws + func remuxRename(from sourcePath: String, to destinationPath: String) async throws + func remuxRemove(atPath path: String) async throws + func remuxRemoveDirectory(atPath path: String) async throws +} + +extension SFTPClient: RemuxCitadelSFTPConnection { + func remuxRealPath(atPath path: String) async throws -> String { + try await getRealPath(atPath: path) + } + + func remuxListDirectory( + atPath path: String + ) async throws -> [RemuxCitadelSFTPDirectoryResponse] { + try await listDirectory(atPath: path).map { response in + RemuxCitadelSFTPDirectoryResponse( + components: response.components.map { component in + RemuxCitadelSFTPDirectoryComponent( + filename: component.filename, + attributes: RemuxCitadelSFTPAttributes(component.attributes) + ) + } + ) + } + } + + func remuxGetAttributes(atPath path: String) async throws -> RemuxCitadelSFTPAttributes { + RemuxCitadelSFTPAttributes(try await getAttributes(at: path)) + } + + func remuxOpenFileForReading( + atPath path: String + ) async throws -> any RemuxCitadelSFTPFile { + RemuxCitadelSFTPFileBox( + file: try await openFile(filePath: path, flags: .read) + ) + } + + func remuxOpenFileForWriting( + atPath path: String, + flags: SFTPOpenFileFlags + ) async throws -> any RemuxCitadelSFTPFile { + RemuxCitadelSFTPFileBox( + file: try await openFile( + filePath: path, + flags: flags + ) + ) + } + + func remuxCreateDirectory(atPath path: String) async throws { + try await createDirectory(atPath: path) + } + + func remuxRename(from sourcePath: String, to destinationPath: String) async throws { + try await rename(at: sourcePath, to: destinationPath) + } + + func remuxRemove(atPath path: String) async throws { + try await remove(at: path) + } + + func remuxRemoveDirectory(atPath path: String) async throws { + try await rmdir(at: path) + } +} + +struct RemuxCitadelSFTPClient: RemuxSFTPFileProviderClient, RemuxSFTPReadOnlyClient, RemuxSFTPUploadClient { private static let pipelinedWriteMaxInFlight = 64 - private let sftp: SFTPClient + private let connection: any RemuxCitadelSFTPConnection private let chunkSize: Int private let operationTimeout: TimeAmount private let leaseState: RemuxSFTPLeaseTeardown @@ -16,7 +137,21 @@ struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { operationTimeout: TimeAmount = .seconds(15), leaseState: RemuxSFTPLeaseTeardown ) { - self.sftp = sftp + self.init( + connection: sftp, + chunkSize: chunkSize, + operationTimeout: operationTimeout, + leaseState: leaseState + ) + } + + init( + connection: any RemuxCitadelSFTPConnection, + chunkSize: Int = 4 * 1024 * 1024, + operationTimeout: TimeAmount = .seconds(15), + leaseState: RemuxSFTPLeaseTeardown + ) { + self.connection = connection self.chunkSize = chunkSize self.operationTimeout = operationTimeout self.leaseState = leaseState @@ -24,32 +159,70 @@ struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { func realPath(atPath path: String) async throws -> String { try await withOperationTimeout { - try await sftp.getRealPath(atPath: path) + try await connection.remuxRealPath(atPath: path) + } + } + + func listDirectory(atPath path: String) async throws -> [RemuxSFTPDirectoryEntry] { + do { + let responses = try await withOperationTimeout { + try await connection.remuxListDirectory(atPath: path) + } + return responses + .flatMap(\.components) + .filter { $0.filename != "." && $0.filename != ".." } + .map { component in + RemuxSFTPDirectoryEntry( + name: component.filename, + metadata: metadata(from: component.attributes) + ) + } + } catch { + throw normalizedReadError(error, path: path) } } func metadata(atPath path: String) async throws -> RemuxSFTPFileMetadata { - let attributes = try await getAttributes(at: path) - return RemuxSFTPFileMetadata( - size: attributes.size, - permissions: attributes.permissions, - modificationDate: attributes.accessModificationTime?.modificationTime - ) + do { + return metadata(from: try await getAttributes(at: path)) + } catch { + throw normalizedReadError(error, path: path) + } + } + + func linkMetadata(atPath path: String) async throws -> RemuxSFTPFileMetadata { + let parentPath = (path as NSString).deletingLastPathComponent + let directoryPath = parentPath.isEmpty ? "." : parentPath + let basename = (path as NSString).lastPathComponent + guard !basename.isEmpty else { + throw RemuxSFTPClientError.noSuchFile(path) + } + guard let entry = try await listDirectory(atPath: directoryPath) + .first(where: { $0.name == basename }) + else { + throw RemuxSFTPClientError.noSuchFile(path) + } + return entry.metadata } func withFile( atPath path: String, _ operation: @Sendable (RemuxSFTPReadableFile) async throws -> ReturnValue ) async throws -> ReturnValue { - let remoteFile = try await openRemoteFile( - at: path, - flags: .read - ) + let remoteFile: any RemuxCitadelSFTPFile + do { + remoteFile = try await openRemoteFile(at: path, mode: .read) + } catch { + throw normalizedReadError(error, path: path) + } let readableFile = RemuxSFTPReadableFile { offset, length in - let buffer = try await withOperationTimeout { - try await remoteFile.file.read(from: offset, length: length) + do { + return try await withOperationTimeout { + try await remoteFile.readData(from: offset, length: length) + } + } catch { + throw normalizedReadError(error, path: path) } - return Data(buffer.readableBytesView) } let operationResult: Result @@ -84,7 +257,7 @@ struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { } catch where isNoSuchFile(error) { do { try await withOperationTimeout { - try await sftp.createDirectory(atPath: path) + try await connection.remuxCreateDirectory(atPath: path) } } catch { if try await exists(atPath: path) { @@ -95,6 +268,16 @@ struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { } } + func createDirectory(atPath path: String) async throws { + do { + try await withOperationTimeout { + try await connection.remuxCreateDirectory(atPath: path) + } + } catch { + throw normalizedWriteError(error) + } + } + func uploadFile( from localURL: URL, to remotePath: String, @@ -105,10 +288,12 @@ struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { try? localFile.close() } - let remoteFile = try await openRemoteFile( - at: remotePath, - flags: [.write, .create, .truncate] - ) + let remoteFile: any RemuxCitadelSFTPFile + do { + remoteFile = try await openRemoteFile(at: remotePath, mode: .write) + } catch { + throw normalizedWriteError(error) + } do { var offset: UInt64 = 0 @@ -118,13 +303,10 @@ struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { let data = try localFile.read(upToCount: chunkSize) ?? Data() guard !data.isEmpty else { break } - var buffer = ByteBufferAllocator().buffer(capacity: data.count) - buffer.writeBytes(data) - let writeBuffer = buffer let writeOffset = offset try await withOperationTimeout { - try await remoteFile.file.writePipelined( - writeBuffer, + try await remoteFile.writeDataPipelined( + data, at: writeOffset, maxInFlight: Self.pipelinedWriteMaxInFlight ) @@ -138,20 +320,51 @@ struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { throw RemuxSFTPClientError.operationTimedOut } catch { try? await closeRemoteFile(remoteFile) - throw error + throw normalizedWriteError(error) } } func renameFile(from temporaryPath: String, to finalPath: String) async throws { - try await withOperationTimeout { - try await sftp.rename(at: temporaryPath, to: finalPath) + try await renameItem(from: temporaryPath, to: finalPath) + } + + func renameItem(from sourcePath: String, to destinationPath: String) async throws { + do { + try await withOperationTimeout { + try await connection.remuxRename( + from: sourcePath, + to: destinationPath + ) + } + } catch { + throw normalizedWriteError(error) + } + } + + func removeFile(atPath path: String) async throws { + do { + try await withOperationTimeout { + try await connection.remuxRemove(atPath: path) + } + } catch { + throw normalizedWriteError(error) + } + } + + func removeEmptyDirectory(atPath path: String) async throws { + do { + try await withOperationTimeout { + try await connection.remuxRemoveDirectory(atPath: path) + } + } catch { + throw normalizedWriteError(error) } } func removeFileIfExists(atPath path: String) async throws { do { try await withOperationTimeout { - try await sftp.remove(at: path) + try await connection.remuxRemove(atPath: path) } } catch where isNoSuchFile(error) { return @@ -160,19 +373,25 @@ struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { private func openRemoteFile( at remotePath: String, - flags: SFTPOpenFileFlags - ) async throws -> RemuxCitadelSFTPFileBox { + mode: RemuxCitadelSFTPOpenMode + ) async throws -> any RemuxCitadelSFTPFile { try await withOperationTimeout( operation: { - let file = try await sftp.openFile( - filePath: remotePath, - flags: flags - ) - return RemuxCitadelSFTPFileBox(file: file) + switch mode { + case .read: + return try await connection.remuxOpenFileForReading( + atPath: remotePath + ) + case .write: + return try await connection.remuxOpenFileForWriting( + atPath: remotePath, + flags: [.write, .create, .forceCreate] + ) + } }, - cleanupLateSuccess: { fileBox in + cleanupLateSuccess: { file in do { - try await fileBox.file.close() + try await file.close() } catch { NSLog( "Remux SFTP late file open close failed for %@: %@", @@ -184,9 +403,9 @@ struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { ) } - private func closeRemoteFile(_ fileBox: RemuxCitadelSFTPFileBox) async throws { + private func closeRemoteFile(_ file: any RemuxCitadelSFTPFile) async throws { try await withOperationTimeout { - try await fileBox.file.close() + try await file.close() } } @@ -199,12 +418,22 @@ struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { } } - private func getAttributes(at path: String) async throws -> SFTPFileAttributes { + private func getAttributes(at path: String) async throws -> RemuxCitadelSFTPAttributes { try await withOperationTimeout { - try await sftp.getAttributes(at: path) + try await connection.remuxGetAttributes(atPath: path) } } + private func metadata( + from attributes: RemuxCitadelSFTPAttributes + ) -> RemuxSFTPFileMetadata { + RemuxSFTPFileMetadata( + size: attributes.size, + permissions: attributes.permissions, + modificationDate: attributes.modificationDate + ) + } + private func withOperationTimeout( operation: @escaping @Sendable () async throws -> Value, cleanupLateSuccess: @escaping @Sendable (Value) async -> Void = { _ in } @@ -229,19 +458,83 @@ struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { } private func isNoSuchFile(_ error: Error) -> Bool { + if case .noSuchFile = error as? RemuxSFTPClientError { + return true + } guard let status = error as? SFTPMessage.Status else { return false } return status.errorCode == .noSuchFile } + + private func normalizedReadError(_ error: Error, path: String) -> Error { + isNoSuchFile(error) ? RemuxSFTPClientError.noSuchFile(path) : error + } + + static func normalizedWriteError(for statusCode: SFTPStatusCode) -> RemuxSFTPClientError? { + switch statusCode { + case .permissionDenied: + return .permissionDenied + case .failure: + return .unsupportedMutation + default: + return nil + } + } + + private func normalizedWriteError(_ error: Error) -> Error { + let statusCode: SFTPStatusCode? + if let status = error as? SFTPMessage.Status { + statusCode = status.errorCode + } else if case .errorStatus(let status) = error as? SFTPError { + statusCode = status.errorCode + } else { + statusCode = nil + } + + guard let statusCode, + let normalizedError = Self.normalizedWriteError(for: statusCode) + else { + return error + } + return normalizedError + } +} + +private enum RemuxCitadelSFTPOpenMode: Sendable { + case read + case write } -private final class RemuxCitadelSFTPFileBox: @unchecked Sendable { - let file: SFTPFile +private final class RemuxCitadelSFTPFileBox: RemuxCitadelSFTPFile, @unchecked Sendable { + private let file: SFTPFile init(file: SFTPFile) { self.file = file } + + func readData(from offset: UInt64, length: UInt32) async throws -> Data { + let buffer = try await file.read(from: offset, length: length) + return Data(buffer.readableBytesView) + } + + func writeDataPipelined( + _ data: Data, + at offset: UInt64, + maxInFlight: Int + ) async throws { + var buffer = ByteBufferAllocator().buffer(capacity: data.count) + buffer.writeBytes(data) + try await file.writePipelined( + buffer, + at: offset, + maxInFlight: maxInFlight + ) + } + + func close() async throws { + try await file.close() + } } enum RemuxSFTPChildDrain: Equatable, Sendable { @@ -416,12 +709,14 @@ struct RemuxSessionCitadelSFTPClientProvider: RemuxSFTPClientProvider { ) async throws -> RemuxSFTPClientLease { let registration = try await scope.begin() do { +#if !REMUX_FILE_PROVIDER_EXTENSION let startedAt = GhosttyRuntimeTrace.latencyEnabled ? GhosttyRuntimeTrace.nowNanos() : nil GhosttyRuntimeTrace.latency( "sftp.open begin host=\(hostDescription) source=session" ) +#endif // Citadel bounds subsystem negotiation internally. Await the raw // open so shutdown cannot release a shared root while a child is @@ -429,11 +724,13 @@ struct RemuxSessionCitadelSFTPClientProvider: RemuxSFTPClientProvider { let sftp = try await SFTPClient.open( overAuthenticatedSSHChannel: registration.rootChannel ) +#if !REMUX_FILE_PROVIDER_EXTENSION if let startedAt { GhosttyRuntimeTrace.latency( "sftp.open end host=\(hostDescription) source=session elapsed_ms=\(GhosttyRuntimeTrace.elapsedMilliseconds(from: startedAt))" ) } +#endif let teardown = RemuxSFTPLeaseTeardown( closeBorrowedChild: { @@ -553,8 +850,10 @@ struct RemuxCitadelSFTPClientProvider: RemuxSFTPClientProvider { } do { +#if !REMUX_FILE_PROVIDER_EXTENSION let sftpOpenStartedAt = GhosttyRuntimeTrace.latencyEnabled ? GhosttyRuntimeTrace.nowNanos() : nil GhosttyRuntimeTrace.latency("sftp.open begin host=\(rootConfiguration.host):\(rootConfiguration.port)") +#endif let sftp = try await RemuxSFTPTimeout.run( timeout: operationTimeout, operation: { @@ -568,11 +867,13 @@ struct RemuxCitadelSFTPClientProvider: RemuxSFTPClientProvider { } } ) +#if !REMUX_FILE_PROVIDER_EXTENSION if let sftpOpenStartedAt { GhosttyRuntimeTrace.latency( "sftp.open end host=\(rootConfiguration.host):\(rootConfiguration.port) elapsed_ms=\(GhosttyRuntimeTrace.elapsedMilliseconds(from: sftpOpenStartedAt))" ) } +#endif let leaseState = RemuxSFTPLeaseTeardown( closeChild: { try await RemuxSFTPTimeout.run( diff --git a/RemuxApp/Sources/SSH/RemuxSFTPClient.swift b/RemuxApp/Sources/SSH/RemuxSFTPClient.swift index 466c01f5..25b494fc 100644 --- a/RemuxApp/Sources/SSH/RemuxSFTPClient.swift +++ b/RemuxApp/Sources/SSH/RemuxSFTPClient.swift @@ -4,6 +4,9 @@ import NIO enum RemuxSFTPClientError: LocalizedError, Equatable, Sendable { case operationTimedOut case sessionUnavailable + case noSuchFile(String) + case permissionDenied + case unsupportedMutation case invalidReadLength(Int) case oversizedReadResult(requested: Int, actual: Int) @@ -13,6 +16,12 @@ enum RemuxSFTPClientError: LocalizedError, Equatable, Sendable { return "The remote file operation timed out." case .sessionUnavailable: return "The terminal session is no longer available." + case .noSuchFile: + return "The remote file does not exist." + case .permissionDenied: + return "You do not have permission to modify the remote file." + case .unsupportedMutation: + return "The remote server does not support this file operation." case .invalidReadLength: return "The remote file read length is invalid." case .oversizedReadResult: @@ -21,10 +30,49 @@ enum RemuxSFTPClientError: LocalizedError, Equatable, Sendable { } } +enum RemuxSFTPFileType: String, Codable, Sendable { + case regular + case directory + case symbolicLink + case other + + init(permissions: UInt32?) { + let typeBits = permissions.map { $0 & 0o170000 } + switch typeBits { + case 0o100000: + self = .regular + case 0o040000: + self = .directory + case 0o120000: + self = .symbolicLink + default: + self = .other + } + } +} + struct RemuxSFTPFileMetadata: Equatable, Sendable { let size: UInt64? let permissions: UInt32? let modificationDate: Date? + let type: RemuxSFTPFileType + + init( + size: UInt64?, + permissions: UInt32?, + modificationDate: Date?, + type: RemuxSFTPFileType? = nil + ) { + self.size = size + self.permissions = permissions + self.modificationDate = modificationDate + self.type = type ?? RemuxSFTPFileType(permissions: permissions) + } +} + +struct RemuxSFTPDirectoryEntry: Equatable, Sendable { + let name: String + let metadata: RemuxSFTPFileMetadata } final class RemuxSFTPReadableFile { @@ -55,12 +103,62 @@ final class RemuxSFTPReadableFile { } protocol RemuxSFTPReadOnlyClient: Sendable { + func realPath(atPath path: String) async throws -> String + func listDirectory(atPath path: String) async throws -> [RemuxSFTPDirectoryEntry] func metadata(atPath path: String) async throws -> RemuxSFTPFileMetadata + func linkMetadata(atPath path: String) async throws -> RemuxSFTPFileMetadata func withFile( atPath path: String, _ operation: @Sendable (RemuxSFTPReadableFile) async throws -> ReturnValue ) async throws -> ReturnValue + + func downloadFile( + atPath remotePath: String, + to localURL: URL, + progress: @escaping @Sendable (Int64) async -> Void + ) async throws +} + +extension RemuxSFTPReadOnlyClient { + func downloadFile( + atPath remotePath: String, + to localURL: URL, + progress: @escaping @Sendable (Int64) async -> Void + ) async throws { + do { + try Data().write(to: localURL, options: .atomic) + let localFile = try FileHandle(forWritingTo: localURL) + defer { + try? localFile.close() + } + + try await withFile(atPath: remotePath) { remoteFile in + var offset: UInt64 = 0 + while true { + try Task.checkCancellation() + let data = try await remoteFile.readChunk( + from: offset, + length: RemuxSFTPReadableFile.maximumChunkLength + ) + try Task.checkCancellation() + guard !data.isEmpty else { break } + + try Task.checkCancellation() + try localFile.write(contentsOf: data) + try Task.checkCancellation() + + offset += UInt64(data.count) + await progress(Int64(min(offset, UInt64(Int64.max)))) + try Task.checkCancellation() + } + } + try localFile.close() + } catch { + try? FileManager.default.removeItem(at: localURL) + throw error + } + } } typealias RemuxSFTPFileUploadProgressHandler = @Sendable (Int64) async -> Void @@ -77,6 +175,18 @@ protocol RemuxSFTPUploadClient: Sendable { func removeFileIfExists(atPath path: String) async throws } +protocol RemuxSFTPFileProviderClient: RemuxSFTPReadOnlyClient { + func createDirectory(atPath path: String) async throws + func uploadFile( + from localURL: URL, + to remotePath: String, + progress: @escaping RemuxSFTPFileUploadProgressHandler + ) async throws + func renameItem(from sourcePath: String, to destinationPath: String) async throws + func removeFile(atPath path: String) async throws + func removeEmptyDirectory(atPath path: String) async throws +} + protocol RemuxSFTPClientProvider: Sendable { associatedtype Client: Sendable diff --git a/RemuxApp/Sources/SSH/RemuxSSHRootService.swift b/RemuxApp/Sources/SSH/RemuxSSHRootService.swift index 2756b665..78fba2d9 100644 --- a/RemuxApp/Sources/SSH/RemuxSSHRootService.swift +++ b/RemuxApp/Sources/SSH/RemuxSSHRootService.swift @@ -13,11 +13,15 @@ struct RemuxSSHRootKey: Hashable, Sendable { private let authFingerprint: String init(target: TmuxConnectionTarget) { - self.serverID = target.server.id - self.host = target.server.host - self.port = target.server.port - self.username = target.sshAuth.username - self.authFingerprint = target.sshAuth.authFingerprint + self.init(server: target.server, auth: target.sshAuth) + } + + init(server: SavedServer, auth: ResolvedSSHAuth) { + self.serverID = server.id + self.host = server.host + self.port = server.port + self.username = auth.username + self.authFingerprint = auth.authFingerprint } } @@ -396,7 +400,9 @@ struct RemuxSSHPreparedRoot { let sshRoot = try await task.value await sshRoot.close() } catch is CancellationError { +#if !REMUX_FILE_PROVIDER_EXTENSION GhosttyRuntimeTrace.latency("transport.prepare.cleanup cancelled") +#endif } catch { NSLog("Remux prepared SSH connection cleanup failed: %@", String(describing: error)) } @@ -1096,10 +1102,14 @@ actor RemuxSSHRootService { Task { do { let connection = try await task.value +#if !REMUX_FILE_PROVIDER_EXTENSION GhosttyRuntimeTrace.latency("sshRoot.pool.close reason=\(reason)") +#endif await connection.close() } catch is CancellationError { +#if !REMUX_FILE_PROVIDER_EXTENSION GhosttyRuntimeTrace.latency("sshRoot.pool.close cancelled reason=\(reason)") +#endif } catch { NSLog("Remux SSH root pool close failed (%@): %@", reason, String(describing: error)) } diff --git a/RemuxApp/Sources/SSH/RemuxTransportStartupTrace.swift b/RemuxApp/Sources/SSH/RemuxTransportStartupTrace.swift new file mode 100644 index 00000000..66133fea --- /dev/null +++ b/RemuxApp/Sources/SSH/RemuxTransportStartupTrace.swift @@ -0,0 +1,102 @@ +import Foundation + +#if REMUX_FILE_PROVIDER_EXTENSION +struct RemuxTransportStartupTrace: Sendable { + init(flowID: String?, startedAt: UInt64 = 0) {} + + func event( + _ name: String, + fields: [String: String] = [:], + at timestamp: UInt64 = 0 + ) {} + + func stage( + _ name: String, + fields: [String: String] = [:], + operation: () async throws -> T + ) async throws -> T { + try await operation() + } +} +#else +struct RemuxTransportStartupTrace: Sendable { + private let flowID: String? + private let startedAt: UInt64 + + init(flowID: String?, startedAt: UInt64 = GhosttyRuntimeTrace.nowNanos()) { + self.flowID = flowID + self.startedAt = startedAt + } + + func event( + _ name: String, + fields: [String: String] = [:], + at timestamp: UInt64 = GhosttyRuntimeTrace.nowNanos() + ) { + GhosttyRuntimeTrace.latency( + "transport.startup.\(name) since_ms=\(GhosttyRuntimeTrace.elapsedMilliseconds(from: startedAt, to: timestamp))\(latencyFields(fields))" + ) + + if let flowID { + GhosttyRuntimeTrace.flowEventIfActive( + flowID, + event: "transport.startup.\(name)", + fields: fields, + at: timestamp + ) + } + } + + func stage( + _ name: String, + fields: [String: String] = [:], + operation: () async throws -> T + ) async throws -> T { + let stageStart = GhosttyRuntimeTrace.nowNanos() + event("\(name).begin", fields: fields, at: stageStart) + + do { + let result = try await operation() + let finishedAt = GhosttyRuntimeTrace.nowNanos() + event( + "\(name).end", + fields: stageFields(fields, stageStart: stageStart, finishedAt: finishedAt), + at: finishedAt + ) + return result + } catch { + let failedAt = GhosttyRuntimeTrace.nowNanos() + var failureFields = stageFields(fields, stageStart: stageStart, finishedAt: failedAt) + failureFields["error"] = String(describing: error) + event("\(name).failed", fields: failureFields, at: failedAt) + throw error + } + } + + private func stageFields( + _ fields: [String: String], + stageStart: UInt64, + finishedAt: UInt64 + ) -> [String: String] { + var stageFields = fields + stageFields["elapsed_ms"] = GhosttyRuntimeTrace.elapsedMilliseconds(from: stageStart, to: finishedAt) + return stageFields + } + + private func latencyFields(_ fields: [String: String]) -> String { + guard !fields.isEmpty else { return "" } + + return " " + fields + .sorted(by: { $0.key < $1.key }) + .map { key, value in "\(key)=\(sanitizeLatencyField(value))" } + .joined(separator: " ") + } + + private func sanitizeLatencyField(_ value: String) -> String { + value + .replacingOccurrences(of: " ", with: "_") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + } +} +#endif diff --git a/RemuxApp/Sources/Tmux/GhosttyRuntimeTrace.swift b/RemuxApp/Sources/Tmux/GhosttyRuntimeTrace.swift index 51c22086..3e3e86d8 100644 --- a/RemuxApp/Sources/Tmux/GhosttyRuntimeTrace.swift +++ b/RemuxApp/Sources/Tmux/GhosttyRuntimeTrace.swift @@ -279,87 +279,6 @@ enum GhosttyRuntimeTrace { } } -struct RemuxTransportStartupTrace: Sendable { - private let flowID: String? - private let startedAt: UInt64 - - init(flowID: String?, startedAt: UInt64 = GhosttyRuntimeTrace.nowNanos()) { - self.flowID = flowID - self.startedAt = startedAt - } - - func event( - _ name: String, - fields: [String: String] = [:], - at timestamp: UInt64 = GhosttyRuntimeTrace.nowNanos() - ) { - GhosttyRuntimeTrace.latency( - "transport.startup.\(name) since_ms=\(GhosttyRuntimeTrace.elapsedMilliseconds(from: startedAt, to: timestamp))\(latencyFields(fields))" - ) - - if let flowID { - GhosttyRuntimeTrace.flowEventIfActive( - flowID, - event: "transport.startup.\(name)", - fields: fields, - at: timestamp - ) - } - } - - func stage( - _ name: String, - fields: [String: String] = [:], - operation: () async throws -> T - ) async throws -> T { - let stageStart = GhosttyRuntimeTrace.nowNanos() - event("\(name).begin", fields: fields, at: stageStart) - - do { - let result = try await operation() - let finishedAt = GhosttyRuntimeTrace.nowNanos() - event( - "\(name).end", - fields: stageFields(fields, stageStart: stageStart, finishedAt: finishedAt), - at: finishedAt - ) - return result - } catch { - let failedAt = GhosttyRuntimeTrace.nowNanos() - var failureFields = stageFields(fields, stageStart: stageStart, finishedAt: failedAt) - failureFields["error"] = String(describing: error) - event("\(name).failed", fields: failureFields, at: failedAt) - throw error - } - } - - private func stageFields( - _ fields: [String: String], - stageStart: UInt64, - finishedAt: UInt64 - ) -> [String: String] { - var stageFields = fields - stageFields["elapsed_ms"] = GhosttyRuntimeTrace.elapsedMilliseconds(from: stageStart, to: finishedAt) - return stageFields - } - - private func latencyFields(_ fields: [String: String]) -> String { - guard !fields.isEmpty else { return "" } - - return " " + fields - .sorted(by: { $0.key < $1.key }) - .map { key, value in "\(key)=\(sanitizeLatencyField(value))" } - .joined(separator: " ") - } - - private func sanitizeLatencyField(_ value: String) -> String { - value - .replacingOccurrences(of: " ", with: "_") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - } -} - enum GhosttyTmuxActionTrace { enum Action: Equatable, Sendable { case newWindow diff --git a/RemuxAppTests/FileProviderDomainOperationCoordinatorTests.swift b/RemuxAppTests/FileProviderDomainOperationCoordinatorTests.swift new file mode 100644 index 00000000..11b0905c --- /dev/null +++ b/RemuxAppTests/FileProviderDomainOperationCoordinatorTests.swift @@ -0,0 +1,258 @@ +import XCTest + +@testable import Remux + +final class FileProviderDomainOperationCoordinatorTests: XCTestCase { + func testOperationsRunInFirstInFirstOutOrder() async throws { + let coordinator = FileProviderDomainOperationCoordinator() + let events = FileProviderTestEventRecorder() + let firstGate = FileProviderBlockingGate() + + let first = Task { + try await coordinator.perform { + await events.record("first-start") + await firstGate.wait() + await events.record("first-end") + return 1 + } + } + await firstGate.waitUntilEntered() + + let secondSubmitter = FileProviderOperationSubmitter() + let second = Task { + try await secondSubmitter.submit(to: coordinator) { + await events.record("second") + return 2 + } + } + await secondSubmitter.waitUntilSubmitted() + + let thirdSubmitter = FileProviderOperationSubmitter() + let third = Task { + try await thirdSubmitter.submit(to: coordinator) { + await events.record("third") + return 3 + } + } + await thirdSubmitter.waitUntilSubmitted() + + await firstGate.release() + _ = try await (first.value, second.value, third.value) + + let completedEvents = await events.values() + XCTAssertEqual( + completedEvents, + ["first-start", "first-end", "second", "third"] + ) + } + + func testPerformReturnsTheOperationValue() async throws { + let coordinator = FileProviderDomainOperationCoordinator() + + let value = try await coordinator.perform { + "value" + } + + XCTAssertEqual(value, "value") + } + + func testFailureReleasesTheNextOperation() async throws { + let coordinator = FileProviderDomainOperationCoordinator() + let events = FileProviderTestEventRecorder() + let firstGate = FileProviderBlockingGate() + + let first = Task { + try await coordinator.perform { () -> Int in + await events.record("first") + await firstGate.wait() + throw FileProviderCoordinatorTestError.expected + } + } + await firstGate.waitUntilEntered() + + let secondSubmitter = FileProviderOperationSubmitter() + let second = Task { + try await secondSubmitter.submit(to: coordinator) { + await events.record("second") + return 2 + } + } + + await secondSubmitter.waitUntilSubmitted() + await firstGate.release() + await XCTAssertThrowsCoordinatorTestErrorAsync { try await first.value } + let secondValue = try await second.value + + XCTAssertEqual(secondValue, 2) + let completedEvents = await events.values() + XCTAssertEqual(completedEvents, ["first", "second"]) + } + + func testCancelledQueuedOperationNeverStarts() async throws { + let coordinator = FileProviderDomainOperationCoordinator() + let events = FileProviderTestEventRecorder() + let activeGate = FileProviderBlockingGate() + + let active = Task { + try await coordinator.perform { + await events.record("active") + await activeGate.wait() + return 1 + } + } + await activeGate.waitUntilEntered() + + let cancelledSubmitter = FileProviderOperationSubmitter() + let cancelled = Task { + try await cancelledSubmitter.submit(to: coordinator) { + await events.record("cancelled") + return 2 + } + } + await cancelledSubmitter.waitUntilSubmitted() + cancelled.cancel() + + await XCTAssertThrowsCancellationAsync { try await cancelled.value } + await activeGate.release() + _ = try await active.value + + let eventsAfterCancellation = await events.values() + XCTAssertEqual(eventsAfterCancellation, ["active"]) + } + + func testCancellingActiveOperationCancelsItsBody() async throws { + let coordinator = FileProviderDomainOperationCoordinator() + let events = FileProviderTestEventRecorder() + let cancellation = FileProviderCancellationRecorder() + + let operation = Task { + try await coordinator.perform { + await events.record("started") + return try await withTaskCancellationHandler { + try await Task.sleep(for: .seconds(60)) + return 1 + } onCancel: { + Task { await cancellation.record() } + } + } + } + await events.waitUntilRecorded("started") + + operation.cancel() + await cancellation.wait() + await XCTAssertThrowsCancellationAsync { try await operation.value } + } +} + +private enum FileProviderCoordinatorTestError: Error { + case expected +} + +private actor FileProviderTestEventRecorder { + private var events: [String] = [] + private var recordWaiters: [String: [CheckedContinuation]] = [:] + + func record(_ event: String) { + events.append(event) + recordWaiters.removeValue(forKey: event)?.forEach { $0.resume() } + } + + func values() -> [String] { + events + } + + func waitUntilRecorded(_ event: String) async { + guard !events.contains(event) else { return } + await withCheckedContinuation { + recordWaiters[event, default: []].append($0) + } + } +} + +private actor FileProviderBlockingGate { + private var entered = false + private var released = false + private var enteredWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func wait() async { + entered = true + enteredWaiters.forEach { $0.resume() } + enteredWaiters.removeAll() + guard !released else { return } + await withCheckedContinuation { releaseWaiters.append($0) } + } + + func waitUntilEntered() async { + guard !entered else { return } + await withCheckedContinuation { enteredWaiters.append($0) } + } + + func release() { + released = true + releaseWaiters.forEach { $0.resume() } + releaseWaiters.removeAll() + } +} + +private actor FileProviderCancellationRecorder { + private var wasRecorded = false + private var waiters: [CheckedContinuation] = [] + + func record() { + wasRecorded = true + waiters.forEach { $0.resume() } + waiters.removeAll() + } + + func wait() async { + guard !wasRecorded else { return } + await withCheckedContinuation { waiters.append($0) } + } +} + +private actor FileProviderOperationSubmitter { + private var hasSubmitted = false + private var submissionWaiters: [CheckedContinuation] = [] + + func submit( + to coordinator: FileProviderDomainOperationCoordinator, + operation: @escaping @Sendable () async throws -> Value + ) async throws -> Value { + hasSubmitted = true + submissionWaiters.forEach { $0.resume() } + submissionWaiters.removeAll() + return try await coordinator.perform(operation) + } + + func waitUntilSubmitted() async { + guard !hasSubmitted else { return } + await withCheckedContinuation { submissionWaiters.append($0) } + } +} + +private func XCTAssertThrowsCancellationAsync( + _ operation: () async throws -> T, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await operation() + XCTFail("Expected cancellation", file: file, line: line) + } catch { + XCTAssertTrue(error is CancellationError, file: file, line: line) + } +} + +private func XCTAssertThrowsCoordinatorTestErrorAsync( + _ operation: () async throws -> T, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await operation() + XCTFail("Expected coordinator test error", file: file, line: line) + } catch { + XCTAssertEqual(error as? FileProviderCoordinatorTestError, .expected) + } +} diff --git a/RemuxAppTests/FileProviderDomainReconcilerTests.swift b/RemuxAppTests/FileProviderDomainReconcilerTests.swift new file mode 100644 index 00000000..55fb454e --- /dev/null +++ b/RemuxAppTests/FileProviderDomainReconcilerTests.swift @@ -0,0 +1,238 @@ +import XCTest +@testable import Remux + +final class FileProviderDomainReconcilerTests: XCTestCase { + func testOnlyServersWithCredentialAndTrustedHostBecomeDomains() async throws { + let fixture = try await DomainFixture( + passwordServer: .eligible, + keyServer: .missingTrust, + thirdServer: .missingCredential + ) + + try await fixture.reconciler.reconcile() + + let records = await fixture.registry.currentRecords() + XCTAssertEqual(records, [ + FileProviderDomainRecord( + serverID: fixture.passwordServer.id, + displayName: "Password" + ) + ]) + } + + func testReconcileAddsRenamesAndRemovesToMatchDesiredSet() async throws { + let fixture = try await DomainFixture( + passwordServer: .eligible, + keyServer: .missingTrust, + thirdServer: .missingCredential, + existing: [ + .init(serverID: UUID(), displayName: "Removed"), + .init(serverID: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, displayName: "Old Password"), + ] + ) + let reconciler = fixture.reconciler + let registry = fixture.registry + let expectedRecords = fixture.expectedRecords + let removedServerID = fixture.existingRecords[0].serverID + let passwordServerID = fixture.passwordServer.id + + async let firstReconciliation: Void = reconciler.reconcile() + async let secondReconciliation: Void = reconciler.reconcile() + try await firstReconciliation + try await secondReconciliation + + let records = await registry.currentRecords() + let recordsRequestCount = await registry.currentRecordsRequestCount() + let maximumConcurrentMutationCount = await registry.currentMaximumConcurrentMutationCount() + let mutations = await registry.currentMutations() + XCTAssertEqual(records, expectedRecords) + XCTAssertEqual(recordsRequestCount, 1) + XCTAssertEqual(maximumConcurrentMutationCount, 1) + XCTAssertEqual(mutations, [ + .remove(removedServerID), + .remove(passwordServerID), + .add(.init(serverID: passwordServerID, displayName: "Password")), + ]) + } + + func testChangingServerHostRemovesDomainUntilTheNewHostIsTrusted() async throws { + let fixture = try await DomainFixture( + passwordServer: .eligible, + keyServer: .missingTrust, + thirdServer: .missingTrust + ) + try await fixture.reconciler.reconcile() + + var editedServer = fixture.passwordServer + editedServer.host = "replacement.example.test" + try await fixture.profiles.saveServer(editedServer) + try await fixture.reconciler.reconcile() + + let recordsAfterHostChange = try await fixture.registry.records() + XCTAssertEqual(recordsAfterHostChange, []) + + try fixture.trust.replaceIdentities([ + TrustedHostIdentity( + serverID: editedServer.id, + host: editedServer.host, + keyType: "ssh-ed25519", + openSSHPublicKey: "ssh-ed25519 replacement", + trustedAt: Date(timeIntervalSince1970: 2) + ) + ]) + try await fixture.reconciler.reconcile() + + let recordsAfterTrust = try await fixture.registry.records() + XCTAssertEqual( + recordsAfterTrust, + [ + FileProviderDomainRecord( + serverID: editedServer.id, + displayName: editedServer.displayName + ) + ] + ) + } +} + +private final class DomainFixture { + enum Eligibility { + case eligible + case missingTrust + case missingCredential + } + + let passwordServer: SavedServer + let profiles: FileBackedConnectionProfileRepository + let trust: TrustedHostStore + let registry: InMemoryFileProviderDomainRegistry + let reconciler: FileProviderDomainReconciler + let existingRecords: [FileProviderDomainRecord] + let expectedRecords: [FileProviderDomainRecord] + + init( + passwordServer: Eligibility = .eligible, + keyServer: Eligibility = .eligible, + thirdServer: Eligibility = .eligible, + existing: [FileProviderDomainRecord] = [] + ) async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profiles = FileBackedConnectionProfileRepository(rootURL: root) + let credentials = KeychainSSHCredentialStore(service: "dev.remux.tests.\(UUID().uuidString)") + let trust = TrustedHostStore(rootURL: root) + + let servers = [ + Self.server(id: "00000000-0000-0000-0000-000000000001", name: "Password"), + Self.server(id: "00000000-0000-0000-0000-000000000002", name: "Key"), + Self.server(id: "00000000-0000-0000-0000-000000000003", name: "Third"), + ] + let eligibility = [passwordServer, keyServer, thirdServer] + + for (server, eligibility) in zip(servers, eligibility) { + try await profiles.saveServer(server) + + if eligibility != .missingCredential { + try await credentials.saveCredential(.password("secret"), identityID: server.identityID) + } + } + + try trust.replaceIdentities( + zip(servers, eligibility).compactMap { server, eligibility in + guard eligibility != .missingTrust else { return nil } + return TrustedHostIdentity( + serverID: server.id, + host: server.host, + keyType: "ssh-ed25519", + openSSHPublicKey: "ssh-ed25519 \(server.id.uuidString)", + trustedAt: Date(timeIntervalSince1970: 1) + ) + } + ) + + self.passwordServer = servers[0] + self.profiles = profiles + self.trust = trust + self.registry = InMemoryFileProviderDomainRegistry(records: existing) + self.reconciler = FileProviderDomainReconciler( + profiles: profiles, + credentials: credentials, + trust: trust, + registry: registry + ) + self.existingRecords = existing + self.expectedRecords = zip(servers, eligibility).compactMap { server, eligibility in + guard eligibility == .eligible else { return nil } + return .init(serverID: server.id, displayName: server.displayName) + } + } + + private static func server(id: String, name: String) -> SavedServer { + SavedServer( + id: UUID(uuidString: id)!, + displayName: name, + host: "\(name.lowercased()).example.test", + username: "demo", + identityID: UUID(uuidString: id)! + ) + } +} + +private actor InMemoryFileProviderDomainRegistry: FileProviderDomainRegistering { + enum Mutation: Equatable { + case add(FileProviderDomainRecord) + case remove(SavedServer.ID) + } + + private var storedRecords: [FileProviderDomainRecord] + private var recordsRequestCount = 0 + private var mutations: [Mutation] = [] + private var maximumConcurrentMutationCount = 0 + private var activeMutationCount = 0 + + init(records: [FileProviderDomainRecord]) { + self.storedRecords = records + } + + func records() async throws -> [FileProviderDomainRecord] { + recordsRequestCount += 1 + return storedRecords + } + + func currentRecords() -> [FileProviderDomainRecord] { + storedRecords + } + + func currentRecordsRequestCount() -> Int { + recordsRequestCount + } + + func currentMutations() -> [Mutation] { + mutations + } + + func currentMaximumConcurrentMutationCount() -> Int { + maximumConcurrentMutationCount + } + + func add(_ record: FileProviderDomainRecord) async throws { + try await mutate(.add(record)) { + storedRecords.append(record) + } + } + + func remove(serverID: SavedServer.ID) async throws { + try await mutate(.remove(serverID)) { + storedRecords.removeAll { $0.serverID == serverID } + } + } + + private func mutate(_ mutation: Mutation, body: () -> Void) async throws { + activeMutationCount += 1 + maximumConcurrentMutationCount = max(maximumConcurrentMutationCount, activeMutationCount) + try await Task.sleep(for: .milliseconds(10)) + body() + mutations.append(mutation) + activeMutationCount -= 1 + } +} diff --git a/RemuxAppTests/FileProviderErrorMapperTests.swift b/RemuxAppTests/FileProviderErrorMapperTests.swift new file mode 100644 index 00000000..f62b1db2 --- /dev/null +++ b/RemuxAppTests/FileProviderErrorMapperTests.swift @@ -0,0 +1,207 @@ +@preconcurrency import Citadel +import FileProvider +import NIOCore +import XCTest + +@testable import Remux + +final class FileProviderErrorMapperTests: XCTestCase { + func testErrorMapperUsesStableDomainsCodesAndRequestedItem() { + let identifier = NSFileProviderItemIdentifier(rawValue: "p:cmVwb3J0LnR4dA") + let cases: [(Error, String, Int)] = [ + (RemuxSFTPClientError.operationTimedOut, NSFileProviderErrorDomain, NSFileProviderError.serverUnreachable.rawValue), + (RemuxSFTPClientError.sessionUnavailable, NSFileProviderErrorDomain, NSFileProviderError.serverUnreachable.rawValue), + (URLError(.timedOut), NSFileProviderErrorDomain, NSFileProviderError.serverUnreachable.rawValue), + (SSHAuthResolverError.missingCredential(UUID()), NSFileProviderErrorDomain, NSFileProviderError.notAuthenticated.rawValue), + (TrustedHostStoreError.hostKeyTrustRequired(unknownTrustChallenge), NSFileProviderErrorDomain, NSFileProviderError.notAuthenticated.rawValue), + (TrustedHostStoreError.staleHostKeyTrust(host: "server.example.test"), NSFileProviderErrorDomain, NSFileProviderError.notAuthenticated.rawValue), + (RemuxSFTPClientError.noSuchFile("/private/report.txt"), NSFileProviderErrorDomain, NSFileProviderError.noSuchItem.rawValue), + (FileProviderSnapshotStoreError.syncAnchorExpired, NSFileProviderErrorDomain, NSFileProviderError.syncAnchorExpired.rawValue), + ] + + for (error, domain, code) in cases { + let mapped = FileProviderErrorMapper.map(error, itemIdentifier: identifier) + XCTAssertEqual(mapped.domain, domain) + XCTAssertEqual(mapped.code, code) + } + + let missing = FileProviderErrorMapper.map( + RemuxSFTPClientError.noSuchFile("/private/report.txt"), + itemIdentifier: identifier + ) + XCTAssertEqual(missing.userInfo[NSFileProviderErrorItemKey] as? NSFileProviderItemIdentifier, identifier) + } + + func testUnknownOpaqueIdentityMapsToRequestedNoSuchItem() { + let identifier = NSFileProviderItemIdentifier( + rawValue: "i:11111111-2222-3333-4444-555555555555" + ) + + let error = FileProviderErrorMapper.map( + FileProviderSnapshotStoreError.itemIdentityNotFound, + itemIdentifier: identifier + ) + + XCTAssertEqual(error.domain, NSFileProviderErrorDomain) + XCTAssertEqual(error.code, NSFileProviderError.noSuchItem.rawValue) + XCTAssertEqual( + error.userInfo[NSFileProviderErrorNonExistentItemIdentifierKey] + as? NSFileProviderItemIdentifier, + identifier + ) + } + + func testErrorMapperSanitizesUnknownErrorsAndWritePolicy() { + let sentinel = "password=super-secret-private-key-passphrase" + let mapped = FileProviderErrorMapper.map(SecretError(sentinel)) + + XCTAssertEqual(mapped.domain, NSCocoaErrorDomain) + XCTAssertEqual(mapped.code, NSXPCConnectionReplyInvalid) + XCTAssertTrue(mapped.userInfo.isEmpty) + XCTAssertFalse(mapped.localizedDescription.contains(sentinel)) + XCTAssertFalse(String(reflecting: mapped).contains(sentinel)) + + XCTAssertEqual(FileProviderErrorMapper.writePermission.domain, NSCocoaErrorDomain) + XCTAssertEqual(FileProviderErrorMapper.writePermission.code, NSFileWriteNoPermissionError) + } + + func testErrorMapperMapsWritePermissionAndSanitizesUnsupportedMutation() { + let permissionDenied = FileProviderErrorMapper.map( + RemuxSFTPClientError.permissionDenied + ) + XCTAssertEqual(permissionDenied.domain, NSCocoaErrorDomain) + XCTAssertEqual(permissionDenied.code, NSFileWriteNoPermissionError) + + let unsupportedMutation = FileProviderErrorMapper.map( + RemuxSFTPClientError.unsupportedMutation + ) + XCTAssertEqual(unsupportedMutation.domain, NSCocoaErrorDomain) + XCTAssertEqual(unsupportedMutation.code, NSXPCConnectionReplyInvalid) + XCTAssertTrue(unsupportedMutation.userInfo.isEmpty) + } + + func testErrorMapperMapsChannelSessionFailuresToServerUnreachable() { + let errors: [ChannelError] = [ + .connectTimeout(.seconds(1)), + .eof, + .ioOnClosedChannel, + .alreadyClosed, + .inputClosed, + .outputClosed, + ] + + for error in errors { + let mapped = FileProviderErrorMapper.map(error) + XCTAssertEqual(mapped.domain, NSFileProviderErrorDomain) + XCTAssertEqual(mapped.code, NSFileProviderError.serverUnreachable.rawValue) + } + } + + func testErrorMapperMapsConfiguredCitadelAuthenticationFailuresToNotAuthenticated() { + let errors: [SSHClientError] = [ + .allAuthenticationOptionsFailed, + .unsupportedPasswordAuthentication, + .unsupportedPrivateKeyAuthentication, + ] + + for error in errors { + let mapped = FileProviderErrorMapper.map(error) + XCTAssertEqual(mapped.domain, NSFileProviderErrorDomain) + XCTAssertEqual(mapped.code, NSFileProviderError.notAuthenticated.rawValue) + } + } + + func testErrorMapperMapsCancellationWithoutLeakingImplementationDetails() { + let mapped = FileProviderErrorMapper.map(CancellationError()) + + XCTAssertEqual(mapped.domain, NSCocoaErrorDomain) + XCTAssertEqual(mapped.code, NSUserCancelledError) + XCTAssertTrue(mapped.userInfo.isEmpty) + } + + func testMissingItemIdentifierDoesNotProduceKeylessNoSuchItem() { + let mapped = FileProviderErrorMapper.map( + RemuxSFTPClientError.noSuchFile("/private/report.txt") + ) + + XCTAssertEqual(mapped.domain, NSCocoaErrorDomain) + XCTAssertEqual(mapped.code, NSXPCConnectionReplyInvalid) + XCTAssertTrue(mapped.userInfo.isEmpty) + } + + func testErrorMapperProducesWritableMutationErrors() throws { + let item = FileProviderSDKItem( + item: try identifiedItem(), + rootDisplayName: "Fixture" + ) + + let collision = FileProviderErrorMapper.filenameCollision(existingItem: item) + XCTAssertEqual(collision.domain, NSFileProviderErrorDomain) + XCTAssertEqual(collision.code, NSFileProviderError.filenameCollision.rawValue) + XCTAssertEqual( + (collision.userInfo[NSFileProviderErrorItemKey] as? FileProviderSDKItem)?.itemIdentifier, + item.itemIdentifier + ) + + let rejectedDeletion = FileProviderErrorMapper.deletionRejected(updatedItem: item) + XCTAssertEqual(rejectedDeletion.domain, NSFileProviderErrorDomain) + XCTAssertEqual(rejectedDeletion.code, NSFileProviderError.deletionRejected.rawValue) + XCTAssertEqual( + (rejectedDeletion.userInfo[NSFileProviderErrorItemKey] as? FileProviderSDKItem)?.itemIdentifier, + item.itemIdentifier + ) + + XCTAssertEqual(FileProviderErrorMapper.directoryNotEmpty.domain, NSFileProviderErrorDomain) + XCTAssertEqual( + FileProviderErrorMapper.directoryNotEmpty.code, + NSFileProviderError.directoryNotEmpty.rawValue + ) + XCTAssertEqual(FileProviderErrorMapper.cannotSynchronize.domain, NSFileProviderErrorDomain) + XCTAssertEqual( + FileProviderErrorMapper.cannotSynchronize.code, + NSFileProviderError.cannotSynchronize.rawValue + ) + XCTAssertEqual(FileProviderErrorMapper.writePermission.domain, NSCocoaErrorDomain) + XCTAssertEqual(FileProviderErrorMapper.writePermission.code, NSFileWriteNoPermissionError) + } + + private func identifiedItem() throws -> FileProviderIdentifiedItem { + FileProviderIdentifiedItem( + identity: .item(UUID()), + parentIdentity: .root, + remoteItem: try FileProviderRemoteItem( + path: FileProviderRemotePath(relative: "report.txt"), + metadata: RemuxSFTPFileMetadata( + size: 1, + permissions: 0o100644, + modificationDate: Date(timeIntervalSince1970: 1), + type: .regular + ) + ) + ) + } + + private var unknownTrustChallenge: SSHHostKeyTrustChallenge { + SSHHostKeyTrustChallenge( + kind: .unknown, + serverID: UUID(), + host: "server.example.test", + trustedKeyType: nil, + trustedOpenSSHPublicKey: nil, + receivedKeyType: "ssh-ed25519", + receivedOpenSSHPublicKey: "ssh-ed25519 public-key" + ) + } +} + +private struct SecretError: LocalizedError { + let secret: String + + init(_ secret: String) { + self.secret = secret + } + + var errorDescription: String? { + secret + } +} diff --git a/RemuxAppTests/FileProviderMutationCoreTests.swift b/RemuxAppTests/FileProviderMutationCoreTests.swift new file mode 100644 index 00000000..0ed23488 --- /dev/null +++ b/RemuxAppTests/FileProviderMutationCoreTests.swift @@ -0,0 +1,404 @@ +import FileProvider +import Foundation +import XCTest +@testable import Remux + +final class FileProviderMutationCoreTests: XCTestCase { + func testCreateFileUploadsTemporarySiblingThenRenamesAndCommitsReceipt() async throws { + let fixture = try MutationFixture() + let localURL = fixture.localFile(contents: Data("hello".utf8)) + let request = fixture.createRequest( + template: "template-1", + parent: .rootContainer, + filename: "report.txt", + type: .regular, + contentsURL: localURL + ) + + let result = try await fixture.core.create(request: request) { bytes in + await fixture.progress.record(bytes) + } + + let mutations = await fixture.remote.mutations() + XCTAssertEqual(mutations, [ + .upload(localURL, "/home/me/.remux-upload-\(fixture.nonce)"), + .rename("/home/me/.remux-upload-\(fixture.nonce)", "/home/me/report.txt"), + ]) + XCTAssertEqual(result.item.remoteItem.path.relative, "report.txt") + let receipt = try await fixture.snapshots.receipt(for: .create(templateIdentifier: "template-1")) + XCTAssertEqual( + receipt, + .item(key: .create(templateIdentifier: "template-1"), item: result.item) + ) + } + + func testCreateDirectoryUsesStrictMkdirAndReturnsAuthoritativeItem() async throws { + let fixture = try MutationFixture() + + let result = try await fixture.core.create( + request: fixture.createRequest( + template: "template-dir", + parent: .rootContainer, + filename: "notes", + type: .directory, + contentsURL: nil + ) + ) { _ in } + + let mutations = await fixture.remote.mutations() + XCTAssertEqual(mutations, [.mkdir("/home/me/notes")]) + XCTAssertEqual(result.item.remoteItem.type, .directory) + } + + func testCreateRejectsExistingDestinationWithoutMutation() async throws { + let fixture = try await MutationFixture.withFile(path: "report.txt") + await assertThrows({ + try await fixture.core.create( + request: fixture.createRequest( + template: "collision", + parent: .rootContainer, + filename: "report.txt", + type: .regular, + contentsURL: fixture.localFile(contents: Data()) + ) + ) { _ in } + }) { error in + guard case .collision(let existing) = error as? FileProviderCreateMutationError else { + XCTFail("Expected collision carrying the existing item") + return + } + XCTAssertEqual(existing.remoteItem.path.relative, "report.txt") + } + let mutations = await fixture.remote.mutations() + XCTAssertTrue(mutations.isEmpty) + } + + func testCreateEmptyFileUploadsZeroByteTemporarySibling() async throws { + let fixture = try MutationFixture() + _ = try await fixture.core.create( + request: fixture.createRequest( + template: "empty", + parent: .rootContainer, + filename: "empty.txt", + type: .regular, + contentsURL: nil + ) + ) { _ in } + let contents = await fixture.remote.contents(path: "/home/me/empty.txt") + XCTAssertEqual(contents, Data()) + } + + func testCreateReplayReturnsRecordedIdentityWithoutRemoteCalls() async throws { + let fixture = try MutationFixture() + let request = fixture.createRequest( + template: "replay", + parent: .rootContainer, + filename: "report.txt", + type: .regular, + contentsURL: fixture.localFile(contents: Data("one".utf8)) + ) + let first = try await fixture.core.create(request: request) { _ in } + let mutations = await fixture.remote.mutations() + let replay = try await fixture.core.create(request: request) { _ in } + XCTAssertEqual(replay.item.identity, first.item.identity) + let replayMutations = await fixture.remote.mutations() + XCTAssertEqual(replayMutations, mutations) + } + + func testCreateMayAlreadyExistWithoutContentsRequiresRecordedAlias() async throws { + let fixture = try MutationFixture() + let request = fixture.createRequest( + template: "alias", + parent: .rootContainer, + filename: "report.txt", + type: .regular, + contentsURL: nil + ) + let created = try await fixture.core.create(request: request) { _ in } + let replay = try await fixture.core.create(request: request) { _ in } + XCTAssertEqual(replay.item.identity, created.item.identity) + XCTAssertEqual(replay.item.remoteItem.path.relative, "report.txt") + } + + func testCreateRejectsSymlinkWithoutRemoteCalls() async throws { + let fixture = try MutationFixture() + await assertThrows({ + try await fixture.core.create( + request: fixture.createRequest( + template: "link", + parent: .rootContainer, + filename: "link", + type: .symbolicLink, + contentsURL: nil + ) + ) { _ in } + }) { error in + XCTAssertEqual(error as? FileProviderMutationValidationError, .symbolicLinkMutation) + } + let mutations = await fixture.remote.mutations() + XCTAssertTrue(mutations.isEmpty) + } + + func testCreateUploadFailureRemovesExactTemporarySibling() async throws { + let fixture = try MutationFixture() + await fixture.remote.failNextUpload() + await assertThrows { + try await fixture.core.create( + request: fixture.createRequest(template: "upload-fail", parent: .rootContainer, filename: "report.txt", type: .regular, contentsURL: fixture.localFile(contents: Data("hello".utf8))) + ) { _ in } + } + let mutations = await fixture.remote.mutations() + let exists = await fixture.remote.exists("/home/me/report.txt") + XCTAssertEqual(mutations.last, .removeFile("/home/me/.remux-upload-\(fixture.nonce)")) + XCTAssertFalse(exists) + } + + func testCreateRenameFailureRemovesTemporarySiblingAndLeavesDestination() async throws { + let fixture = try MutationFixture() + await fixture.remote.failNextRename() + await assertThrows { + try await fixture.core.create( + request: fixture.createRequest(template: "rename-fail", parent: .rootContainer, filename: "report.txt", type: .regular, contentsURL: fixture.localFile(contents: Data("hello".utf8))) + ) { _ in } + } + let mutations = await fixture.remote.mutations() + let exists = await fixture.remote.exists("/home/me/report.txt") + XCTAssertEqual(mutations.last, .removeFile("/home/me/.remux-upload-\(fixture.nonce)")) + XCTAssertFalse(exists) + } + + func testCreateRenameFailureWaitsForExactTemporaryCleanup() async throws { + let fixture = try MutationFixture() + await fixture.remote.failNextRename() + await fixture.remote.blockRemoval() + let task = Task { + try await fixture.core.create(request: fixture.createRequest(template: "rename-cleanup-gate", parent: .rootContainer, filename: "report.txt", type: .regular, contentsURL: fixture.localFile(contents: Data("hello".utf8)))) { _ in } + } + await fixture.remote.waitUntilRemovalBlocked() + await fixture.remote.releaseRemoval() + await assertThrows { try await task.value } + let temporaryExists = await fixture.remote.exists("/home/me/.remux-upload-\(fixture.nonce)") + let destinationExists = await fixture.remote.exists("/home/me/report.txt") + XCTAssertFalse(temporaryExists) + XCTAssertFalse(destinationExists) + } + + func testCreateReportsCumulativeProgressAndCancellationBeforeRenameDoesNotCommit() async throws { + let fixture = try MutationFixture() + await fixture.remote.blockRename() + let task = Task { + try await fixture.core.create( + request: fixture.createRequest(template: "cancel-before", parent: .rootContainer, filename: "report.txt", type: .regular, contentsURL: fixture.localFile(contents: Data("hello".utf8))) + ) { bytes in + await fixture.progress.record(bytes) + } + } + await fixture.remote.waitUntilRenameBlocked() + task.cancel() + await Task.yield() + await fixture.remote.releaseRename() + await assertThrows { try await task.value } + let progress = await fixture.progress.values() + let exists = await fixture.remote.exists("/home/me/report.txt") + let receipt = try await fixture.snapshots.receipt(for: .create(templateIdentifier: "cancel-before")) + XCTAssertEqual(progress, [0, 5]) + XCTAssertFalse(exists) + XCTAssertNil(receipt) + } + + func testCreateCancellationAfterRenameCommitsAuthoritativeSnapshotAndReceipt() async throws { + let fixture = try MutationFixture() + await fixture.remote.blockItemReadAfterRename() + let task = Task { + try await fixture.core.create( + request: fixture.createRequest(template: "cancel-after", parent: .rootContainer, filename: "report.txt", type: .regular, contentsURL: fixture.localFile(contents: Data("hello".utf8))) + ) { _ in } + } + await fixture.remote.waitUntilItemReadBlocked() + task.cancel() + await fixture.remote.releaseItemRead() + let result = try await task.value + let exists = await fixture.remote.exists("/home/me/report.txt") + let receipt = try await fixture.snapshots.receipt(for: .create(templateIdentifier: "cancel-after")) + let path = try await fixture.snapshots.path(for: result.item.itemIdentifier) + XCTAssertTrue(exists) + XCTAssertEqual(receipt, .item(key: .create(templateIdentifier: "cancel-after"), item: result.item)) + XCTAssertEqual(path.relative, "report.txt") + } + +} + +private actor MutationGate { + private var started = false + private var released = false + private var startedWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + func beginAndWait() async { started = true; let waiters = startedWaiters; startedWaiters.removeAll(); waiters.forEach { $0.resume() }; guard !released else { return }; await withCheckedContinuation { releaseWaiters.append($0) } } + func waitUntilStarted() async { guard !started else { return }; await withCheckedContinuation { startedWaiters.append($0) } } + func release() { released = true; let waiters = releaseWaiters; releaseWaiters.removeAll(); waiters.forEach { $0.resume() } } +} + +private func assertThrows( + _ operation: () async throws -> Value, + file: StaticString = #filePath, + line: UInt = #line, + _ verify: (Error) -> Void = { _ in } +) async { + do { + _ = try await operation() + XCTFail("Expected operation to throw", file: file, line: line) + } catch { + verify(error) + } +} + +private final class MutationFixture: @unchecked Sendable { + let core: FileProviderMutationCore + let snapshots: FileProviderSnapshotStore + let remote: FileProviderMutableRemoteService + let progress = FileProviderTestProgressRecorder() + let nonce = "11111111-2222-3333-4444-555555555555" + private let root: URL + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + snapshots = FileProviderSnapshotStore(rootURL: root, identityGenerator: { UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! }) + remote = FileProviderMutableRemoteService() + core = FileProviderMutationCore( + remote: remote, + snapshots: snapshots, + coordinator: FileProviderDomainOperationCoordinator(), + nonce: { UUID(uuidString: "11111111-2222-3333-4444-555555555555")! } + ) + } + + static func withFile(path: String, contents: Data = Data("contents".utf8)) async throws -> MutationFixture { + let fixture = try MutationFixture() + await fixture.remote.seed(path: path, type: .regular, contents: contents) + try await fixture.recordRoot() + return fixture + } + + static func withEmptyDirectory(path: String) async throws -> MutationFixture { + try await withDirectory(path: path, children: []) + } + + static func withDirectory(path: String, children: [String]) async throws -> MutationFixture { + let fixture = try MutationFixture() + await fixture.remote.seed(path: path, type: .directory, contents: Data()) + for child in children { + await fixture.remote.seed(path: "\(path)/\(child)", type: .regular, contents: Data("contents".utf8)) + } + try await fixture.recordRoot() + try await fixture.recordDirectory(path) + return fixture + } + + func localFile(contents: Data) -> URL { + let url = root.appendingPathComponent(UUID().uuidString) + try! contents.write(to: url) + return url + } + + func createRequest(template: String, parent: NSFileProviderItemIdentifier, filename: String, type: RemuxSFTPFileType, contentsURL: URL?) -> FileProviderCreateRequest { + FileProviderCreateRequest(templateIdentifier: .init(rawValue: template), parentIdentifier: parent, filename: filename, type: type, fields: [.filename, .parentItemIdentifier, .contents], contentsURL: contentsURL, options: []) + } + + func modifyRequest(item: FileProviderIdentifiedItem, parent: NSFileProviderItemIdentifier? = nil, filename: String? = nil, contentsURL: URL? = nil, changedFields: NSFileProviderItemFields) -> FileProviderModifyRequest { fatalError("Task 7 only") } + func deleteRequest(item: FileProviderIdentifiedItem, options: NSFileProviderDeleteItemOptions = []) -> FileProviderDeleteRequest { fatalError("Task 7 only") } + func identifiedItem(path: String) async throws -> FileProviderIdentifiedItem { try await snapshots.item(for: try await identifier(path: path))! } + func identifier(path: String) async throws -> NSFileProviderItemIdentifier { try await identifiedItemForPath(path).itemIdentifier } + + private func identifiedItemForPath(_ path: String) async throws -> FileProviderIdentifiedItem { + let remotePath = try FileProviderRemotePath(relative: path) + let parent = remotePath == .root ? .root : try FileProviderRemotePath(relative: remotePath.relative.split(separator: "/").dropLast().joined(separator: "/")) + let items = try await snapshots.items(directory: parent) + return try XCTUnwrap(items.first { $0.remoteItem.path == remotePath }) + } + + private func recordRoot() async throws { _ = try await snapshots.record(directory: .root, items: await remote.list(directory: .root)) } + private func recordDirectory(_ path: String) async throws { let directory = try FileProviderRemotePath(relative: path); _ = try await snapshots.record(directory: directory, items: await remote.list(directory: directory)) } +} + +private actor FileProviderTestProgressRecorder { + private var reportedBytes: [Int64] = [] + func record(_ bytes: Int64) { reportedBytes.append(bytes) } + func values() -> [Int64] { reportedBytes } +} + +private actor FileProviderMutableRemoteService: FileProviderRemoteServicing, FileProviderRemoteMutationAccess { + enum Mutation: Equatable { case mkdir(String); case upload(URL, String); case rename(String, String); case removeFile(String); case rmdir(String) } + private var entries: [FileProviderRemotePath: (metadata: RemuxSFTPFileMetadata, contents: Data)] = [:] + private var recordedMutations: [Mutation] = [] + private var uploadFailure = false + private var renameFailure = false + private var renameBlocked = false + private var renameWaiters: [CheckedContinuation] = [] + private var renameStartedWaiters: [CheckedContinuation] = [] + private var itemReadBlocked = false + private var itemReadAfterRename = false + private var itemReadWaiters: [CheckedContinuation] = [] + private var itemReadStartedWaiters: [CheckedContinuation] = [] + private var removalBlocked = false + private var removalWaiters: [CheckedContinuation] = [] + private var removalStartedWaiters: [CheckedContinuation] = [] + private var listBlocked = false + private var listWaiters: [CheckedContinuation] = [] + private var listStartedWaiters: [CheckedContinuation] = [] + + init() { + entries[.root] = ( + RemuxSFTPFileMetadata( + size: nil, + permissions: nil, + modificationDate: Date(timeIntervalSince1970: 1), + type: .directory + ), + Data() + ) + } + + func mutations() -> [Mutation] { recordedMutations } + func listedPaths() -> [String] { entries.keys.map { "/home/me/\($0.relative)" }.sorted() } + func contents(path: String) -> Data? { entries.first { "/home/me/\($0.key.relative)" == path }?.value.contents } + func exists(_ absolutePath: String) -> Bool { contents(path: absolutePath) != nil } + func seed(path: String, type: RemuxSFTPFileType, contents: Data) { let remotePath = try! FileProviderRemotePath(relative: path); entries[remotePath] = (RemuxSFTPFileMetadata(size: type == .regular ? UInt64(contents.count) : nil, permissions: nil, modificationDate: Date(timeIntervalSince1970: 1), type: type), contents) } + func failNextUpload() { uploadFailure = true } + func failNextRename() { renameFailure = true } + func blockRename() { renameBlocked = true } + func releaseRename() { renameBlocked = false; let waiters = renameWaiters; renameWaiters.removeAll(); waiters.forEach { $0.resume() } } + func waitUntilRenameBlocked() async { guard renameStartedWaiters.isEmpty else { return }; await withCheckedContinuation { renameStartedWaiters.append($0) } } + func blockItemReadAfterRename() { itemReadBlocked = true } + func releaseItemRead() { itemReadBlocked = false; let waiters = itemReadWaiters; itemReadWaiters.removeAll(); waiters.forEach { $0.resume() } } + func waitUntilItemReadBlocked() async { guard itemReadStartedWaiters.isEmpty else { return }; await withCheckedContinuation { itemReadStartedWaiters.append($0) } } + func blockRemoval() { removalBlocked = true } + func releaseRemoval() { removalBlocked = false; let waiters = removalWaiters; removalWaiters.removeAll(); waiters.forEach { $0.resume() } } + func waitUntilRemovalBlocked() async { guard removalStartedWaiters.isEmpty else { return }; await withCheckedContinuation { removalStartedWaiters.append($0) } } + func blockNextList() { listBlocked = true } + func releaseList() { listBlocked = false; let waiters = listWaiters; listWaiters.removeAll(); waiters.forEach { $0.resume() } } + func waitUntilListBlocked() async { guard listStartedWaiters.isEmpty else { return }; await withCheckedContinuation { listStartedWaiters.append($0) } } + + func item(at path: FileProviderRemotePath) async throws -> FileProviderRemoteItem { + if itemReadBlocked && itemReadAfterRename { + let waiters = itemReadStartedWaiters + itemReadStartedWaiters.removeAll() + waiters.forEach { $0.resume() } + await withCheckedContinuation { itemReadWaiters.append($0) } + } + guard let entry = entries[path] else { + throw RemuxSFTPClientError.noSuchFile(path.relative) + } + return try FileProviderRemoteItem(path: path, metadata: entry.metadata) + } + func list(directory: FileProviderRemotePath) async throws -> [FileProviderRemoteItem] { let listed: [FileProviderRemoteItem] = try entries.compactMap { path, entry in guard path != .root, try FileProviderRemoteItem(path: path, metadata: entry.metadata).parent == directory else { return nil }; return try FileProviderRemoteItem(path: path, metadata: entry.metadata) }.sorted { $0.path.relative < $1.path.relative }; if listBlocked { listBlocked = false; let waiters = listStartedWaiters; listStartedWaiters.removeAll(); waiters.forEach { $0.resume() }; await withCheckedContinuation { listWaiters.append($0) } }; return listed } + func fetch(path: FileProviderRemotePath, to localURL: URL, progress: @escaping @Sendable (FileProviderRemoteFetchProgress) async -> Void) async throws -> FileProviderRemoteItem { throw RemuxSFTPClientError.unsupportedMutation } + func withMutationAccess(_ operation: @Sendable (any FileProviderRemoteMutationAccess) async throws -> Value) async throws -> Value { try await operation(self) } + func invalidate() {} + func createDirectory(at path: FileProviderRemotePath) throws { try Task.checkCancellation(); guard entries[path] == nil else { throw FileProviderMutationValidationError.destinationOccupied }; entries[path] = (RemuxSFTPFileMetadata(size: nil, permissions: nil, modificationDate: Date(timeIntervalSince1970: 1), type: .directory), Data()); recordedMutations.append(.mkdir("/home/me/\(path.relative)")) } + func uploadFile(from localURL: URL, to path: FileProviderRemotePath, progress: @escaping @Sendable (Int64) async -> Void) async throws { try Task.checkCancellation(); recordedMutations.append(.upload(localURL, "/home/me/\(path.relative)")); await progress(0); if uploadFailure { uploadFailure = false; throw RemuxSFTPClientError.unsupportedMutation }; let data = try Data(contentsOf: localURL); await progress(Int64(data.count)); entries[path] = (RemuxSFTPFileMetadata(size: UInt64(data.count), permissions: nil, modificationDate: Date(timeIntervalSince1970: 1), type: .regular), data) } + func renameItem(from source: FileProviderRemotePath, to destination: FileProviderRemotePath) async throws { let waiters = renameStartedWaiters; renameStartedWaiters.removeAll(); waiters.forEach { $0.resume() }; if renameBlocked { await withCheckedContinuation { renameWaiters.append($0) } }; try Task.checkCancellation(); recordedMutations.append(.rename("/home/me/\(source.relative)", "/home/me/\(destination.relative)")); if renameFailure { renameFailure = false; throw RemuxSFTPClientError.unsupportedMutation }; let moved = entries.filter { $0.key == source || $0.key.relative.hasPrefix(source.relative + "/") }; guard !moved.isEmpty else { throw RemuxSFTPClientError.noSuchFile(source.relative) }; for path in moved.keys { entries.removeValue(forKey: path) }; for (path, entry) in moved { let suffix = path == source ? "" : String(path.relative.dropFirst(source.relative.count)); entries[try FileProviderRemotePath(relative: destination.relative + suffix)] = entry }; itemReadAfterRename = true } + func removeFile(at path: FileProviderRemotePath) async throws { let waiters = removalStartedWaiters; removalStartedWaiters.removeAll(); waiters.forEach { $0.resume() }; if removalBlocked { await withCheckedContinuation { removalWaiters.append($0) } }; entries.removeValue(forKey: path); recordedMutations.append(.removeFile("/home/me/\(path.relative)")) } + func removeEmptyDirectory(at path: FileProviderRemotePath) throws { guard !entries.keys.contains(where: { $0.relative.hasPrefix(path.relative + "/") }) else { throw FileProviderMutationValidationError.destinationOccupied }; entries.removeValue(forKey: path); recordedMutations.append(.rmdir("/home/me/\(path.relative)")) } +} diff --git a/RemuxAppTests/FileProviderMutationValidatorTests.swift b/RemuxAppTests/FileProviderMutationValidatorTests.swift new file mode 100644 index 00000000..e6375c3b --- /dev/null +++ b/RemuxAppTests/FileProviderMutationValidatorTests.swift @@ -0,0 +1,113 @@ +import FileProvider +import XCTest +@testable import Remux + +final class FileProviderMutationValidatorTests: XCTestCase { + private let validator = FileProviderMutationValidator() + + func testValidatorRejectsRemoteVersionConflictWithoutChangingRemote() throws { + let requestedItem = try identifiedItem(size: 1) + let currentItem = try identifiedItem(size: 2) + + let result = validator.validateBaseVersion( + requested: version(for: requestedItem), + current: currentItem + ) + + XCTAssertEqual(result, .conflict(current: currentItem)) + } + + func testValidatorAcceptsMatchingRemoteVersion() throws { + let item = try identifiedItem(size: 1) + + XCTAssertEqual( + validator.validateBaseVersion(requested: version(for: item), current: item), + .matches + ) + } + + func testValidatorRejectsOccupiedDestinationsRegardlessOfCase() throws { + let destination = try FileProviderRemotePath(relative: "folder/Report.txt") + + XCTAssertThrowsError( + try validator.validateDestination(destination, occupiedPaths: [destination]) + ) { error in + XCTAssertEqual(error as? FileProviderMutationValidationError, .destinationOccupied) + } + XCTAssertThrowsError( + try validator.validateDestination( + try FileProviderRemotePath(relative: "folder/report.txt"), + occupiedPaths: [destination] + ) + ) { error in + XCTAssertEqual(error as? FileProviderMutationValidationError, .destinationOccupied) + } + } + + func testValidatorRejectsUnsupportedSpecialFileAndAllSymlinkMutations() throws { + XCTAssertThrowsError(try validator.validateMutation(of: .other)) { error in + XCTAssertEqual(error as? FileProviderMutationValidationError, .unsupportedFileType) + } + XCTAssertThrowsError(try validator.validateMutation(of: .symbolicLink)) { error in + XCTAssertEqual(error as? FileProviderMutationValidationError, .symbolicLinkMutation) + } + } + + func testValidatorRejectsInvalidChildNameAndMissingParent() throws { + XCTAssertThrowsError(try validator.validateChildName("..")) { error in + XCTAssertEqual(error as? FileProviderMutationValidationError, .invalidChildName) + } + XCTAssertThrowsError(try validator.validateParent(exists: false)) { error in + XCTAssertEqual(error as? FileProviderMutationValidationError, .parentNotFound) + } + } + + func testValidatorRejectsRootMutationAndDirectoryContents() throws { + XCTAssertThrowsError(try validator.validateMutablePath(.root)) { error in + XCTAssertEqual(error as? FileProviderMutationValidationError, .rootMutation) + } + XCTAssertThrowsError(try validator.validateContents(supplied: true, for: .directory)) { error in + XCTAssertEqual(error as? FileProviderMutationValidationError, .directoryContents) + } + } + + func testValidatorRejectsDirectoryMoveIntoDescendant() throws { + XCTAssertThrowsError( + try validator.validateMove( + source: FileProviderRemotePath(relative: "folder"), + destination: FileProviderRemotePath(relative: "folder/child/folder"), + sourceType: .directory + ) + ) { error in + XCTAssertEqual(error as? FileProviderMutationValidationError, .directoryCycle) + } + } + + private func identifiedItem( + path: String = "report.txt", + size: UInt64 + ) throws -> FileProviderIdentifiedItem { + FileProviderIdentifiedItem( + identity: .item(UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000001")!), + parentIdentity: .root, + remoteItem: try FileProviderRemoteItem( + path: FileProviderRemotePath(relative: path), + metadata: RemuxSFTPFileMetadata( + size: size, + permissions: 0o100644, + modificationDate: Date(timeIntervalSince1970: TimeInterval(size)), + type: .regular + ) + ) + ) + } + + private func version( + for item: FileProviderIdentifiedItem + ) -> NSFileProviderItemVersion { + NSFileProviderItemVersion( + contentVersion: item.remoteItem.contentVersion, + metadataVersion: item.remoteItem.metadataVersion + ) + } +} diff --git a/RemuxAppTests/FileProviderRemoteItemTests.swift b/RemuxAppTests/FileProviderRemoteItemTests.swift new file mode 100644 index 00000000..343804c5 --- /dev/null +++ b/RemuxAppTests/FileProviderRemoteItemTests.swift @@ -0,0 +1,254 @@ +import FileProvider +import XCTest +@testable import Remux + +final class FileProviderRemoteItemTests: XCTestCase { + private let codec = FileProviderItemIdentifierCodec() + private let resolver = FileProviderSafeLinkResolver() + + func testNormalizationRejectsTraversalAndAcceptsDotfiles() throws { + XCTAssertEqual(try FileProviderRemotePath(relative: ".config/tool").relative, ".config/tool") + XCTAssertThrowsError(try FileProviderRemotePath(relative: "/absolute")) + XCTAssertThrowsError(try FileProviderRemotePath(relative: "../escape")) + XCTAssertThrowsError(try FileProviderRemotePath(relative: "folder/../../escape")) + XCTAssertThrowsError(try FileProviderRemotePath(relative: "folder\0name")) + } + + func testNormalizationCollapsesHarmlessComponentsToOneIdentity() throws { + let normalized = try FileProviderRemotePath(relative: "folder//./document.txt") + + XCTAssertEqual(normalized.relative, "folder/document.txt") + XCTAssertEqual(try FileProviderRemotePath(relative: "folder/document.txt"), normalized) + XCTAssertEqual(try FileProviderRemotePath(relative: "."), .root) + } + + func testRemotePathAppendsOnlyNormalizedRelativePath() throws { + XCTAssertEqual(try FileProviderRemotePath.root.remotePath(beneath: "/home/me"), "/home/me") + XCTAssertEqual( + try FileProviderRemotePath(relative: "資料/a b.txt").remotePath(beneath: "/home/me"), + "/home/me/資料/a b.txt" + ) + } + + func testOpaqueIdentifierRoundTripsIdentityWithoutExposingPath() throws { + let identity = FileProviderItemIdentity.item( + UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + ) + + let identifier = codec.identifier(for: identity) + + XCTAssertEqual(identifier.rawValue, "i:11111111-2222-3333-4444-555555555555") + XCTAssertEqual(try codec.identity(for: identifier), identity) + XCTAssertFalse(identifier.rawValue.contains("report")) + XCTAssertEqual(codec.identifier(for: .root), .rootContainer) + } + + func testOpaqueIdentifierRejectsPathAndMalformedRepresentations() { + XCTAssertThrowsError( + try codec.identity( + for: NSFileProviderItemIdentifier(rawValue: "p:cmVwb3J0LnR4dA") + ) + ) + XCTAssertThrowsError( + try codec.identity( + for: NSFileProviderItemIdentifier(rawValue: "i:not-a-uuid") + ) + ) + } + + func testItemDerivesRootAndNestedParents() throws { + let root = try item(path: .root, type: .directory) + let nested = try item(path: FileProviderRemotePath(relative: "folder/document.txt")) + + XCTAssertEqual(root.name, "") + XCTAssertEqual(root.parent, .root) + XCTAssertEqual(nested.name, "document.txt") + XCTAssertEqual(nested.parent, try FileProviderRemotePath(relative: "folder")) + } + + func testContentVersionChangesForOnlyDesignedFields() throws { + let original = try item(size: 4, modificationDate: 100.9, permissions: 0o100644) + + XCTAssertNotEqual(original.contentVersion, try item(size: 5, modificationDate: 100.9, permissions: 0o100644).contentVersion) + XCTAssertNotEqual(original.contentVersion, try item(type: .directory, size: 4, modificationDate: 100.9, permissions: 0o040644).contentVersion) + XCTAssertNotEqual(original.contentVersion, try item(size: 4, modificationDate: 101, permissions: 0o100644).contentVersion) + XCTAssertEqual(original.contentVersion, try item(size: 4, modificationDate: 100.1, permissions: 0o100600).contentVersion) + XCTAssertEqual( + original.contentVersion, + try item(path: FileProviderRemotePath(relative: "renamed.txt"), size: 4, modificationDate: 100.1, permissions: 0o100644).contentVersion + ) + } + + func testMetadataVersionChangesForMetadataFieldsButNotLinkTarget() throws { + let original = try item(symlinkTargetRelativePath: "safe-target") + + XCTAssertNotEqual(original.metadataVersion, try item(permissions: 0o100600).metadataVersion) + XCTAssertNotEqual( + original.metadataVersion, + try item(path: FileProviderRemotePath(relative: "renamed.txt")).metadataVersion + ) + XCTAssertEqual( + original.metadataVersion, + try item(symlinkTargetRelativePath: "another-safe-target").metadataVersion + ) + } + + func testMetadataVersionIsFixedSizeForLongUnicodeDeepPaths() throws { + let component = String(repeating: "資料", count: 40) + let path = try FileProviderRemotePath( + relative: Array(repeating: component, count: 20).joined(separator: "/") + "/document.txt" + ) + let original = try item(path: path) + + XCTAssertEqual(original.metadataVersion.count, 32) + XCTAssertLessThanOrEqual(original.metadataVersion.count, 128) + XCTAssertEqual(original.metadataVersion, try item(path: path).metadataVersion) + XCTAssertNotEqual(original.metadataVersion, try item(path: path, permissions: 0o100600).metadataVersion) + } + + func testLinkResolverProjectsTargetsRelativeToSymlinkParent() throws { + XCTAssertEqual( + try resolver.resolve( + "/home/me/projects/target", + home: "/home/me", + for: FileProviderRemotePath(relative: "projects/link") + ), + "target" + ) + XCTAssertEqual( + try resolver.resolve( + "/home/me/shared/target", + home: "/home/me", + for: FileProviderRemotePath(relative: "projects/link") + ), + "../shared/target" + ) + XCTAssertEqual( + try resolver.resolve( + "/home/me", + home: "/home/me", + for: FileProviderRemotePath(relative: "link") + ), + "." + ) + XCTAssertEqual( + try resolver.resolve( + "/home/me", + home: "/home/me", + for: FileProviderRemotePath(relative: "projects/link") + ), + ".." + ) + XCTAssertThrowsError( + try resolver.resolve( + "/home/me2/project", + home: "/home/me", + for: FileProviderRemotePath(relative: "projects/link") + ) + ) + } + + func testItemRejectsUnsafeSymlinkTargetsAndAcceptsParentRelativeTarget() throws { + let nestedLink = try FileProviderRemotePath(relative: "projects/link") + + XCTAssertEqual( + try item(path: nestedLink, symlinkTargetRelativePath: "../shared/target").symlinkTargetRelativePath, + "../shared/target" + ) + XCTAssertThrowsError(try item(path: nestedLink, symlinkTargetRelativePath: "/etc/passwd")) + XCTAssertThrowsError(try item(path: nestedLink, symlinkTargetRelativePath: "target\0name")) + XCTAssertThrowsError(try item(path: nestedLink, symlinkTargetRelativePath: "./target")) + XCTAssertThrowsError(try item(path: nestedLink, symlinkTargetRelativePath: "folder//target")) + XCTAssertThrowsError(try item(path: nestedLink, symlinkTargetRelativePath: "folder/../target")) + XCTAssertThrowsError(try item(path: nestedLink, symlinkTargetRelativePath: "../../escape")) + XCTAssertThrowsError( + try item( + path: FileProviderRemotePath(relative: "link"), + symlinkTargetRelativePath: "../escape" + ) + ) + } + + func testItemDecodingRejectsUnsafeSymlinkTarget() throws { + let source = try item( + path: FileProviderRemotePath(relative: "projects/link"), + symlinkTargetRelativePath: "../shared/target" + ) + var payload = try XCTUnwrap( + try JSONSerialization.jsonObject(with: JSONEncoder().encode(source)) as? [String: Any] + ) + payload["symlinkTargetRelativePath"] = "../../escape" + let unsafePayload = try JSONSerialization.data(withJSONObject: payload) + + XCTAssertThrowsError(try JSONDecoder().decode(FileProviderRemoteItem.self, from: unsafePayload)) + } + + func testCapabilitiesMatchWritableTypePolicy() throws { + XCTAssertEqual( + try projection(path: .root, type: .directory).capabilities, + [.allowsReading, .allowsWriting, .allowsContentEnumerating, .allowsAddingSubItems] + ) + XCTAssertEqual( + try projection(path: "folder", type: .directory).capabilities, + [ + .allowsReading, .allowsWriting, .allowsContentEnumerating, + .allowsAddingSubItems, .allowsRenaming, .allowsReparenting, + .allowsDeleting, + ] + ) + XCTAssertEqual( + try projection(path: "file.txt", type: .regular).capabilities, + [.allowsReading, .allowsWriting, .allowsRenaming, .allowsReparenting, .allowsDeleting] + ) + XCTAssertEqual( + try projection(path: "link", type: .symbolicLink).capabilities, + [.allowsReading] + ) + XCTAssertEqual( + try projection(path: "socket", type: .other).capabilities, + [.allowsReading] + ) + } + + private func projection( + path: String, + type: RemuxSFTPFileType + ) throws -> FileProviderItemProjection { + try projection(path: FileProviderRemotePath(relative: path), type: type) + } + + private func projection( + path: FileProviderRemotePath, + type: RemuxSFTPFileType + ) throws -> FileProviderItemProjection { + let remote = try item(path: path, type: type) + return FileProviderItemProjection( + item: FileProviderIdentifiedItem( + identity: path == .root ? .root : .item(UUID()), + parentIdentity: .root, + remoteItem: remote + ), + rootDisplayName: "Fixture" + ) + } + + private func item( + path: FileProviderRemotePath? = nil, + type: RemuxSFTPFileType = .regular, + size: UInt64? = 4, + modificationDate: TimeInterval? = 100, + permissions: UInt32? = 0o100644, + symlinkTargetRelativePath: String? = nil + ) throws -> FileProviderRemoteItem { + try FileProviderRemoteItem( + path: try path ?? FileProviderRemotePath(relative: "document.txt"), + metadata: RemuxSFTPFileMetadata( + size: size, + permissions: permissions, + modificationDate: modificationDate.map(Date.init(timeIntervalSince1970:)), + type: type + ), + symlinkTargetRelativePath: symlinkTargetRelativePath + ) + } +} diff --git a/RemuxAppTests/FileProviderRemoteServiceTests.swift b/RemuxAppTests/FileProviderRemoteServiceTests.swift new file mode 100644 index 00000000..0e697c06 --- /dev/null +++ b/RemuxAppTests/FileProviderRemoteServiceTests.swift @@ -0,0 +1,1049 @@ +import Foundation +import XCTest +@testable import Remux + +final class FileProviderRemoteServiceTests: XCTestCase { + func testServiceListsHomeAndFiltersUnsupportedAndUnsafeLinks() async throws { + let fixture = try await FileProviderRemoteServiceFixture.make() + + let items = try await fixture.service.list(directory: .root) + + XCTAssertEqual(items.map(\.name), [".env", "folder", "safe-link", "file.txt"]) + XCTAssertEqual( + items.first(where: { $0.name == "safe-link" })?.symlinkTargetRelativePath, + "folder/target" + ) + } + + func testServiceLoadsAnItemRelativeToCanonicalHome() async throws { + let fixture = try await FileProviderRemoteServiceFixture.make() + let path = try FileProviderRemotePath(relative: "file.txt") + + let item = try await fixture.service.item(at: path) + + XCTAssertEqual(item.path, path) + XCTAssertEqual(item.name, "file.txt") + XCTAssertEqual(item.type, .regular) + XCTAssertEqual(item.size, 4) + } + + func testServiceLoadsTheCanonicalHomeAsTheRootItem() async throws { + let home = "/home/reader" + let directory = RemuxSFTPFileMetadata( + size: nil, + permissions: 0o040755, + modificationDate: Date(timeIntervalSince1970: 200) + ) + let client = FileProviderTestSFTPClient( + realPaths: [".": .success(home)], + listings: [:], + metadataByPath: [home: directory] + ) + let fixture = try await FileProviderRemoteServiceFixture.make(client: client) + + let item = try await fixture.service.item(at: .root) + + XCTAssertEqual(item.path, .root) + XCTAssertEqual(item.type, .directory) + } + + func testServiceUsesOneScopedClientOperationPerRequest() async throws { + let fixture = try await FileProviderRemoteServiceFixture.make() + + _ = try await fixture.service.list(directory: .root) + + XCTAssertEqual(fixture.clientProvider.callCount, 1) + } + + func testMutationAccessUsesOneClientLeaseAndContainedPaths() async throws { + let fixture = try await FileProviderRemoteServiceFixture.makeWritable() + guard let client = fixture.client else { + XCTFail("Expected writable fixture client") + return + } + let parent = try FileProviderRemotePath(relative: "projects") + let child = try FileProviderRemotePath(relative: "projects/report.txt") + + try await fixture.service.withMutationAccess { access in + _ = try await access.list(directory: parent) + try await access.uploadFile( + from: fixture.localFile, + to: child, + progress: { _ in } + ) + try await access.renameItem( + from: child, + to: FileProviderRemotePath(relative: "projects/final.txt") + ) + } + + XCTAssertEqual(fixture.clientProvider.callCount, 1) + let mutationPaths = await client.mutationPaths() + XCTAssertEqual( + mutationPaths, + [ + "/home/reader/projects/report.txt", + "/home/reader/projects/report.txt", + "/home/reader/projects/final.txt", + ] + ) + } + + func testMutationAccessRejectsDestinationBelowEscapingSymlinkParent() async throws { + let fixture = try await FileProviderRemoteServiceFixture.makeWritable( + canonicalPaths: ["/home/reader/link": "/etc"] + ) + guard let client = fixture.client else { + XCTFail("Expected writable fixture client") + return + } + + await XCTAssertThrowsErrorAsync({ + try await fixture.service.withMutationAccess { access in + try await access.createDirectory( + at: FileProviderRemotePath(relative: "link/new") + ) + } + }) { error in + XCTAssertEqual(error as? FileProviderRemotePathError, .unsafeLinkTarget) + } + let mutations = await client.mutations() + XCTAssertTrue(mutations.isEmpty) + } + + func testMutationAccessDoesNotStartMutationAfterCancellation() async throws { + let fixture = try await FileProviderRemoteServiceFixture.makeWritable() + guard let client = fixture.client else { + XCTFail("Expected writable fixture client") + return + } + let gate = FileProviderMutationAccessTestGate() + let task = Task { + try await fixture.service.withMutationAccess { access in + await gate.beginAndWait() + try await access.createDirectory( + at: FileProviderRemotePath(relative: "projects/new") + ) + } + } + + await gate.waitUntilStarted() + task.cancel() + await gate.release() + + await XCTAssertThrowsErrorAsync { try await task.value } + let mutations = await client.mutations() + XCTAssertTrue(mutations.isEmpty) + } + + func testMutationAccessRejectsSymbolicLinkSourceWithoutFollowingIt() async throws { + let home = "/home/reader" + let directory = RemuxSFTPFileMetadata( + size: nil, + permissions: 0o040755, + modificationDate: Date(timeIntervalSince1970: 100) + ) + let link = RemuxSFTPFileMetadata( + size: nil, + permissions: 0o120777, + modificationDate: Date(timeIntervalSince1970: 100) + ) + let client = FileProviderTestSFTPClient( + realPaths: [ + ".": .success(home), + "\(home)/projects": .success("\(home)/projects"), + "\(home)/projects/link": .success("/etc/passwd"), + ], + listings: [ + home: [RemuxSFTPDirectoryEntry(name: "projects", metadata: directory)], + "\(home)/projects": [ + RemuxSFTPDirectoryEntry(name: "link", metadata: link), + ], + ] + ) + let fixture = try await FileProviderRemoteServiceFixture.make(client: client) + + await XCTAssertThrowsErrorAsync({ + try await fixture.service.withMutationAccess { access in + try await access.removeFile( + at: FileProviderRemotePath(relative: "projects/link") + ) + } + }) { error in + XCTAssertEqual( + error as? RemuxSFTPClientError, + .noSuchFile("projects/link") + ) + } + + let mutations = await client.mutations() + XCTAssertTrue(mutations.isEmpty) + } + + func testMutationAccessDoesNotCreateDirectoryAfterCancellationDuringParentResolution() async throws { + let gate = FileProviderMutationAccessTestGate() + let client = FileProviderGatedMutationSFTPClient( + gate: gate, + gatePoint: .parentResolution + ) + let fixture = try await FileProviderRemoteServiceFixture.make(client: client) + let task = Task { + try await fixture.service.withMutationAccess { access in + try await access.createDirectory( + at: FileProviderRemotePath(relative: "projects/new") + ) + } + } + + await gate.waitUntilStarted() + task.cancel() + await gate.release() + + await XCTAssertThrowsErrorAsync { try await task.value } + let mutations = await client.mutations() + XCTAssertTrue(mutations.isEmpty) + } + + func testMutationAccessDoesNotRemoveFileAfterCancellationDuringMetadataLookup() async throws { + let gate = FileProviderMutationAccessTestGate() + let client = FileProviderGatedMutationSFTPClient( + gate: gate, + gatePoint: .metadataLookup + ) + let fixture = try await FileProviderRemoteServiceFixture.make(client: client) + let task = Task { + try await fixture.service.withMutationAccess { access in + try await access.removeFile( + at: FileProviderRemotePath(relative: "projects/report.txt") + ) + } + } + + await gate.waitUntilStarted() + task.cancel() + await gate.release() + + await XCTAssertThrowsErrorAsync { try await task.value } + let mutations = await client.mutations() + XCTAssertTrue(mutations.isEmpty) + } + + + func testServiceLoadsOnlySymlinksWhoseCanonicalTargetStaysInHome() async throws { + let fixture = try await FileProviderRemoteServiceFixture.make() + + let safe = try await fixture.service.item( + at: FileProviderRemotePath(relative: "safe-link") + ) + + XCTAssertEqual(safe.type, .symbolicLink) + XCTAssertEqual(safe.symlinkTargetRelativePath, "folder/target") + await XCTAssertThrowsErrorAsync { + try await fixture.service.item( + at: FileProviderRemotePath(relative: "escape-link") + ) + } + } + + func testListRejectsDirectoryWhoseSymlinkAncestorEscapesHome() async throws { + let home = "/home/reader" + let requestedDirectory = "\(home)/escape/nested" + let client = FileProviderTestSFTPClient( + realPaths: [ + ".": .success(home), + requestedDirectory: .success("/outside/nested"), + ], + listings: [ + requestedDirectory: [ + RemuxSFTPDirectoryEntry( + name: "secret.txt", + metadata: regularMetadata() + ), + ], + ] + ) + let fixture = try await FileProviderRemoteServiceFixture.make(client: client) + + await XCTAssertThrowsErrorAsync { + try await fixture.service.list( + directory: FileProviderRemotePath(relative: "escape/nested") + ) + } + } + + func testItemRejectsPathWhoseSymlinkAncestorEscapesHome() async throws { + let home = "/home/reader" + let requestedParent = "\(home)/escape" + let client = FileProviderTestSFTPClient( + realPaths: [ + ".": .success(home), + requestedParent: .success("/outside"), + ], + listings: [ + requestedParent: [ + RemuxSFTPDirectoryEntry( + name: "secret.txt", + metadata: regularMetadata() + ), + ], + ] + ) + let fixture = try await FileProviderRemoteServiceFixture.make(client: client) + + await XCTAssertThrowsErrorAsync { + try await fixture.service.item( + at: FileProviderRemotePath(relative: "escape/secret.txt") + ) + } + } + + func testFetchRejectsPathWhoseSymlinkAncestorEscapesHome() async throws { + let home = "/home/reader" + let requestedParent = "\(home)/escape" + let requestedFile = "\(requestedParent)/secret.txt" + let client = FileProviderTestSFTPClient( + realPaths: [ + ".": .success(home), + requestedParent: .success("/outside"), + ], + listings: [ + requestedParent: [ + RemuxSFTPDirectoryEntry( + name: "secret.txt", + metadata: regularMetadata() + ), + ], + ], + fileDataByPath: [requestedFile: Data("evil".utf8)] + ) + let fixture = try await FileProviderRemoteServiceFixture.make(client: client) + let localURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { + try? FileManager.default.removeItem(at: localURL) + } + + await XCTAssertThrowsErrorAsync { + try await fixture.service.fetch( + path: FileProviderRemotePath(relative: "escape/secret.txt"), + to: localURL, + progress: { _ in } + ) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: localURL.path)) + } + + func testFetchReadsCanonicalPathAfterResolvingSymlinkAncestor() async throws { + let home = "/home/reader" + let requestedParent = "\(home)/alias" + let requestedFile = "\(requestedParent)/file.txt" + let canonicalParent = "\(home)/real" + let canonicalFile = "\(canonicalParent)/file.txt" + let metadata = regularMetadata() + let client = FileProviderTestSFTPClient( + realPaths: [ + ".": .success(home), + requestedParent: .success(canonicalParent), + canonicalFile: .success(canonicalFile), + ], + listings: [ + requestedParent: [ + RemuxSFTPDirectoryEntry(name: "file.txt", metadata: metadata), + ], + canonicalParent: [ + RemuxSFTPDirectoryEntry(name: "file.txt", metadata: metadata), + ], + ], + fileDataByPath: [ + requestedFile: Data("evil".utf8), + canonicalFile: Data("safe".utf8), + ] + ) + let fixture = try await FileProviderRemoteServiceFixture.make(client: client) + let localURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { + try? FileManager.default.removeItem(at: localURL) + } + + _ = try await fixture.service.fetch( + path: FileProviderRemotePath(relative: "alias/file.txt"), + to: localURL, + progress: { _ in } + ) + + XCTAssertEqual(try Data(contentsOf: localURL), Data("safe".utf8)) + } + + func testFetchRejectsFinalComponentSwappedToEscapingSymlinkAfterMetadataCheck() async throws { + let home = "/home/reader" + let requestedFile = "\(home)/report.txt" + let client = FileProviderFinalComponentSwapSFTPClient( + home: home, + requestedFile: requestedFile, + escapedFile: "/outside/secret.txt", + metadata: regularMetadata(), + escapedData: Data("outside".utf8) + ) + let fixture = try await FileProviderRemoteServiceFixture.make( + client: client + ) + let localURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { + try? FileManager.default.removeItem(at: localURL) + } + + await XCTAssertThrowsErrorAsync { + try await fixture.service.fetch( + path: FileProviderRemotePath(relative: "report.txt"), + to: localURL, + progress: { _ in } + ) + } + + XCTAssertFalse(FileManager.default.fileExists(atPath: localURL.path)) + } + + func testFetchCancellationCancelsRemoteReadAndRemovesPartialFile() async throws { + let home = "/home/reader" + let metadata = RemuxSFTPFileMetadata( + size: 8 * 1024 * 1024, + permissions: 0o100644, + modificationDate: Date(timeIntervalSince1970: 400) + ) + let readState = FileProviderTestReadState() + let client = FileProviderTestSFTPClient( + realPaths: [ + ".": .success(home), + "\(home)/large.bin": .success("\(home)/large.bin"), + ], + listings: [ + home: [RemuxSFTPDirectoryEntry(name: "large.bin", metadata: metadata)], + ], + fileRead: { _, _ in + await readState.markStarted() + do { + try await Task.sleep(for: .seconds(60)) + return Data() + } catch is CancellationError { + await readState.markCancelled() + throw CancellationError() + } + } + ) + let fixture = try await FileProviderRemoteServiceFixture.make(client: client) + let partialURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + let task = Task { + try await fixture.service.fetch( + path: FileProviderRemotePath(relative: "large.bin"), + to: partialURL, + progress: { _ in } + ) + } + + await readState.waitUntilStarted() + task.cancel() + + await XCTAssertThrowsErrorAsync { try await task.value } + let wasCancelled = await readState.wasCancelled + XCTAssertTrue(wasCancelled) + XCTAssertFalse(FileManager.default.fileExists(atPath: partialURL.path)) + } + + private func regularMetadata() -> RemuxSFTPFileMetadata { + RemuxSFTPFileMetadata( + size: 4, + permissions: 0o100644, + modificationDate: Date(timeIntervalSince1970: 500) + ) + } +} + +private actor FileProviderFinalComponentSwapSFTPClient: + RemuxSFTPFileProviderClient +{ + private let home: String + private let requestedFile: String + private let escapedFile: String + private let metadataValue: RemuxSFTPFileMetadata + private let escapedData: Data + private var didReadMetadata = false + + init( + home: String, + requestedFile: String, + escapedFile: String, + metadata: RemuxSFTPFileMetadata, + escapedData: Data + ) { + self.home = home + self.requestedFile = requestedFile + self.escapedFile = escapedFile + self.metadataValue = metadata + self.escapedData = escapedData + } + + func realPath(atPath path: String) throws -> String { + if path == "." { + return home + } + guard path == requestedFile, didReadMetadata else { + throw RemuxSFTPClientError.noSuchFile(path) + } + return escapedFile + } + + func listDirectory( + atPath path: String + ) throws -> [RemuxSFTPDirectoryEntry] { + throw RemuxSFTPClientError.noSuchFile(path) + } + + func metadata(atPath path: String) throws -> RemuxSFTPFileMetadata { + throw RemuxSFTPClientError.noSuchFile(path) + } + + func linkMetadata( + atPath path: String + ) throws -> RemuxSFTPFileMetadata { + guard path == requestedFile else { + throw RemuxSFTPClientError.noSuchFile(path) + } + didReadMetadata = true + return metadataValue + } + + func withFile( + atPath path: String, + _ operation: @Sendable (RemuxSFTPReadableFile) async throws -> ReturnValue + ) async throws -> ReturnValue { + guard path == requestedFile, didReadMetadata else { + throw RemuxSFTPClientError.noSuchFile(path) + } + let escapedData = escapedData + return try await operation( + RemuxSFTPReadableFile { offset, length in + let start = min(Int(offset), escapedData.count) + let end = min(start + Int(length), escapedData.count) + return escapedData.subdata(in: start..( + _ expression: () async throws -> T, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + XCTFail("Expected expression to throw", file: file, line: line) + } catch { + } +} + +private func XCTAssertThrowsErrorAsync( + _ expression: () async throws -> T, + _ errorHandler: (Error) -> Void, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + XCTFail("Expected expression to throw", file: file, line: line) + } catch { + errorHandler(error) + } +} + +struct FileProviderRemoteServiceFixture { + let service: FileProviderRemoteService + let clientProvider: FileProviderTestSFTPClientProvider + let client: FileProviderTestSFTPClient? + let localFile: URL + let rootURL: URL + let server: SavedServer + + static func make() async throws -> FileProviderRemoteServiceFixture { + let home = "/home/reader" + let regular = RemuxSFTPFileMetadata( + size: 4, + permissions: 0o100644, + modificationDate: Date(timeIntervalSince1970: 100) + ) + let directory = RemuxSFTPFileMetadata( + size: nil, + permissions: 0o040755, + modificationDate: Date(timeIntervalSince1970: 200) + ) + let link = RemuxSFTPFileMetadata( + size: 12, + permissions: 0o120777, + modificationDate: Date(timeIntervalSince1970: 300) + ) + let other = RemuxSFTPFileMetadata( + size: nil, + permissions: 0o140755, + modificationDate: nil + ) + let client = FileProviderTestSFTPClient( + realPaths: [ + ".": .success(home), + "\(home)/safe-link": .success("\(home)/folder/target"), + "\(home)/escape-link": .success("/etc/passwd"), + "\(home)/broken-link": .failure(.unresolved), + "\(home)/cycle-link": .failure(.unresolved), + "\(home)/file.txt": .success("\(home)/file.txt"), + ], + listings: [ + home: [ + RemuxSFTPDirectoryEntry(name: ".env", metadata: regular), + RemuxSFTPDirectoryEntry(name: "folder", metadata: directory), + RemuxSFTPDirectoryEntry(name: "safe-link", metadata: link), + RemuxSFTPDirectoryEntry(name: "file.txt", metadata: regular), + RemuxSFTPDirectoryEntry(name: "socket", metadata: other), + RemuxSFTPDirectoryEntry(name: "escape-link", metadata: link), + RemuxSFTPDirectoryEntry(name: "broken-link", metadata: link), + RemuxSFTPDirectoryEntry(name: "cycle-link", metadata: link), + ], + ], + metadataByPath: ["\(home)/file.txt": regular] + ) + return try await make(client: client) + } + + static func make( + client: any RemuxSFTPFileProviderClient + ) async throws -> FileProviderRemoteServiceFixture { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profiles = FileBackedConnectionProfileRepository(rootURL: root) + let credentials = FileProviderTestCredentialStore() + let identity = SSHIdentity(name: "Read only", authenticationKind: .password) + let server = SavedServer( + displayName: "Fixture", + host: "fixture.example.test", + username: "reader", + identityID: identity.id + ) + try await profiles.saveIdentity(identity) + try await profiles.saveServer(server) + await credentials.saveCredential(.password("fixture-password"), identityID: identity.id) + + let localFile = root.appendingPathComponent("local-upload.txt") + try Data("contents".utf8).write(to: localFile) + + let clientProvider = FileProviderTestSFTPClientProvider(client: client) + return FileProviderRemoteServiceFixture( + service: FileProviderRemoteService( + domainIdentifier: server.id.uuidString.lowercased(), + profiles: profiles, + credentials: credentials, + clientProvider: clientProvider + ), + clientProvider: clientProvider, + client: client as? FileProviderTestSFTPClient, + localFile: localFile, + rootURL: root, + server: server + ) + } + + static func makeWritable( + canonicalPaths: [String: String] = [:] + ) async throws -> FileProviderRemoteServiceFixture { + let home = "/home/reader" + let directory = RemuxSFTPFileMetadata( + size: nil, + permissions: 0o040755, + modificationDate: Date(timeIntervalSince1970: 100) + ) + let regular = RemuxSFTPFileMetadata( + size: 4, + permissions: 0o100644, + modificationDate: Date(timeIntervalSince1970: 100) + ) + let realPaths = canonicalPaths.reduce( + into: [ + ".": Result.success(home), + "\(home)/projects": .success("\(home)/projects"), + ] + ) { paths, pair in + paths[pair.key] = .success(pair.value) + } + let client = FileProviderTestSFTPClient( + realPaths: realPaths, + listings: [ + home: [RemuxSFTPDirectoryEntry(name: "projects", metadata: directory)], + "\(home)/projects": [ + RemuxSFTPDirectoryEntry(name: "report.txt", metadata: regular), + ], + ] + ) + return try await make(client: client) + } +} + +actor FileProviderTestCredentialStore: SSHCredentialStore { + private var credentials: [SSHIdentity.ID: SSHCredential] = [:] + + func loadCredential(identityID: SSHIdentity.ID) -> SSHCredential? { + credentials[identityID] + } + + func saveCredential(_ credential: SSHCredential, identityID: SSHIdentity.ID) { + credentials[identityID] = credential + } + + func deleteCredential(identityID: SSHIdentity.ID) { + credentials.removeValue(forKey: identityID) + } +} + +final class FileProviderTestSFTPClientProvider: FileProviderSFTPClientProviding, @unchecked Sendable { + let client: any RemuxSFTPFileProviderClient + private let lock = NSLock() + private var calls = 0 + private var closedServers: [SavedServer.ID] = [] + + init(client: any RemuxSFTPFileProviderClient) { + self.client = client + } + + var callCount: Int { + lock.withLock { calls } + } + + var closedServerIDs: [SavedServer.ID] { + lock.withLock { closedServers } + } + + func withClient( + server: SavedServer, + authentication: ResolvedSSHAuth, + operation: @Sendable (any RemuxSFTPFileProviderClient) async throws -> Value + ) async throws -> Value { + lock.withLock { + calls += 1 + } + return try await operation(client) + } + + func closeIdleConnections(forServerID serverID: SavedServer.ID) async { + lock.withLock { + closedServers.append(serverID) + } + } + + func waitForCloseCall() async { + for _ in 0..<100 { + guard closedServerIDs.isEmpty else { return } + try? await Task.sleep(for: .milliseconds(5)) + } + } +} + +enum FileProviderTestSFTPFailure: Error { + case unresolved +} + +enum FileProviderTestSFTPMutation: Equatable { + case createDirectory(String) + case uploadFile(String) + case renameItem(String, String) + case removeFile(String) + case removeEmptyDirectory(String) +} + +final class FileProviderTestSFTPClient: RemuxSFTPFileProviderClient, @unchecked Sendable { + private let realPaths: [String: Result] + private let listings: [String: [RemuxSFTPDirectoryEntry]] + private let metadataByPath: [String: RemuxSFTPFileMetadata] + private let fileDataByPath: [String: Data] + private let fileRead: (@Sendable (UInt64, UInt32) async throws -> Data)? + private let lock = NSLock() + private var recordedMutations: [FileProviderTestSFTPMutation] = [] + + init( + realPaths: [String: Result], + listings: [String: [RemuxSFTPDirectoryEntry]], + metadataByPath: [String: RemuxSFTPFileMetadata] = [:], + fileDataByPath: [String: Data] = [:], + fileRead: (@Sendable (UInt64, UInt32) async throws -> Data)? = nil + ) { + self.realPaths = realPaths + self.listings = listings + self.metadataByPath = metadataByPath + self.fileDataByPath = fileDataByPath + self.fileRead = fileRead + } + + func realPath(atPath path: String) async throws -> String { + guard let result = realPaths[path] else { + throw RemuxSFTPClientError.noSuchFile(path) + } + return try result.get() + } + + func listDirectory(atPath path: String) async throws -> [RemuxSFTPDirectoryEntry] { + guard let listing = listings[path] else { + throw RemuxSFTPClientError.noSuchFile(path) + } + return listing + } + + func metadata(atPath path: String) async throws -> RemuxSFTPFileMetadata { + guard let metadata = metadataByPath[path] else { + throw RemuxSFTPClientError.noSuchFile(path) + } + return metadata + } + + func linkMetadata(atPath path: String) async throws -> RemuxSFTPFileMetadata { + let parent = (path as NSString).deletingLastPathComponent + let name = (path as NSString).lastPathComponent + guard let metadata = listings[parent]?.first(where: { $0.name == name })?.metadata else { + throw RemuxSFTPClientError.noSuchFile(path) + } + return metadata + } + + func withFile( + atPath path: String, + _ operation: @Sendable (RemuxSFTPReadableFile) async throws -> ReturnValue + ) async throws -> ReturnValue { + if let fileRead { + return try await operation(RemuxSFTPReadableFile(readChunk: fileRead)) + } + guard let data = fileDataByPath[path] else { + throw RemuxSFTPClientError.noSuchFile(path) + } + let file = RemuxSFTPReadableFile { offset, length in + let start = min(Int(offset), data.count) + let end = min(start + Int(length), data.count) + return data.subdata(in: start.. [FileProviderTestSFTPMutation] { + lock.withLock { recordedMutations } + } + + func mutationPaths() async -> [String] { + lock.withLock { + recordedMutations.flatMap { mutation in + switch mutation { + case .createDirectory(let path), .uploadFile(let path), + .removeFile(let path), .removeEmptyDirectory(let path): + [path] + case .renameItem(let source, let destination): + [source, destination] + } + } + } + } +} + +private actor FileProviderMutationAccessTestGate { + private var started = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func beginAndWait() async { + started = true + let startWaiters = startWaiters + self.startWaiters.removeAll() + startWaiters.forEach { $0.resume() } + await withCheckedContinuation { continuation in + releaseWaiters.append(continuation) + } + } + + func waitUntilStarted() async { + guard !started else { return } + await withCheckedContinuation { continuation in + startWaiters.append(continuation) + } + } + + func release() { + let releaseWaiters = releaseWaiters + self.releaseWaiters.removeAll() + releaseWaiters.forEach { $0.resume() } + } +} + +private actor FileProviderGatedMutationSFTPClient: RemuxSFTPFileProviderClient { + enum GatePoint { + case parentResolution + case metadataLookup + } + + private let home = "/home/reader" + private let gate: FileProviderMutationAccessTestGate + private let gatePoint: GatePoint + private var recordedMutations: [FileProviderTestSFTPMutation] = [] + + init(gate: FileProviderMutationAccessTestGate, gatePoint: GatePoint) { + self.gate = gate + self.gatePoint = gatePoint + } + + func realPath(atPath path: String) async throws -> String { + switch path { + case ".": + return home + case "\(home)/projects": + if gatePoint == .parentResolution { + await gate.beginAndWait() + } + return path + default: + throw RemuxSFTPClientError.noSuchFile(path) + } + } + + func listDirectory(atPath path: String) async throws -> [RemuxSFTPDirectoryEntry] { + throw RemuxSFTPClientError.noSuchFile(path) + } + + func metadata(atPath path: String) async throws -> RemuxSFTPFileMetadata { + throw RemuxSFTPClientError.noSuchFile(path) + } + + func linkMetadata(atPath path: String) async throws -> RemuxSFTPFileMetadata { + guard path == "\(home)/projects/report.txt" else { + throw RemuxSFTPClientError.noSuchFile(path) + } + if gatePoint == .metadataLookup { + await gate.beginAndWait() + } + return RemuxSFTPFileMetadata( + size: 4, + permissions: 0o100644, + modificationDate: Date(timeIntervalSince1970: 100) + ) + } + + func withFile( + atPath path: String, + _ operation: @Sendable (RemuxSFTPReadableFile) async throws -> ReturnValue + ) async throws -> ReturnValue { + throw RemuxSFTPClientError.noSuchFile(path) + } + + func createDirectory(atPath path: String) async throws { + recordedMutations.append(.createDirectory(path)) + } + + func uploadFile( + from localURL: URL, + to remotePath: String, + progress: @escaping RemuxSFTPFileUploadProgressHandler + ) async throws { + recordedMutations.append(.uploadFile(remotePath)) + } + + func renameItem(from sourcePath: String, to destinationPath: String) async throws { + recordedMutations.append(.renameItem(sourcePath, destinationPath)) + } + + func removeFile(atPath path: String) async throws { + recordedMutations.append(.removeFile(path)) + } + + func removeEmptyDirectory(atPath path: String) async throws { + recordedMutations.append(.removeEmptyDirectory(path)) + } + + func mutations() -> [FileProviderTestSFTPMutation] { + recordedMutations + } +} + +private actor FileProviderTestReadState { + private var started = false + private var startWaiters: [CheckedContinuation] = [] + private(set) var wasCancelled = false + + func markStarted() { + started = true + let waiters = startWaiters + startWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + func waitUntilStarted() async { + guard !started else { return } + await withCheckedContinuation { continuation in + startWaiters.append(continuation) + } + } + + func markCancelled() { + wasCancelled = true + } +} diff --git a/RemuxAppTests/FileProviderSharedStorageMigratorTests.swift b/RemuxAppTests/FileProviderSharedStorageMigratorTests.swift new file mode 100644 index 00000000..308a5536 --- /dev/null +++ b/RemuxAppTests/FileProviderSharedStorageMigratorTests.swift @@ -0,0 +1,221 @@ +import XCTest +@testable import Remux + +final class FileProviderSharedStorageMigratorTests: XCTestCase { + func testMigrationCopiesProfilesTrustAndEveryReferencedCredentialBeforeMarkingComplete() async throws { + let fixture = try MigrationFixture() + try await fixture.seedLegacy(password: "secret") + + try await fixture.migrator.migrateIfNeeded() + + let sharedSnapshot = try await fixture.sharedProfiles.loadSnapshot() + let sharedCredential = try await fixture.sharedCredentials.loadCredential(identityID: fixture.identity.id) + let legacySnapshot = try await fixture.legacyProfiles.loadSnapshot() + let legacyCredential = try await fixture.legacyCredentials.loadCredential(identityID: fixture.identity.id) + + XCTAssertEqual(sharedSnapshot, fixture.legacySnapshot) + XCTAssertEqual(try fixture.sharedTrust.loadIdentities(), fixture.expectedTrust) + XCTAssertEqual(sharedCredential, .password("secret")) + XCTAssertEqual(legacySnapshot, fixture.legacySnapshot) + XCTAssertEqual(try fixture.legacyTrust.loadIdentities(), fixture.expectedTrust) + XCTAssertEqual(legacyCredential, .password("secret")) + XCTAssertTrue(fixture.markerExists) + } + + func testMigrationCopiesPrivateKeyCredentialWithPassphrase() async throws { + let fixture = try MigrationFixture(identityAuthenticationKind: .privateKey) + let credential = SSHCredential.privateKey( + SSHPrivateKeyCredential( + privateKeyPEM: """ + -----BEGIN OPENSSH PRIVATE KEY----- + private-key-fixture + -----END OPENSSH PRIVATE KEY----- + """, + passphrase: "passphrase-fixture" + ) + ) + try await fixture.seedLegacy(credential: credential) + + try await fixture.migrator.migrateIfNeeded() + + let sharedCredential = try await fixture.sharedCredentials.loadCredential(identityID: fixture.identity.id) + XCTAssertEqual(sharedCredential, credential) + } + + func testMigrationFailureLeavesSourceIntactDoesNotMarkAndRetriesIdempotently() async throws { + let fixture = try MigrationFixture(sharedCredentials: FailingOnceCredentialStore()) + try await fixture.seedLegacy(password: "secret") + + await XCTAssertThrowsErrorAsync { + try await fixture.migrator.migrateIfNeeded() + } + + XCTAssertFalse(fixture.markerExists) + let legacySnapshot = try await fixture.legacyProfiles.loadSnapshot() + let legacyCredential = try await fixture.legacyCredentials.loadCredential(identityID: fixture.identity.id) + + XCTAssertEqual(legacySnapshot, fixture.legacySnapshot) + XCTAssertEqual(try fixture.legacyTrust.loadIdentities(), fixture.expectedTrust) + XCTAssertEqual(legacyCredential, .password("secret")) + + try await fixture.migrator.migrateIfNeeded() + try await fixture.migrator.migrateIfNeeded() + + XCTAssertTrue(fixture.markerExists) + let sharedSnapshot = try await fixture.sharedProfiles.loadSnapshot() + let sharedCredential = try await fixture.sharedCredentials.loadCredential(identityID: fixture.identity.id) + + XCTAssertEqual(sharedSnapshot, fixture.legacySnapshot) + XCTAssertEqual(try fixture.sharedTrust.loadIdentities(), fixture.expectedTrust) + XCTAssertEqual(sharedCredential, .password("secret")) + } +} + +private final class MigrationFixture { + let identity: SSHIdentity + let legacyProfiles: FileBackedConnectionProfileRepository + let legacyCredentials: InMemorySSHCredentialStore + let legacyTrust: TrustedHostStore + let sharedProfiles: FileBackedConnectionProfileRepository + let sharedCredentials: any SSHCredentialStore + let sharedTrust: TrustedHostStore + let markerURL: URL + let migrator: FileProviderSharedStorageMigrator + + var legacySnapshot: ConnectionLibrarySnapshot { + ConnectionLibrarySnapshot( + servers: [server], + workspaces: [workspace], + identities: [identity] + ) + } + + var expectedTrust: [TrustedHostIdentity] { + [ + TrustedHostIdentity( + serverID: server.id, + host: server.host, + keyType: "ssh-ed25519", + openSSHPublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest", + trustedAt: Date(timeIntervalSince1970: 1) + ) + ] + } + + var markerExists: Bool { + FileManager.default.fileExists(atPath: markerURL.path) + } + + private let server: SavedServer + private let workspace: SavedWorkspace + + init( + identityAuthenticationKind: SSHAuthenticationKind = .password, + sharedCredentials: any SSHCredentialStore = InMemorySSHCredentialStore() + ) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let legacyRoot = root.appendingPathComponent("legacy", isDirectory: true) + let sharedRoot = root.appendingPathComponent("shared", isDirectory: true) + markerURL = root.appendingPathComponent("file-provider-shared-storage-migration-v1") + + identity = SSHIdentity( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + name: "Primary credential", + authenticationKind: identityAuthenticationKind + ) + server = SavedServer( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, + displayName: "Example Server", + host: "server.example.test", + username: "demo", + identityID: identity.id + ) + workspace = SavedWorkspace( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000003")!, + serverID: server.id, + sessionName: "base", + lastOpenedAt: Date(timeIntervalSince1970: 2) + ) + legacyProfiles = FileBackedConnectionProfileRepository(rootURL: legacyRoot) + legacyCredentials = InMemorySSHCredentialStore() + legacyTrust = TrustedHostStore(rootURL: legacyRoot) + self.sharedProfiles = FileBackedConnectionProfileRepository(rootURL: sharedRoot) + self.sharedCredentials = sharedCredentials + sharedTrust = TrustedHostStore(rootURL: sharedRoot) + migrator = FileProviderSharedStorageMigrator( + legacyProfiles: legacyProfiles, + legacyCredentials: legacyCredentials, + legacyTrust: legacyTrust, + sharedProfiles: self.sharedProfiles, + sharedCredentials: sharedCredentials, + sharedTrust: sharedTrust, + markerURL: markerURL + ) + } + + func seedLegacy(password: String) async throws { + try await seedLegacy(credential: .password(password)) + } + + func seedLegacy(credential: SSHCredential) async throws { + try await legacyProfiles.saveIdentity(identity) + try await legacyProfiles.saveServer(server) + try await legacyProfiles.saveWorkspace(workspace) + try legacyTrust.replaceIdentities(expectedTrust) + try await legacyCredentials.saveCredential(credential, identityID: identity.id) + } +} + +private actor InMemorySSHCredentialStore: SSHCredentialStore { + private var credentials: [SSHIdentity.ID: SSHCredential] = [:] + + func loadCredential(identityID: SSHIdentity.ID) async throws -> SSHCredential? { + credentials[identityID] + } + + func saveCredential(_ credential: SSHCredential, identityID: SSHIdentity.ID) async throws { + credentials[identityID] = credential + } + + func deleteCredential(identityID: SSHIdentity.ID) async throws { + credentials.removeValue(forKey: identityID) + } +} + +private actor FailingOnceCredentialStore: SSHCredentialStore { + private var shouldFail = true + private let store = InMemorySSHCredentialStore() + + func loadCredential(identityID: SSHIdentity.ID) async throws -> SSHCredential? { + try await store.loadCredential(identityID: identityID) + } + + func saveCredential(_ credential: SSHCredential, identityID: SSHIdentity.ID) async throws { + if shouldFail { + shouldFail = false + throw Failure.saveCredential + } + + try await store.saveCredential(credential, identityID: identityID) + } + + func deleteCredential(identityID: SSHIdentity.ID) async throws { + try await store.deleteCredential(identityID: identityID) + } + + private enum Failure: Error { + case saveCredential + } +} + +private func XCTAssertThrowsErrorAsync( + _ expression: () async throws -> Void, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + try await expression() + XCTFail("expected error", file: file, line: line) + } catch {} +} diff --git a/RemuxAppTests/FileProviderSharedStorageTests.swift b/RemuxAppTests/FileProviderSharedStorageTests.swift new file mode 100644 index 00000000..269422cb --- /dev/null +++ b/RemuxAppTests/FileProviderSharedStorageTests.swift @@ -0,0 +1,54 @@ +import Security +import XCTest +@testable import Remux + +final class FileProviderSharedStorageTests: XCTestCase { + func testSharedRootAppendsRemuxToResolvedContainer() throws { + XCTAssertEqual( + try ApplicationStorage.sharedRemuxRoot( + appGroupIdentifier: "group.dev.remux", + fileManager: .default, + containerURL: { _ in URL(fileURLWithPath: "/shared") } + ).path, + "/shared/Remux" + ) + } + + func testCredentialQueryIncludesExplicitAccessGroup() { + let query = KeychainSSHCredentialStore.query( + service: "dev.remux.ssh-credentials", + accessGroup: "TEAM.dev.remux.shared", + identityID: UUID(), + returnData: false + ) + + XCTAssertEqual(query[kSecAttrAccessGroup] as? String, "TEAM.dev.remux.shared") + } + + func testSharedConfigurationReadsExpandedAccessGroup() throws { + XCTAssertEqual( + try FileProviderSharedConfiguration.keychainAccessGroup( + infoDictionary: ["RemuxSharedKeychainAccessGroup": "TEAM.dev.remux.shared"] + ), + "TEAM.dev.remux.shared" + ) + } + + func testLiveCredentialStoresKeepApplicationSourceSeparateFromSharedDestination() async throws { + let service = "dev.remux.tests.\(UUID().uuidString)" + let stores = try RemuxAppDependencies.fileProviderCredentialStores(service: service) + let identityID = UUID() + + try await stores.application.saveCredential(.password("application"), identityID: identityID) + try await stores.shared.saveCredential(.password("shared"), identityID: identityID) + + let applicationCredential = try await stores.application.loadCredential(identityID: identityID) + let sharedCredential = try await stores.shared.loadCredential(identityID: identityID) + + XCTAssertEqual(applicationCredential, .password("application")) + XCTAssertEqual(sharedCredential, .password("shared")) + + try await stores.application.deleteCredential(identityID: identityID) + try await stores.shared.deleteCredential(identityID: identityID) + } +} diff --git a/RemuxAppTests/FileProviderSnapshotStoreTests.swift b/RemuxAppTests/FileProviderSnapshotStoreTests.swift new file mode 100644 index 00000000..cd96f09d --- /dev/null +++ b/RemuxAppTests/FileProviderSnapshotStoreTests.swift @@ -0,0 +1,864 @@ +import FileProvider +import XCTest + +@testable import Remux + +final class FileProviderSnapshotStoreTests: XCTestCase { + private var root: URL! + + override func setUpWithError() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("FileProviderSnapshotStoreTests") + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try FileManager.default.removeItem(at: root) + } + + func testRecordProducesInsertUpdateDeleteAndStableGeneration() async throws { + let store = FileProviderSnapshotStore(rootURL: root) + let a = try item(path: "a.txt", size: 1) + let b = try item(path: "b.txt", size: 2) + let aChanged = try item(path: "a.txt", size: 3) + let c = try item(path: "c.txt", size: 4) + + _ = try await store.record(directory: .root, items: []) + let initialAnchor = try await requiredCurrentAnchor(of: store) + let first = try await store.record(directory: .root, items: [b, a]) + let firstAnchor = try await requiredCurrentAnchor(of: store) + let firstDelta = try await store.delta(directory: .root, from: initialAnchor) + let unchanged = try await store.record(directory: .root, items: [a, b]) + let unchangedAnchor = try await requiredCurrentAnchor(of: store) + let unchangedDelta = try await store.delta(directory: .root, from: firstAnchor) + let changed = try await store.record(directory: .root, items: [c, aChanged]) + let changedAnchor = try await requiredCurrentAnchor(of: store) + let changedDelta = try await store.delta(directory: .root, from: firstAnchor) + + XCTAssertEqual(firstAnchor, unchangedAnchor) + XCTAssertEqual(firstDelta.delta.updated.map(\.remoteItem), [a, b]) + XCTAssertTrue(firstDelta.delta.deleted.isEmpty) + XCTAssertEqual( + unchangedDelta.delta, + FileProviderSnapshotDelta(updated: [], deleted: []) + ) + XCTAssertEqual(changedDelta.delta.updated.map(\.remoteItem), [aChanged, c]) + XCTAssertEqual(changedDelta.delta.deleted, [first[1].itemIdentifier]) + XCTAssertEqual(generation(of: firstAnchor), 2) + XCTAssertEqual(generation(of: changedAnchor), 3) + XCTAssertEqual(changedDelta.anchor, changedAnchor) + XCTAssertEqual(unchanged, first) + XCTAssertEqual(changed.map(\.remoteItem), [aChanged, c]) + } + + func testRecordAllocatesAndPersistsIdentityForPath() async throws { + let ids = FileProviderTestIdentitySequence([ + UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000001")!, + ]) + let store = FileProviderSnapshotStore( + rootURL: root, + identityGenerator: { ids.next() } + ) + let remote = try item(path: "report.txt") + + let first = try await store.record(directory: .root, items: [remote]) + let second = try await store.record(directory: .root, items: [remote]) + + XCTAssertEqual(first, second) + XCTAssertEqual( + first.first?.identity, + .item(UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000001")!) + ) + let path = try await store.path(for: first[0].itemIdentifier) + XCTAssertEqual(path, remote.path) + XCTAssertEqual( + try store.pathSynchronously(for: first[0].itemIdentifier), + remote.path + ) + } + + func testPathSynchronouslyReturnsRootForFreshDomain() throws { + let store = FileProviderSnapshotStore(rootURL: root) + + XCTAssertEqual( + try store.pathSynchronously(for: .rootContainer), + .root + ) + } + + func testNestedRecordPersistsOpaqueParentIdentity() async throws { + let ids = FileProviderTestIdentitySequence([ + UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000001")!, + UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000002")!, + ]) + let store = FileProviderSnapshotStore( + rootURL: root, + identityGenerator: { ids.next() } + ) + let directory = try item(path: "reports", type: .directory) + let nestedPath = try FileProviderRemotePath(relative: "reports") + let child = try item(path: "reports/week.txt") + + let rootRecord = try await store.record(directory: .root, items: [directory]) + let nestedRecord = try await store.record( + directory: nestedPath, + items: [child] + ) + + XCTAssertEqual(nestedRecord[0].parentIdentity, rootRecord[0].identity) + XCTAssertEqual( + FileProviderItemProjection( + item: nestedRecord[0], + rootDisplayName: "Fixture" + ).parentItemIdentifier, + rootRecord[0].itemIdentifier + ) + } + + func testLocalMoveRetainsIdentityRelocatesDescendantsAndDoesNotQueueSignal() async throws { + let store = FileProviderSnapshotStore(rootURL: root) + let folder = try item(path: "old", type: .directory) + let child = try item(path: "old/child.txt") + let rootRecord = try await store.record(directory: .root, items: [folder]) + let childRecord = try await store.record( + directory: FileProviderRemotePath(relative: "old"), + items: [child] + ) + let identity = rootRecord[0].identity + let childIdentity = childRecord[0].identity + let pendingSignal = try await store.pendingWorkingSetSignalGeneration() + + try await store.commit( + localMutation: FileProviderSnapshotLocalMutation( + refreshedDirectories: [ + .init(directory: .root, items: [ + try item(path: "new", type: .directory), + ]), + .init( + directory: FileProviderRemotePath(relative: "new"), + items: [try item(path: "new/child.txt")] + ), + ], + relocations: [ + .init( + identity: identity, + from: FileProviderRemotePath(relative: "old"), + to: FileProviderRemotePath(relative: "new") + ), + ], + deletedIdentities: [] + ) + ) + let moved = try await store.workingSetSnapshot() + + let movedPath = try await store.path(for: identity.itemIdentifier) + XCTAssertEqual(movedPath, try FileProviderRemotePath(relative: "new")) + XCTAssertEqual( + moved.items.first(where: { $0.identity == identity })?.remoteItem.path, + try FileProviderRemotePath(relative: "new") + ) + let movedChild = moved.items.first { $0.identity == childIdentity } + XCTAssertEqual( + movedChild?.remoteItem.path, + try FileProviderRemotePath(relative: "new/child.txt") + ) + XCTAssertEqual(movedChild?.parentIdentity, identity) + let pendingSignalAfterMutation = try await store.pendingWorkingSetSignalGeneration() + XCTAssertEqual(pendingSignalAfterMutation, pendingSignal) + } + + func testCreateAliasSurvivesRetainedGenerationTrimming() async throws { + let store = FileProviderSnapshotStore( + rootURL: root, + retainedGenerationCount: 2 + ) + let created = FileProviderIdentifiedItem( + identity: .item(UUID()), + parentIdentity: .root, + remoteItem: try item(path: "created.txt") + ) + + try await store.commit( + localMutation: .init( + refreshedDirectories: [.init(directory: .root, items: [created.remoteItem])], + identityReservations: [.init(identity: created.identity, path: created.remoteItem.path)], + createAlias: FileProviderCreateAlias( + templateIdentifier: "system-template-1", + identity: created.identity + ) + ) + ) + + _ = try await store.record(directory: .root, items: [created.remoteItem, try item(path: "a")]) + _ = try await store.record(directory: .root, items: [created.remoteItem, try item(path: "b")]) + + let reopenedStore = FileProviderSnapshotStore( + rootURL: root, + retainedGenerationCount: 2 + ) + let replayed = try await reopenedStore.item( + forCreateTemplateIdentifier: "system-template-1" + ) + XCTAssertEqual(replayed, created) + } + + func testCreateAliasFollowsIdentityAcrossLocalMove() async throws { + let store = FileProviderSnapshotStore(rootURL: root) + let created = FileProviderIdentifiedItem( + identity: .item(UUID()), + parentIdentity: .root, + remoteItem: try item(path: "old.txt") + ) + try await store.commit( + localMutation: .init( + refreshedDirectories: [.init(directory: .root, items: [created.remoteItem])], + identityReservations: [.init(identity: created.identity, path: created.remoteItem.path)], + createAlias: FileProviderCreateAlias( + templateIdentifier: "system-template-2", + identity: created.identity + ) + ) + ) + let movedItem = try item(path: "new.txt") + + try await store.commit( + localMutation: .init( + refreshedDirectories: [.init(directory: .root, items: [movedItem])], + relocations: [ + .init( + identity: created.identity, + from: created.remoteItem.path, + to: movedItem.path + ), + ] + ) + ) + + let replayed = try await store.item( + forCreateTemplateIdentifier: "system-template-2" + ) + XCTAssertEqual(replayed?.identity, created.identity) + XCTAssertEqual(replayed?.remoteItem, movedItem) + } + + func testCreateAliasIsPrunedWhenIdentityLeavesWorkingSet() async throws { + let store = FileProviderSnapshotStore(rootURL: root) + let created = FileProviderIdentifiedItem( + identity: .item(UUID()), + parentIdentity: .root, + remoteItem: try item(path: "created.txt") + ) + try await store.commit( + localMutation: .init( + refreshedDirectories: [.init(directory: .root, items: [created.remoteItem])], + identityReservations: [.init(identity: created.identity, path: created.remoteItem.path)], + createAlias: FileProviderCreateAlias( + templateIdentifier: "system-template-3", + identity: created.identity + ) + ) + ) + + _ = try await store.record(directory: .root, items: []) + + let replayed = try await store.item( + forCreateTemplateIdentifier: "system-template-3" + ) + XCTAssertNil(replayed) + } + + func testLocalRefreshPrunesRemovedTrackedDirectory() async throws { + let store = FileProviderSnapshotStore(rootURL: root) + let directory = try item(path: "nested", type: .directory) + let child = try item(path: "nested/child.txt") + let rootRecord = try await store.record(directory: .root, items: [directory]) + let childRecord = try await store.record( + directory: FileProviderRemotePath(relative: "nested"), + items: [child] + ) + let beforeMutation = try await requiredCurrentAnchor(of: store) + + try await store.commit( + localMutation: .init( + refreshedDirectories: [.init(directory: .root, items: [])] + ) + ) + let afterMutation = try await requiredCurrentAnchor(of: store) + let mutationDelta = try await store.workingSetDelta(from: beforeMutation) + + XCTAssertEqual(mutationDelta.anchor, afterMutation) + XCTAssertEqual(mutationDelta.delta.deleted, [ + rootRecord[0].itemIdentifier, + childRecord[0].itemIdentifier, + ].sorted { $0.rawValue < $1.rawValue }) + let nestedItems = try await store.items( + directory: FileProviderRemotePath(relative: "nested") + ) + XCTAssertTrue(nestedItems.isEmpty) + } + + func testLocalMutationQueuesWorkingSetSignalOnlyWhenRequested() async throws { + let store = FileProviderSnapshotStore(rootURL: root) + _ = try await store.record( + directory: .root, + items: [try item(path: "initial.txt")] + ) + _ = try await store.record( + directory: .root, + items: [try item(path: "remote.txt")] + ) + let remoteAnchor = try await requiredCurrentAnchor(of: store) + let remoteSignal = try await store.pendingWorkingSetSignalGeneration() + XCTAssertEqual(remoteSignal, generation(of: remoteAnchor)) + + try await store.commit( + localMutation: .init( + refreshedDirectories: [.init( + directory: .root, + items: [try item(path: "local.txt")] + )] + ) + ) + let normalAnchor = try await requiredCurrentAnchor(of: store) + let normalSignal = try await store.pendingWorkingSetSignalGeneration() + XCTAssertEqual(normalSignal, remoteSignal) + + try await store.commit( + localMutation: .init( + refreshedDirectories: [.init( + directory: .root, + items: [try item(path: "partial.txt")] + )], + queuesWorkingSetSignal: true + ) + ) + let partialAnchor = try await requiredCurrentAnchor(of: store) + XCTAssertEqual(generation(of: normalAnchor) + 1, generation(of: partialAnchor)) + let partialSignal = try await store.pendingWorkingSetSignalGeneration() + XCTAssertEqual(partialSignal, generation(of: partialAnchor)) + let stateURL = root.appendingPathComponent("snapshot-generations-v3.json") + let state = try JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as! [String: Any] + XCTAssertEqual( + (state["pendingWorkingSetSignalGeneration"] as? NSNumber)?.uint64Value, + generation(of: partialAnchor) + ) + } + + func testInvalidIdentityReservationDoesNotPersistMutation() async throws { + let store = FileProviderSnapshotStore(rootURL: root) + let existing = try item(path: "existing.txt") + let baseline = try await store.record(directory: .root, items: [existing]) + let baselineAnchor = try await requiredCurrentAnchor(of: store) + let identity = baseline[0].identity + + await XCTAssertThrowsErrorAsync( + try await store.commit( + localMutation: .init( + refreshedDirectories: [.init( + directory: .root, + items: [existing, try item(path: "new.txt")] + )], + identityReservations: [.init( + identity: identity, + path: try FileProviderRemotePath(relative: "new.txt") + )] + ) + ) + ) { error in + XCTAssertEqual(error as? FileProviderSnapshotStoreError, .duplicatePath) + } + + let anchor = try await store.currentAnchor() + XCTAssertEqual(anchor, baselineAnchor) + let items = try await store.items(directory: .root) + XCTAssertEqual( + items.map(\.remoteItem), + [existing] + ) + } + + func testCollidingRelocationDoesNotPersistMutation() async throws { + let store = FileProviderSnapshotStore(rootURL: root) + let old = try item(path: "old", type: .directory) + let new = try item(path: "new", type: .directory) + let baseline = try await store.record(directory: .root, items: [old, new]) + let baselineAnchor = try await requiredCurrentAnchor(of: store) + + await XCTAssertThrowsErrorAsync( + try await store.commit( + localMutation: .init( + refreshedDirectories: [], + relocations: [.init( + identity: baseline.first(where: { + $0.remoteItem.path == old.path + })!.identity, + from: old.path, + to: new.path + )] + ) + ) + ) { error in + XCTAssertEqual(error as? FileProviderSnapshotStoreError, .duplicatePath) + } + + let anchor = try await store.currentAnchor() + XCTAssertEqual(anchor, baselineAnchor) + let items = try await store.items(directory: .root) + XCTAssertEqual(items, baseline) + } + + func testRelocationOntoDeletedIdentityRetainsMovedItem() async throws { + let store = FileProviderSnapshotStore(rootURL: root) + let old = try item(path: "old.txt") + let new = try item(path: "new.txt") + let baseline = try await store.record(directory: .root, items: [old, new]) + let oldIdentity = try XCTUnwrap( + baseline.first(where: { $0.remoteItem.path == old.path })?.identity + ) + let deletedIdentity = try XCTUnwrap( + baseline.first(where: { $0.remoteItem.path == new.path })?.identity + ) + + try await store.commit( + localMutation: .init( + refreshedDirectories: [.init(directory: .root, items: [new])], + relocations: [.init( + identity: oldIdentity, + from: old.path, + to: new.path + )], + deletedIdentities: [deletedIdentity] + ) + ) + let moved = try await store.workingSetSnapshot() + + XCTAssertEqual(moved.items.map(\.identity), [oldIdentity]) + XCTAssertEqual(moved.items.map(\.remoteItem), [new]) + let path = try await store.path(for: oldIdentity.itemIdentifier) + XCTAssertEqual(path, new.path) + } + + func testRemoteRenameUsesDeleteAndNewIdentity() async throws { + let ids = FileProviderTestIdentitySequence([ + UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000001")!, + UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000002")!, + ]) + let store = FileProviderSnapshotStore( + rootURL: root, + identityGenerator: { ids.next() } + ) + let original = try item(path: "old.txt") + let renamed = try item(path: "new.txt") + let first = try await store.record(directory: .root, items: [original]) + let firstAnchor = try await requiredCurrentAnchor(of: store) + + let second = try await store.record(directory: .root, items: [renamed]) + let secondAnchor = try await requiredCurrentAnchor(of: store) + let delta = try await store.delta(directory: .root, from: firstAnchor) + + XCTAssertNotEqual(first[0].identity, second[0].identity) + XCTAssertEqual(delta.anchor, secondAnchor) + XCTAssertEqual(delta.delta.deleted, [first[0].itemIdentifier]) + } + + func testUnshippedLegacySnapshotFileIsIgnored() async throws { + let v2URL = root.appendingPathComponent("snapshot-generations-v2.json") + let v2Data = Data("unshipped-v2-state".utf8) + try v2Data.write(to: v2URL) + let store = FileProviderSnapshotStore(rootURL: root) + + let result = try await store.record( + directory: .root, + items: [try item(path: "report.txt")] + ) + + XCTAssertEqual(result.count, 1) + XCTAssertTrue( + FileManager.default.fileExists( + atPath: root.appendingPathComponent( + "snapshot-generations-v3.json" + ).path + ) + ) + XCTAssertEqual(try Data(contentsOf: v2URL), v2Data) + } + + func testRecordPersistsMetadataForFreshStoreInstance() async throws { + let firstStore = FileProviderSnapshotStore(rootURL: root) + let documents = try item(path: "documents", type: .directory) + let document = try item(path: "documents/report.txt", size: 42) + let directory = try FileProviderRemotePath(relative: "documents") + + _ = try await firstStore.record(directory: .root, items: [documents]) + let recorded = try await firstStore.record( + directory: directory, + items: [document] + ) + let recordedAnchor = try await requiredCurrentAnchor(of: firstStore) + + let reloadedStore = FileProviderSnapshotStore(rootURL: root) + let reloadedItems = try await reloadedStore.items(directory: directory) + XCTAssertEqual(reloadedItems.map(\.remoteItem), [document]) + let unchanged = try await reloadedStore.record( + directory: directory, + items: [document] + ) + let unchangedAnchor = try await requiredCurrentAnchor(of: reloadedStore) + XCTAssertEqual(unchangedAnchor, recordedAnchor) + XCTAssertEqual(unchanged.map(\.remoteItem), recorded.map(\.remoteItem)) + XCTAssertTrue( + try FileManager.default.contentsOfDirectory(atPath: root.path) + .allSatisfy { !$0.contains(".tmp") } + ) + } + + func testEmptyInitialRecordPersistsAReusableAnchor() async throws { + let firstStore = FileProviderSnapshotStore(rootURL: root) + + let first = try await firstStore.record(directory: .root, items: []) + let firstAnchor = try await requiredCurrentAnchor(of: firstStore) + + let reloadedStore = FileProviderSnapshotStore(rootURL: root) + let delta = try await reloadedStore.delta(directory: .root, from: firstAnchor) + XCTAssertEqual(delta.anchor, firstAnchor) + XCTAssertEqual(delta.delta, FileProviderSnapshotDelta(updated: [], deleted: [])) + XCTAssertTrue(first.isEmpty) + } + + func testEvictedMalformedAndForeignAnchorsThrowSyncAnchorExpired() async throws { + let store = FileProviderSnapshotStore(rootURL: root, retainedGenerationCount: 2) + _ = try await store.record(directory: .root, items: [try item(path: "a.txt")]) + let old = try await requiredCurrentAnchor(of: store) + _ = try await store.record(directory: .root, items: [try item(path: "b.txt")]) + _ = try await store.record(directory: .root, items: [try item(path: "c.txt")]) + + let anchors: [NSFileProviderSyncAnchor] = [ + old, + NSFileProviderSyncAnchor(rawValue: Data([0xFF])), + anchor(for: 99), + ] + for anchor in anchors { + await XCTAssertThrowsErrorAsync( + try await store.delta(directory: .root, from: anchor) + ) { error in + XCTAssertEqual(error as? FileProviderSnapshotStoreError, .syncAnchorExpired) + let mapped = FileProviderErrorMapper.map(error) + XCTAssertEqual(mapped.domain, NSFileProviderErrorDomain) + XCTAssertEqual(mapped.code, NSFileProviderError.syncAnchorExpired.rawValue) + } + } + } + + func testForeignAnchorWithSameGenerationThrowsSyncAnchorExpired() async throws { + let otherRoot = root.appendingPathComponent("other-domain", isDirectory: true) + try FileManager.default.createDirectory(at: otherRoot, withIntermediateDirectories: true) + let store = FileProviderSnapshotStore(rootURL: root) + let otherStore = FileProviderSnapshotStore(rootURL: otherRoot) + + _ = try await store.record(directory: .root, items: [try item(path: "local.txt")]) + _ = try await otherStore.record(directory: .root, items: [try item(path: "foreign.txt")]) + let foreign = try await requiredCurrentAnchor(of: otherStore) + + await XCTAssertThrowsErrorAsync( + try await store.delta(directory: .root, from: foreign) + ) { error in + XCTAssertEqual(error as? FileProviderSnapshotStoreError, .syncAnchorExpired) + } + } + + func testWorkingSetUsesRetainedDomainAnchorsAndRejectsExpiredOrForeignAnchors() async throws { + let otherRoot = root.appendingPathComponent("other-domain", isDirectory: true) + try FileManager.default.createDirectory(at: otherRoot, withIntermediateDirectories: true) + let store = FileProviderSnapshotStore( + rootURL: root, + retainedGenerationCount: 2 + ) + let otherStore = FileProviderSnapshotStore(rootURL: otherRoot) + let directory = try FileProviderRemotePath(relative: "nested") + let rootItem = try item(path: "nested") + let original = try item(path: "nested/item.txt", size: 1) + let changed = try item(path: "nested/item.txt", size: 2) + + _ = try await store.record( + directory: .root, + items: [rootItem] + ) + let expired = try await requiredCurrentAnchor(of: store) + _ = try await store.record( + directory: directory, + items: [original] + ) + let retained = try await requiredCurrentAnchor(of: store) + _ = try await store.record( + directory: directory, + items: [changed] + ) + let latest = try await requiredCurrentAnchor(of: store) + _ = try await otherStore.record(directory: .root, items: [rootItem]) + _ = try await otherStore.record( + directory: directory, + items: [original] + ) + let foreign = try await requiredCurrentAnchor(of: otherStore) + + let delta = try await store.workingSetDelta(from: retained) + XCTAssertEqual(delta.anchor, latest) + XCTAssertEqual(delta.delta.updated.map(\.remoteItem), [changed]) + XCTAssertTrue(delta.delta.deleted.isEmpty) + XCTAssertEqual(generation(of: foreign), generation(of: retained)) + XCTAssertNotEqual(foreign.rawValue, retained.rawValue) + + for invalidAnchor in [ + expired, + foreign, + NSFileProviderSyncAnchor(rawValue: Data([0xFF])), + ] { + await XCTAssertThrowsErrorAsync( + try await store.workingSetDelta(from: invalidAnchor) + ) { error in + XCTAssertEqual( + error as? FileProviderSnapshotStoreError, + .syncAnchorExpired + ) + } + } + } + + func testRemovingDirectoryPrunesTrackedSubtreeAndRecreationStartsEmpty() async throws { + let store = FileProviderSnapshotStore(rootURL: root) + let nestedPath = try FileProviderRemotePath(relative: "nested") + let childPath = try FileProviderRemotePath(relative: "nested/child") + let nested = try item(path: "nested", type: .directory) + let child = try item(path: "nested/child", type: .directory) + let leaf = try item(path: "nested/child/leaf.txt") + + let rootRecord = try await store.record(directory: .root, items: [nested]) + let childRecord = try await store.record(directory: nestedPath, items: [child]) + let baseline = try await store.record(directory: childPath, items: [leaf]) + let baselineAnchor = try await requiredCurrentAnchor(of: store) + + _ = try await store.record(directory: .root, items: []) + let removedAnchor = try await requiredCurrentAnchor(of: store) + + XCTAssertEqual(generation(of: removedAnchor), generation(of: baselineAnchor) + 1) + let removedDelta = try await store.workingSetDelta(from: baselineAnchor) + XCTAssertEqual(removedDelta.anchor, removedAnchor) + XCTAssertTrue(removedDelta.delta.updated.isEmpty) + XCTAssertEqual( + removedDelta.delta.deleted, + [rootRecord[0], childRecord[0], baseline[0]] + .map(\.itemIdentifier) + .sorted { $0.rawValue < $1.rawValue } + ) + + _ = try await store.record(directory: .root, items: [nested]) + let recreatedAnchor = try await requiredCurrentAnchor(of: store) + let recreatedSnapshot = try await store.workingSetSnapshot() + XCTAssertEqual(recreatedSnapshot.anchor, recreatedAnchor) + XCTAssertEqual(recreatedSnapshot.items.map(\.remoteItem), [nested]) + let recreatedDelta = try await store.workingSetDelta(from: removedAnchor) + XCTAssertEqual(recreatedDelta.delta.updated.map(\.remoteItem), [nested]) + XCTAssertTrue(recreatedDelta.delta.deleted.isEmpty) + } + + func testRenamingDirectoryPrunesTrackedOldSubtreeInOneGeneration() async throws { + let store = FileProviderSnapshotStore(rootURL: root) + let oldPath = try FileProviderRemotePath(relative: "old") + let oldChildPath = try FileProviderRemotePath(relative: "old/child") + let old = try item(path: "old", type: .directory) + let oldChild = try item(path: "old/child", type: .directory) + let oldLeaf = try item(path: "old/child/leaf.txt") + let renamed = try item(path: "renamed", type: .directory) + + let rootRecord = try await store.record(directory: .root, items: [old]) + let childRecord = try await store.record(directory: oldPath, items: [oldChild]) + let baseline = try await store.record(directory: oldChildPath, items: [oldLeaf]) + let baselineAnchor = try await requiredCurrentAnchor(of: store) + + _ = try await store.record(directory: .root, items: [renamed]) + let changedAnchor = try await requiredCurrentAnchor(of: store) + + XCTAssertEqual(generation(of: changedAnchor), generation(of: baselineAnchor) + 1) + let delta = try await store.workingSetDelta(from: baselineAnchor) + XCTAssertEqual(delta.anchor, changedAnchor) + XCTAssertEqual(delta.delta.updated.map(\.remoteItem), [renamed]) + XCTAssertEqual( + delta.delta.deleted, + [rootRecord[0], childRecord[0], baseline[0]] + .map(\.itemIdentifier) + .sorted { $0.rawValue < $1.rawValue } + ) + let snapshot = try await store.workingSetSnapshot() + XCTAssertEqual(snapshot.items.map(\.remoteItem), [renamed]) + } + + func testReplacingDirectoryWithFilePrunesTrackedDescendantsInOneGeneration() async throws { + let store = FileProviderSnapshotStore(rootURL: root) + let nestedPath = try FileProviderRemotePath(relative: "nested") + let childPath = try FileProviderRemotePath(relative: "nested/child") + let nestedDirectory = try item(path: "nested", type: .directory) + let child = try item(path: "nested/child", type: .directory) + let leaf = try item(path: "nested/child/leaf.txt") + let nestedFile = try item(path: "nested", size: 7) + + _ = try await store.record(directory: .root, items: [nestedDirectory]) + let childRecord = try await store.record(directory: nestedPath, items: [child]) + let baseline = try await store.record(directory: childPath, items: [leaf]) + let baselineAnchor = try await requiredCurrentAnchor(of: store) + + _ = try await store.record(directory: .root, items: [nestedFile]) + let changedAnchor = try await requiredCurrentAnchor(of: store) + + XCTAssertEqual(generation(of: changedAnchor), generation(of: baselineAnchor) + 1) + let delta = try await store.workingSetDelta(from: baselineAnchor) + XCTAssertEqual(delta.anchor, changedAnchor) + XCTAssertEqual(delta.delta.updated.map(\.remoteItem), [nestedFile]) + XCTAssertEqual( + delta.delta.deleted, + [childRecord[0], baseline[0]] + .map(\.itemIdentifier) + .sorted { $0.rawValue < $1.rawValue } + ) + let snapshot = try await store.workingSetSnapshot() + XCTAssertEqual(snapshot.items.map(\.remoteItem), [nestedFile]) + } + + func testDuplicatePathsInPersistedStateThrowTypedError() async throws { + let store = FileProviderSnapshotStore(rootURL: root) + let document = try item(path: "document.txt") + _ = try await store.record(directory: .root, items: [document]) + let stateURL = root.appendingPathComponent("snapshot-generations-v3.json") + var state = try JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as! [String: Any] + var generations = state["generations"] as! [[String: Any]] + var generation = generations[0] + var directories = generation["directories"] as! [[String: Any]] + var directory = directories[0] + var items = directory["items"] as! [Any] + items.append(items[0]) + directory["items"] = items + directories[0] = directory + generation["directories"] = directories + generations[0] = generation + state["generations"] = generations + try JSONSerialization.data(withJSONObject: state, options: .sortedKeys) + .write(to: stateURL, options: .atomic) + + let reloadedStore = FileProviderSnapshotStore(rootURL: root) + await XCTAssertThrowsErrorAsync( + try await reloadedStore.items(directory: .root) + ) { error in + XCTAssertEqual(error as? FileProviderSnapshotStoreError, .duplicatePath) + let mapped = FileProviderErrorMapper.map(error) + XCTAssertEqual(mapped.domain, NSCocoaErrorDomain) + XCTAssertEqual(mapped.code, NSXPCConnectionReplyInvalid) + } + } + + func testDeleteDirectoryPrunesTrackedDescendants() async throws { + let store = FileProviderSnapshotStore(rootURL: root) + let directory = try item(path: "folder", type: .directory) + let child = try item(path: "folder/child.txt") + let rootRecord = try await store.record(directory: .root, items: [directory]) + let childRecord = try await store.record( + directory: FileProviderRemotePath(relative: "folder"), + items: [child] + ) + let directoryIdentity = rootRecord[0].identity + + try await store.commit( + localMutation: .init( + refreshedDirectories: [.init(directory: .root, items: [])], + deletedIdentities: [directoryIdentity] + ) + ) + + let deletedDirectory = try await store.item(for: rootRecord[0].itemIdentifier) + let deletedChild = try await store.item(for: childRecord[0].itemIdentifier) + XCTAssertNil(deletedDirectory) + XCTAssertNil(deletedChild) + } + + private func requiredCurrentAnchor( + of store: FileProviderSnapshotStore + ) async throws -> NSFileProviderSyncAnchor { + let anchor = try await store.currentAnchor() + return try XCTUnwrap(anchor) + } + + private func item( + path: String, + size: UInt64 = 1, + type: RemuxSFTPFileType = .regular + ) throws -> FileProviderRemoteItem { + let permissions: UInt32 + switch type { + case .regular: + permissions = 0o100644 + case .directory: + permissions = 0o040755 + case .symbolicLink: + permissions = 0o120777 + case .other: + permissions = 0 + } + return try FileProviderRemoteItem( + path: FileProviderRemotePath(relative: path), + metadata: RemuxSFTPFileMetadata( + size: size, + permissions: permissions, + modificationDate: Date(timeIntervalSince1970: 1), + type: type + ) + ) + } + + private func anchor(for generation: UInt64) -> NSFileProviderSyncAnchor { + var namespace = UUID().uuid + var bigEndian = generation.bigEndian + var data = Data(bytes: &namespace, count: 16) + data.append(Data(bytes: &bigEndian, count: MemoryLayout.size)) + return NSFileProviderSyncAnchor( + rawValue: data + ) + } + + private func generation(of anchor: NSFileProviderSyncAnchor) -> UInt64 { + let data = anchor.rawValue + XCTAssertEqual(data.count, 16 + MemoryLayout.size) + return data.suffix(MemoryLayout.size) + .reduce(0) { ($0 << 8) | UInt64($1) } + } +} + +private final class FileProviderTestIdentitySequence: @unchecked Sendable { + private let lock = NSLock() + private var values: [UUID] + + init(_ values: [UUID]) { + self.values = values + } + + func next() -> UUID { + lock.withLock { + precondition(!values.isEmpty) + return values.removeFirst() + } + } +} + +private extension XCTestCase { + func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> Value, + file: StaticString = #filePath, + line: UInt = #line, + _ errorHandler: (Error) -> Void + ) async { + do { + _ = try await expression() + XCTFail("Expected expression to throw", file: file, line: line) + } catch { + errorHandler(error) + } + } +} diff --git a/RemuxAppTests/GhosttyTerminalDisconnectReasonClassifierTests.swift b/RemuxAppTests/GhosttyTerminalDisconnectReasonClassifierTests.swift index 5ea55d1d..95b26238 100644 --- a/RemuxAppTests/GhosttyTerminalDisconnectReasonClassifierTests.swift +++ b/RemuxAppTests/GhosttyTerminalDisconnectReasonClassifierTests.swift @@ -254,6 +254,64 @@ final class TrustedHostStoreTests: XCTestCase { XCTAssertEqual(identities[0].openSSHPublicKey, "ecdsa-sha2-nistp256 received") } + func testValidatorRequiresTrustAgainWhenSavedHostnameChangesButKeyDoesNot() throws { + let root = temporaryRoot() + let server = SavedServer( + id: UUID(), + displayName: "Server", + host: "original.example.com", + username: "macbook", + identityID: UUID() + ) + let hostKey = try makeHostKey(comment: "same-key") + let openSSHPublicKey = String(openSSHPublicKey: hostKey) + let store = TrustedHostStore(rootURL: root) + try store.replaceIdentities([ + TrustedHostIdentity( + serverID: server.id, + host: server.host, + keyType: "ssh-ed25519", + openSSHPublicKey: openSSHPublicKey, + trustedAt: Date(timeIntervalSince1970: 1) + ) + ]) + + var editedServer = server + editedServer.host = "replacement.example.com" + let changedHostPromise = MultiThreadedEventLoopGroup.singleton.next().makePromise(of: Void.self) + store.validator(for: editedServer).validateHostKey( + hostKey: hostKey, + validationCompletePromise: changedHostPromise + ) + + var capturedChallenge: SSHHostKeyTrustChallenge? + XCTAssertThrowsError(try changedHostPromise.futureResult.wait()) { error in + guard case TrustedHostStoreError.hostKeyTrustRequired(let challenge) = error else { + return XCTFail("unexpected error: \(error)") + } + + capturedChallenge = challenge + XCTAssertEqual(challenge.kind, .changed) + XCTAssertEqual(challenge.serverID, server.id) + XCTAssertEqual(challenge.host, editedServer.host) + XCTAssertEqual(challenge.trustedOpenSSHPublicKey, openSSHPublicKey) + XCTAssertEqual(challenge.receivedOpenSSHPublicKey, openSSHPublicKey) + } + + try store.trustHostKey(try XCTUnwrap(capturedChallenge)) + let trustedIdentity = try XCTUnwrap(store.loadIdentities().first) + XCTAssertEqual(trustedIdentity.serverID, editedServer.id) + XCTAssertEqual(trustedIdentity.host, editedServer.host) + XCTAssertEqual(trustedIdentity.openSSHPublicKey, openSSHPublicKey) + + let acceptedPromise = MultiThreadedEventLoopGroup.singleton.next().makePromise(of: Void.self) + store.validator(for: editedServer).validateHostKey( + hostKey: hostKey, + validationCompletePromise: acceptedPromise + ) + XCTAssertNoThrow(try acceptedPromise.futureResult.wait()) + } + func testTrustHostKeyRejectsStaleChange() throws { let root = temporaryRoot() let serverID = UUID() diff --git a/RemuxAppTests/RemuxFileProviderContractTests.swift b/RemuxAppTests/RemuxFileProviderContractTests.swift new file mode 100644 index 00000000..f79dd2b0 --- /dev/null +++ b/RemuxAppTests/RemuxFileProviderContractTests.swift @@ -0,0 +1,1364 @@ +@preconcurrency import Citadel +import FileProvider +import Foundation +import NIOEmbedded +@preconcurrency import NIOSSH +import UniformTypeIdentifiers +import XCTest +@testable import Remux + +final class RemuxFileProviderContractTests: XCTestCase { + + func testSharedAuthenticationFactoryPreservesPasswordCredentials() throws { + let authentication = ResolvedSSHAuth.password( + username: "reader", + password: "test-password", + identityID: UUID(), + displayLabel: "Fixture" + ) + + let credential: SSHCredential + switch authentication.credential { + case .password(let password): + credential = .password(password) + case .privateKey(let privateKey): + credential = .privateKey(privateKey) + } + + let method = try SSHAuthenticationMethodFactory.make( + username: authentication.username, + credential: credential + ) + let eventLoop = EmbeddedEventLoop() + let promise = eventLoop.makePromise(of: NIOSSHUserAuthenticationOffer?.self) + method.nextAuthenticationType( + availableMethods: [.password], + nextChallengePromise: promise + ) + let offer = try XCTUnwrap(promise.futureResult.wait()) + + XCTAssertEqual(offer.username, "reader") + guard case .password(let password) = offer.offer else { + XCTFail("Expected password authentication") + return + } + XCTAssertEqual(password.password, "test-password") + } + + func testSSHRootKeyCanBeBuiltWithoutATmuxConnectionTarget() { + let identityID = UUID() + let server = SavedServer( + displayName: "Fixture", + host: "reader.example.test", + port: 2222, + username: "saved-user", + identityID: identityID + ) + let authentication = ResolvedSSHAuth.password( + username: "resolved-user", + password: "test-password", + identityID: identityID, + displayLabel: "Fixture" + ) + + let key = RemuxSSHRootKey(server: server, auth: authentication) + + XCTAssertEqual(key.serverID, server.id) + XCTAssertEqual(key.host, "reader.example.test") + XCTAssertEqual(key.port, 2222) + XCTAssertEqual(key.username, "resolved-user") + } + + func testPollingCoordinatorCoalescesConcurrentRefreshesForSameDirectory() async throws { + let coordinator = FileProviderDomainOperationCoordinator() + let gate = FileProviderTestRefreshGate() + let directory = try FileProviderRemotePath(relative: "shared") + let firstItems = [ + try fileProviderTestItem(relative: "shared/first.txt", size: 1), + ] + let secondItems = [ + try fileProviderTestItem(relative: "shared/second.txt", size: 2), + ] + let first = Task { + try await coordinator.performRefresh(directory: directory) { + await gate.beginAndWait() + return fileProviderTestRefresh(items: firstItems) + } + } + await gate.waitUntilStarted() + let second = Task { + try await coordinator.performRefresh(directory: directory) { + await gate.recordUnexpectedOperation() + return fileProviderTestRefresh(items: secondItems) + } + } + await coordinator.waitUntilRefreshIsCoalesced(directory: directory) + + await gate.release() + + let firstResult = try await first.value + let secondResult = try await second.value + let operationCount = await gate.operationCount() + XCTAssertEqual(firstResult.items.map(\.remoteItem), firstItems) + XCTAssertEqual(secondResult.items.map(\.remoteItem), firstItems) + XCTAssertEqual(operationCount, 1) + } + + func testPollingCoordinatorNeverReturnsAnotherDirectorysData() async throws { + let coordinator = FileProviderDomainOperationCoordinator() + let gate = FileProviderTestRefreshGate() + let firstDirectory = try FileProviderRemotePath(relative: "first") + let secondDirectory = try FileProviderRemotePath(relative: "second") + let firstItems = [ + try fileProviderTestItem(relative: "first/item.txt", size: 1), + ] + let secondItems = [ + try fileProviderTestItem(relative: "second/item.txt", size: 2), + ] + let first = Task { + try await coordinator.performRefresh(directory: firstDirectory) { + await gate.beginAndWait() + return fileProviderTestRefresh(items: firstItems) + } + } + await gate.waitUntilStarted() + let second = Task { + try await coordinator.performRefresh(directory: secondDirectory) { + await gate.recordUnexpectedOperation() + return fileProviderTestRefresh(items: secondItems) + } + } + await coordinator.waitUntilRefreshIsQueued(directory: secondDirectory) + + await gate.release() + + _ = try await first.value + let secondResult = try await second.value + let operationCount = await gate.operationCount() + XCTAssertEqual(secondResult.items.map(\.remoteItem), secondItems) + XCTAssertEqual(operationCount, 2) + } + + func testPollingCoordinatorCancelsNetworkRefreshWhenOnlyRequesterCancels() async throws { + let coordinator = FileProviderDomainOperationCoordinator() + let gate = FileProviderTestRefreshGate() + let directory = try FileProviderRemotePath(relative: "cancelled") + let items = [ + try fileProviderTestItem(relative: "cancelled/item.txt", size: 1), + ] + let refresh = Task { + try await coordinator.performRefresh(directory: directory) { + try await gate.beginCancellableAndWait() + return fileProviderTestRefresh(items: items) + } + } + await gate.waitUntilStarted() + + refresh.cancel() + for _ in 0..<20 { + await Task.yield() + } + let wasCancelled = await gate.wasCancelled() + if !wasCancelled { + await gate.release() + } + + await XCTAssertFileProviderThrowsAsync { try await refresh.value } + XCTAssertTrue(wasCancelled) + } + + func testCancellingOneCoalescedRequesterKeepsSharedRefreshAlive() async throws { + let coordinator = FileProviderDomainOperationCoordinator() + let gate = FileProviderTestRefreshGate() + let directory = try FileProviderRemotePath(relative: "coalesced") + let items = [ + try fileProviderTestItem(relative: "coalesced/item.txt", size: 1), + ] + let first = Task { + try await coordinator.performRefresh(directory: directory) { + try await gate.beginCancellableAndWait() + return fileProviderTestRefresh(items: items) + } + } + await gate.waitUntilStarted() + let second = Task { + try await coordinator.performRefresh(directory: directory) { + await gate.recordUnexpectedOperation() + return fileProviderTestRefresh(items: []) + } + } + await coordinator.waitUntilRefreshIsCoalesced(directory: directory) + second.cancel() + let networkWasCancelled = await gate.wasCancelled() + await gate.release() + + let firstResult = try await first.value + await XCTAssertFileProviderThrowsAsync { try await second.value } + XCTAssertEqual(firstResult.items.map(\.remoteItem), items) + XCTAssertFalse(networkWasCancelled) + } + + func testCancellingCoalescedRequesterReturnsBeforeSharedRefreshFinishes() async throws { + let coordinator = FileProviderDomainOperationCoordinator() + let gate = FileProviderTestRefreshGate() + let completion = FileProviderTestCompletionFlag() + let directory = try FileProviderRemotePath(relative: "coalesced") + let items = [ + try fileProviderTestItem(relative: "coalesced/item.txt", size: 1), + ] + let first = Task { + try await coordinator.performRefresh(directory: directory) { + try await gate.beginCancellableAndWait() + return fileProviderTestRefresh(items: items) + } + } + await gate.waitUntilStarted() + let second = Task { + do { + let result = try await coordinator.performRefresh(directory: directory) { + await gate.recordUnexpectedOperation() + return fileProviderTestRefresh(items: []) + } + await completion.finish() + return result + } catch { + await completion.finish() + throw error + } + } + await coordinator.waitUntilRefreshIsCoalesced(directory: directory) + + second.cancel() + + let didFinishBeforeRefresh = await completion.waitUntilFinished( + timeout: .seconds(1) + ) + let networkWasCancelled = await gate.wasCancelled() + await gate.release() + + let firstResult = try await first.value + await XCTAssertFileProviderThrowsAsync { try await second.value } + XCTAssertTrue(didFinishBeforeRefresh) + XCTAssertEqual(firstResult.items.map(\.remoteItem), items) + XCTAssertFalse(networkWasCancelled) + } + + func testCancellingRequesterWaitingForAnotherDirectoryReturnsBeforeSharedRefreshFinishes() async throws { + let coordinator = FileProviderDomainOperationCoordinator() + let gate = FileProviderTestRefreshGate() + let completion = FileProviderTestCompletionFlag() + let firstDirectory = try FileProviderRemotePath(relative: "first") + let secondDirectory = try FileProviderRemotePath(relative: "second") + let firstItems = [ + try fileProviderTestItem(relative: "first/item.txt", size: 1), + ] + let first = Task { + try await coordinator.performRefresh(directory: firstDirectory) { + try await gate.beginCancellableAndWait() + return fileProviderTestRefresh(items: firstItems) + } + } + await gate.waitUntilStarted() + let second = Task { + do { + let result = try await coordinator.performRefresh(directory: secondDirectory) { + await gate.recordUnexpectedOperation() + return fileProviderTestRefresh(items: []) + } + await completion.finish() + return result + } catch { + await completion.finish() + throw error + } + } + await coordinator.waitUntilRefreshIsQueued(directory: secondDirectory) + + second.cancel() + + let didFinishBeforeRefresh = await completion.waitUntilFinished( + timeout: .seconds(1) + ) + let networkWasCancelled = await gate.wasCancelled() + await gate.release() + + _ = try await first.value + await XCTAssertFileProviderThrowsAsync { try await second.value } + let operationCount = await gate.operationCount() + XCTAssertTrue(didFinishBeforeRefresh) + XCTAssertFalse(networkWasCancelled) + XCTAssertEqual(operationCount, 1) + } + + func testEnumeratorCoreRefreshesImmediatelyAndRecordsTerminalPage() async throws { + let home = "/home/reader" + let metadata = RemuxSFTPFileMetadata( + size: 7, + permissions: 0o100644, + modificationDate: Date(timeIntervalSince1970: 500) + ) + let client = FileProviderTestSFTPClient( + realPaths: [".": .success(home)], + listings: [ + home: [RemuxSFTPDirectoryEntry(name: "now.txt", metadata: metadata)], + ] + ) + let service = try await FileProviderRemoteServiceFixture.make(client: client).service + let snapshotRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let snapshots = FileProviderSnapshotStore(rootURL: snapshotRoot) + let core = FileProviderEnumeratorCore( + scope: .directory(.root), + service: service, + snapshots: snapshots, + coordinator: FileProviderDomainOperationCoordinator(), + signaler: FileProviderTestSignaler() + ) + + let page = try await core.enumerateItems() + + XCTAssertEqual(page.items.map(\.remoteItem.name), ["now.txt"]) + XCTAssertNil(page.nextPage) + XCTAssertNotNil(page.anchor) + let persisted = try await snapshots.items(directory: .root) + XCTAssertEqual(persisted, page.items) + let currentAnchor = try await core.currentSyncAnchor() + XCTAssertEqual(currentAnchor, page.anchor) + } + + func testEnumeratorCoreReportsRetainedSnapshotChanges() async throws { + let home = "/home/reader" + let client = FileProviderTestSFTPClient( + realPaths: [".": .success(home)], + listings: [home: []] + ) + let service = try await FileProviderRemoteServiceFixture.make(client: client).service + let snapshotRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let snapshots = FileProviderSnapshotStore(rootURL: snapshotRoot) + let original = try fileProviderTestItem(relative: "changed.txt", size: 1) + let removed = try fileProviderTestItem(relative: "removed.txt", size: 2) + let requested = try await snapshots.record( + directory: .root, + items: [original, removed] + ) + let updated = try fileProviderTestItem(relative: "changed.txt", size: 3) + let latest = try await snapshots.record(directory: .root, items: [updated]) + let core = FileProviderEnumeratorCore( + scope: .directory(.root), + service: service, + snapshots: snapshots, + coordinator: FileProviderDomainOperationCoordinator(), + signaler: FileProviderTestSignaler() + ) + + let changes = try await core.enumerateChanges(from: requested.anchor) + + XCTAssertEqual(changes.updated.map(\.remoteItem), [updated]) + XCTAssertEqual( + changes.deleted, + [requested.items[1].itemIdentifier] + ) + XCTAssertFalse(changes.moreComing) + XCTAssertEqual(changes.anchor, latest.anchor) + } + + func testCoalescedOldRefreshCannotOverwriteNewerSnapshot() async throws { + let snapshotRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let oldItem = try fileProviderTestItem(relative: "item.txt", size: 1) + let newItem = try fileProviderTestItem(relative: "item.txt", size: 2) + let refreshGate = FileProviderTestRefreshGate() + let service = FileProviderTestSequencedRemoteService( + listings: [[oldItem], [newItem]], + firstRefreshGate: refreshGate + ) + let coordinator = FileProviderDomainOperationCoordinator() + let primaryCore = FileProviderEnumeratorCore( + scope: .directory(.root), + service: service, + snapshots: FileProviderSnapshotStore(rootURL: snapshotRoot), + coordinator: coordinator, + signaler: FileProviderTestSignaler() + ) + let blockingGate = FileProviderBlockingGate() + let blockingFileManager = FileProviderBlockingFileManager( + gate: blockingGate + ) + let delayedCore = FileProviderEnumeratorCore( + scope: .directory(.root), + service: service, + snapshots: FileProviderSnapshotStore( + rootURL: snapshotRoot, + fileManager: blockingFileManager + ), + coordinator: coordinator, + signaler: FileProviderTestSignaler() + ) + + let first = Task { + try await primaryCore.enumerateItems() + } + await refreshGate.waitUntilStarted() + let delayedOldRefresh = Task { + try await delayedCore.enumerateItems() + } + for _ in 0..<20 { + await Task.yield() + } + await refreshGate.release() + + _ = try await first.value + let callCountAfterCoalescing = await service.listCallCount() + let newerPage = try await primaryCore.enumerateItems() + blockingGate.release() + _ = try await delayedOldRefresh.value + + let persisted = try await FileProviderSnapshotStore(rootURL: snapshotRoot) + .items(directory: .root) + XCTAssertEqual(callCountAfterCoalescing, 1) + XCTAssertEqual(newerPage.items.map(\.remoteItem), [newItem]) + XCTAssertEqual(persisted.map(\.remoteItem), [newItem]) + } + + func testWorkingSetAggregatesNestedCreateUpdateDeleteAndSignalsOnlyWorkingSet() async throws { + let directory = try FileProviderRemotePath(relative: "nested") + let rootItem = try fileProviderTestItem( + relative: "nested", + size: 0, + type: .directory + ) + let original = try fileProviderTestItem( + relative: "nested/updated.txt", + size: 1 + ) + let removed = try fileProviderTestItem( + relative: "nested/deleted.txt", + size: 2 + ) + let changed = try fileProviderTestItem( + relative: "nested/updated.txt", + size: 3 + ) + let created = try fileProviderTestItem( + relative: "nested/created.txt", + size: 4 + ) + let snapshotRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let snapshots = FileProviderSnapshotStore(rootURL: snapshotRoot) + let rootRecord = try await snapshots.record(directory: .root, items: [rootItem]) + let baseline = try await snapshots.record( + directory: directory, + items: [original, removed] + ) + let signaler = FileProviderTestSignaler() + let nestedCore = FileProviderEnumeratorCore( + scope: .directory(directory), + service: FileProviderTestSequencedRemoteService( + listings: [[changed, created]] + ), + snapshots: snapshots, + coordinator: FileProviderDomainOperationCoordinator(), + signaler: signaler + ) + let workingSetCore = FileProviderEnumeratorCore( + scope: .workingSet, + service: FileProviderTestSequencedRemoteService(listings: [[]]), + snapshots: snapshots, + coordinator: FileProviderDomainOperationCoordinator(), + signaler: signaler + ) + + try await nestedCore.refreshAndSignalChanges() + let page = try await workingSetCore.enumerateItems() + let changes = try await workingSetCore.enumerateChanges( + from: baseline.anchor + ) + + XCTAssertEqual(page.items.map(\.remoteItem), [rootItem, created, changed]) + XCTAssertEqual(changes.updated.map(\.remoteItem), [created, changed]) + XCTAssertEqual( + changes.updated.map(\.parentIdentity), + [rootRecord.items[0].identity, rootRecord.items[0].identity] + ) + XCTAssertEqual( + changes.deleted, + [ + try XCTUnwrap( + baseline.items.first { + $0.remoteItem.path == removed.path + } + ).itemIdentifier, + ] + ) + let signals = await signaler.signaledIdentifiers() + XCTAssertEqual(signals, [.workingSet]) + } + + func testChangedDirectoryPollSignalsOnlyWorkingSet() async throws { + let home = "/home/reader" + let original = RemuxSFTPFileMetadata( + size: 1, + permissions: 0o100644, + modificationDate: Date(timeIntervalSince1970: 600) + ) + let changed = RemuxSFTPFileMetadata( + size: 2, + permissions: 0o100644, + modificationDate: Date(timeIntervalSince1970: 601) + ) + let client = FileProviderSequencedSFTPClient( + home: home, + listings: [ + [RemuxSFTPDirectoryEntry(name: "item.txt", metadata: original)], + [RemuxSFTPDirectoryEntry(name: "item.txt", metadata: changed)], + ] + ) + let service = try await FileProviderRemoteServiceFixture.make(client: client).service + let snapshots = FileProviderSnapshotStore( + rootURL: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + ) + let signaler = FileProviderTestSignaler() + let core = FileProviderEnumeratorCore( + scope: .directory(.root), + service: service, + snapshots: snapshots, + coordinator: FileProviderDomainOperationCoordinator(), + signaler: signaler + ) + _ = try await core.enumerateItems() + + try await core.refreshAndSignalChanges() + + let signals = await signaler.signaledIdentifiers() + XCTAssertEqual(signals, [.workingSet]) + } + + func testUnchangedNestedPollDoesNotAdvanceGenerationOrSignalAgain() async throws { + let directory = try FileProviderRemotePath(relative: "nested") + let rootItem = try fileProviderTestItem( + relative: "nested", + size: 0, + type: .directory + ) + let item = try fileProviderTestItem( + relative: "nested/same.txt", + size: 1 + ) + let snapshots = FileProviderSnapshotStore( + rootURL: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + ) + _ = try await snapshots.record(directory: .root, items: [rootItem]) + let signaler = FileProviderTestSignaler() + let core = FileProviderEnumeratorCore( + scope: .directory(directory), + service: FileProviderTestSequencedRemoteService( + listings: [[item], [item], [item]] + ), + snapshots: snapshots, + coordinator: FileProviderDomainOperationCoordinator(), + signaler: signaler + ) + _ = try await core.enumerateItems() + try await core.refreshAndSignalChanges() + let currentAnchorBeforeUnchangedPoll = try await core.currentSyncAnchor() + let anchorBeforeUnchangedPoll = try XCTUnwrap( + currentAnchorBeforeUnchangedPoll + ) + + try await core.refreshAndSignalChanges() + + let currentAnchorAfterUnchangedPoll = try await core.currentSyncAnchor() + let anchorAfterUnchangedPoll = try XCTUnwrap( + currentAnchorAfterUnchangedPoll + ) + let signals = await signaler.signaledIdentifiers() + XCTAssertEqual(anchorAfterUnchangedPoll, anchorBeforeUnchangedPoll) + XCTAssertEqual(signals, [.workingSet]) + } + + func testCancelledSnapshotRecordDoesNotPersistAfterStorageBoundary() async throws { + let snapshotRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let blockingGate = FileProviderBlockingGate() + let snapshots = FileProviderSnapshotStore( + rootURL: snapshotRoot, + fileManager: FileProviderBlockingFileManager(gate: blockingGate) + ) + let item = try fileProviderTestItem(relative: "cancelled.txt", size: 1) + let record = Task { + try await snapshots.record(directory: .root, items: [item]) + } + await blockingGate.waitUntilEntered() + + record.cancel() + blockingGate.release() + + await XCTAssertFileProviderThrowsAsync { try await record.value } + let stateURL = snapshotRoot.appendingPathComponent( + "snapshot-generations.json" + ) + XCTAssertFalse(FileManager.default.fileExists(atPath: stateURL.path)) + } + + func testCancelledNestedSignalIsDrainedByReopenedWorkingSet() async throws { + let directory = try FileProviderRemotePath(relative: "nested") + let rootItem = try fileProviderTestItem( + relative: "nested", + size: 0, + type: .directory + ) + let original = try fileProviderTestItem( + relative: "nested/item.txt", + size: 1 + ) + let changed = try fileProviderTestItem( + relative: "nested/item.txt", + size: 2 + ) + let service = FileProviderTestSequencedRemoteService( + listings: [[original], [changed]] + ) + let signaler = FileProviderBlockingSignaler() + let snapshotRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let snapshots = FileProviderSnapshotStore(rootURL: snapshotRoot) + _ = try await snapshots.record(directory: .root, items: [rootItem]) + let core = FileProviderEnumeratorCore( + scope: .directory(directory), + service: service, + snapshots: snapshots, + coordinator: FileProviderDomainOperationCoordinator(), + signaler: signaler + ) + _ = try await core.enumerateItems() + let refresh = Task { + try await core.refreshAndSignalChanges() + } + await signaler.waitUntilFirstSignal() + + refresh.cancel() + await signaler.release() + + await XCTAssertFileProviderThrowsAsync { try await refresh.value } + let identifiers = await signaler.signaledIdentifiers() + XCTAssertEqual(identifiers, [.workingSet]) + + let retrySignaler = FileProviderTestSignaler() + let reopenedCore = FileProviderEnumeratorCore( + scope: .workingSet, + service: FileProviderTestSequencedRemoteService( + listings: [[changed]] + ), + snapshots: FileProviderSnapshotStore(rootURL: snapshotRoot), + coordinator: FileProviderDomainOperationCoordinator(), + signaler: retrySignaler + ) + + try await reopenedCore.refreshAndSignalChanges() + try await reopenedCore.refreshAndSignalChanges() + + let retryIdentifiers = await retrySignaler.signaledIdentifiers() + XCTAssertEqual(retryIdentifiers, [.workingSet]) + } + + func testFailedSignalDeliveryRemainsPendingUntilRetrySucceeds() async throws { + let original = try fileProviderTestItem(relative: "item.txt", size: 1) + let changed = try fileProviderTestItem(relative: "item.txt", size: 2) + let signaler = FileProviderFailingOnceSignaler() + let core = FileProviderEnumeratorCore( + scope: .directory(.root), + service: FileProviderTestSequencedRemoteService( + listings: [[original], [changed]] + ), + snapshots: FileProviderSnapshotStore( + rootURL: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + ), + coordinator: FileProviderDomainOperationCoordinator(), + signaler: signaler + ) + _ = try await core.enumerateItems() + + await XCTAssertFileProviderThrowsAsync { + try await core.refreshAndSignalChanges() + } + try await core.refreshAndSignalChanges() + try await core.refreshAndSignalChanges() + + let identifiers = await signaler.signaledIdentifiers() + XCTAssertEqual(identifiers, [.workingSet]) + } + + func testOlderSignalDeliveryDoesNotAcknowledgeNewerSnapshot() async throws { + let original = try fileProviderTestItem(relative: "item.txt", size: 1) + let firstChange = try fileProviderTestItem( + relative: "item.txt", + size: 2 + ) + let secondChange = try fileProviderTestItem( + relative: "item.txt", + size: 3 + ) + let snapshotRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let blockingSignaler = FileProviderBlockingSignaler() + let core = FileProviderEnumeratorCore( + scope: .directory(.root), + service: FileProviderTestSequencedRemoteService( + listings: [[original], [firstChange], [secondChange]] + ), + snapshots: FileProviderSnapshotStore(rootURL: snapshotRoot), + coordinator: FileProviderDomainOperationCoordinator(), + signaler: blockingSignaler + ) + _ = try await core.enumerateItems() + let firstDelivery = Task { + try await core.refreshAndSignalChanges() + } + await blockingSignaler.waitUntilFirstSignal() + + let newerPage = try await core.enumerateItems() + XCTAssertEqual(newerPage.items.map(\.remoteItem), [secondChange]) + await blockingSignaler.release() + try await firstDelivery.value + + let retrySignaler = FileProviderTestSignaler() + let reopenedCore = FileProviderEnumeratorCore( + scope: .workingSet, + service: FileProviderTestSequencedRemoteService( + listings: [[secondChange]] + ), + snapshots: FileProviderSnapshotStore(rootURL: snapshotRoot), + coordinator: FileProviderDomainOperationCoordinator(), + signaler: retrySignaler + ) + + try await reopenedCore.refreshAndSignalChanges() + try await reopenedCore.refreshAndSignalChanges() + + let retryIdentifiers = await retrySignaler.signaledIdentifiers() + XCTAssertEqual(retryIdentifiers, [.workingSet]) + } + + func testPollingLoopRefreshesImmediatelyWhenEnumeratorOpens() async { + let refreshes = FileProviderTestRefreshCounter() + let loop = FileProviderPollingLoop( + clock: FileProviderTestPollingClock(), + refresh: { + await refreshes.record() + } + ) + + loop.start() + await refreshes.wait(for: 1) + + let refreshCount = await refreshes.count() + XCTAssertEqual(refreshCount, 1) + loop.invalidate() + } + + func testPollingLoopWaitsFiveSecondsBetweenRefreshes() async { + let refreshes = FileProviderTestRefreshCounter() + let clock = FileProviderTestPollingClock() + let loop = FileProviderPollingLoop( + clock: clock, + refresh: { + await refreshes.record() + } + ) + + loop.start() + await refreshes.wait(for: 1) + for _ in 0..<20 { + await Task.yield() + } + let durations = await clock.requestedDurations() + XCTAssertEqual(durations, [.seconds(5)]) + guard !durations.isEmpty else { + loop.invalidate() + return + } + + await clock.advance() + await refreshes.wait(for: 2) + + let refreshCount = await refreshes.count() + XCTAssertEqual(refreshCount, 2) + loop.invalidate() + } + + func testPollingLoopStartsRefreshesOnFiveSecondCadenceWhenRefreshTakesTime() async { + let refreshes = FileProviderTestRefreshCounter() + let clock = FileProviderTestPollingClock() + let loop = FileProviderPollingLoop( + clock: clock, + refresh: { + await clock.recordRefreshStart() + await clock.elapse(.seconds(2)) + await refreshes.record() + } + ) + + loop.start() + await refreshes.wait(for: 1) + for _ in 0..<20 { + await Task.yield() + } + let durations = await clock.requestedDurations() + XCTAssertEqual(durations, [.seconds(3)]) + guard !durations.isEmpty else { + loop.invalidate() + return + } + + await clock.advance() + await refreshes.wait(for: 2) + + let startTimes = await clock.recordedRefreshStartTimes() + XCTAssertEqual(startTimes, [.zero, .seconds(5)]) + loop.invalidate() + } + + func testPollingLoopInvalidationCancelsCurrentRefresh() async { + let gate = FileProviderTestRefreshGate() + let clock = FileProviderTestPollingClock() + let loop = FileProviderPollingLoop( + clock: clock, + refresh: { + try await gate.beginCancellableAndWait() + } + ) + loop.start() + await gate.waitUntilStarted() + + loop.invalidate() + for _ in 0..<20 { + await Task.yield() + } + let wasCancelled = await gate.wasCancelled() + if !wasCancelled { + await gate.release() + for _ in 0..<20 { + await Task.yield() + } + await clock.cancelAll() + } + + XCTAssertTrue(wasCancelled) + } + + func testPollingLoopInvalidationWaitsForCurrentRefreshToFinish() async { + let gate = FileProviderTestRefreshGate() + let completion = FileProviderTestCompletionFlag() + let loop = FileProviderPollingLoop( + clock: FileProviderTestPollingClock(), + refresh: { + await gate.beginAndWait() + } + ) + loop.start() + await gate.waitUntilStarted() + + loop.invalidate() + let drain = Task { + await loop.waitUntilInvalidated() + await completion.finish() + } + let finishedBeforeRelease = await completion.waitUntilFinished( + timeout: .milliseconds(100) + ) + XCTAssertFalse(finishedBeforeRelease) + + await gate.release() + await drain.value + + let finishedAfterRelease = await completion.waitUntilFinished( + timeout: .seconds(1) + ) + XCTAssertTrue(finishedAfterRelease) + } + +} + +private actor FileProviderTestCompletionFlag { + private var finished = false + private var waiters: [UUID: CheckedContinuation] = [:] + + func finish() { + finished = true + let waiters = self.waiters.values + self.waiters.removeAll() + waiters.forEach { $0.resume(returning: true) } + } + + func waitUntilFinished(timeout: Duration) async -> Bool { + guard !finished else { return true } + let waiterID = UUID() + return await withCheckedContinuation { continuation in + waiters[waiterID] = continuation + Task { + try? await Task.sleep(for: timeout) + timeOut(waiterID: waiterID) + } + } + } + + private func timeOut(waiterID: UUID) { + waiters.removeValue(forKey: waiterID)?.resume(returning: false) + } +} + +private actor FileProviderTestSequencedRemoteService: FileProviderRemoteServicing { + private let listings: [[FileProviderRemoteItem]] + private let firstRefreshGate: FileProviderTestRefreshGate? + private var nextListingIndex = 0 + private var mutationAccessCallCount = 0 + + init( + listings: [[FileProviderRemoteItem]], + firstRefreshGate: FileProviderTestRefreshGate? = nil + ) { + self.listings = listings + self.firstRefreshGate = firstRefreshGate + } + + func item(at path: FileProviderRemotePath) throws -> FileProviderRemoteItem { + throw RemuxSFTPClientError.noSuchFile(path.relative) + } + + func list(directory: FileProviderRemotePath) async -> [FileProviderRemoteItem] { + let index = min(nextListingIndex, listings.count - 1) + nextListingIndex += 1 + if index == 0, let firstRefreshGate { + await firstRefreshGate.beginAndWait() + } + return listings[index] + } + + func fetch( + path: FileProviderRemotePath, + to localURL: URL, + progress: @escaping @Sendable (FileProviderRemoteFetchProgress) async -> Void + ) throws -> FileProviderRemoteItem { + throw RemuxSFTPClientError.noSuchFile(path.relative) + } + + func invalidate() { + } + + func withMutationAccess( + _ operation: @Sendable (any FileProviderRemoteMutationAccess) async throws -> Value + ) throws -> Value { + mutationAccessCallCount += 1 + throw RemuxSFTPClientError.noSuchFile("mutation access") + } + + func listCallCount() -> Int { + nextListingIndex + } +} + +private actor FileProviderBlockingSignaler: FileProviderEnumeratorSignaling { + private var identifiers: [NSFileProviderItemIdentifier] = [] + private var firstSignalWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + private var isReleased = false + + func signalEnumerator(for identifier: NSFileProviderItemIdentifier) async { + identifiers.append(identifier) + guard identifiers.count == 1 else { return } + + let firstSignalWaiters = self.firstSignalWaiters + self.firstSignalWaiters.removeAll() + firstSignalWaiters.forEach { $0.resume() } + guard !isReleased else { return } + await withCheckedContinuation { continuation in + releaseWaiters.append(continuation) + } + } + + func waitUntilFirstSignal() async { + guard identifiers.isEmpty else { return } + await withCheckedContinuation { continuation in + firstSignalWaiters.append(continuation) + } + } + + func release() { + isReleased = true + let releaseWaiters = self.releaseWaiters + self.releaseWaiters.removeAll() + releaseWaiters.forEach { $0.resume() } + } + + func signaledIdentifiers() -> [NSFileProviderItemIdentifier] { + identifiers + } +} + +private final class FileProviderBlockingGate: @unchecked Sendable { + private let releaseSemaphore = DispatchSemaphore(value: 0) + private let lock = NSLock() + private var hasEntered = false + private var entryWaiters: [CheckedContinuation] = [] + + func wait() { + let entryWaiters = lock.withLock { + hasEntered = true + let entryWaiters = self.entryWaiters + self.entryWaiters.removeAll() + return entryWaiters + } + entryWaiters.forEach { $0.resume() } + releaseSemaphore.wait() + } + + func waitUntilEntered() async { + await withCheckedContinuation { continuation in + let shouldResume = lock.withLock { + guard !hasEntered else { return true } + entryWaiters.append(continuation) + return false + } + if shouldResume { + continuation.resume() + } + } + } + + func release() { + releaseSemaphore.signal() + } +} + +private final class FileProviderBlockingFileManager: FileManager { + private let gate: FileProviderBlockingGate + + init(gate: FileProviderBlockingGate) { + self.gate = gate + super.init() + } + + override func fileExists(atPath path: String) -> Bool { + gate.wait() + return super.fileExists(atPath: path) + } +} + +private func XCTAssertFileProviderThrowsAsync( + _ expression: () async throws -> T, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + XCTFail("Expected expression to throw", file: file, line: line) + } catch { + } +} + +private func fileProviderTestItem( + relative: String, + size: UInt64, + type: RemuxSFTPFileType = .regular +) throws -> FileProviderRemoteItem { + try FileProviderRemoteItem( + path: FileProviderRemotePath(relative: relative), + metadata: RemuxSFTPFileMetadata( + size: size, + permissions: 0o100644, + modificationDate: Date(timeIntervalSince1970: TimeInterval(size)), + type: type + ) + ) +} + +private func fileProviderTestRefresh( + items: [FileProviderRemoteItem] +) -> FileProviderPollingRefresh { + let identifiedItems = items.map { + FileProviderIdentifiedItem( + identity: .item(UUID()), + parentIdentity: .root, + remoteItem: $0 + ) + } + return FileProviderPollingRefresh( + items: identifiedItems, + anchor: NSFileProviderSyncAnchor(rawValue: Data()), + delta: FileProviderSnapshotDelta(updated: identifiedItems, deleted: []) + ) +} + +private actor FileProviderTestRefreshGate { + private var count = 0 + private var isStarted = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + private var isReleased = false + private var cancellationObserved = false + + func beginAndWait() async { + count += 1 + isStarted = true + let waiters = startWaiters + startWaiters.removeAll() + waiters.forEach { $0.resume() } + + guard !isReleased else { return } + await withCheckedContinuation { continuation in + releaseWaiters.append(continuation) + } + } + + func recordUnexpectedOperation() { + count += 1 + } + + func beginCancellableAndWait() async throws { + try await withTaskCancellationHandler { + await beginAndWait() + try Task.checkCancellation() + } onCancel: { + Task { + await self.recordCancellationAndRelease() + } + } + } + + func waitUntilStarted() async { + guard !isStarted else { return } + await withCheckedContinuation { continuation in + startWaiters.append(continuation) + } + } + + func release() { + isReleased = true + let waiters = releaseWaiters + releaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + func operationCount() -> Int { + count + } + + func wasCancelled() -> Bool { + cancellationObserved + } + + private func recordCancellationAndRelease() { + cancellationObserved = true + release() + } +} + +private actor FileProviderTestSignaler: FileProviderEnumeratorSignaling { + private var identifiers: [NSFileProviderItemIdentifier] = [] + + func signalEnumerator(for identifier: NSFileProviderItemIdentifier) { + identifiers.append(identifier) + } + + func signaledIdentifiers() -> [NSFileProviderItemIdentifier] { + identifiers + } +} + +private actor FileProviderFailingOnceSignaler: + FileProviderEnumeratorSignaling +{ + private enum Failure: Error { + case rejected + } + + private var shouldFail = true + private var identifiers: [NSFileProviderItemIdentifier] = [] + + func signalEnumerator( + for identifier: NSFileProviderItemIdentifier + ) throws { + if shouldFail { + shouldFail = false + throw Failure.rejected + } + identifiers.append(identifier) + } + + func signaledIdentifiers() -> [NSFileProviderItemIdentifier] { + identifiers + } +} + +private actor FileProviderSequencedSFTPClient: RemuxSFTPFileProviderClient { + private let home: String + private let listings: [[RemuxSFTPDirectoryEntry]] + private var listingIndex = 0 + + init(home: String, listings: [[RemuxSFTPDirectoryEntry]]) { + self.home = home + self.listings = listings + } + + func realPath(atPath path: String) async throws -> String { + guard path == "." else { + throw RemuxSFTPClientError.noSuchFile(path) + } + return home + } + + func listDirectory(atPath path: String) async throws -> [RemuxSFTPDirectoryEntry] { + guard path == home, !listings.isEmpty else { + throw RemuxSFTPClientError.noSuchFile(path) + } + let index = min(listingIndex, listings.count - 1) + listingIndex += 1 + return listings[index] + } + + func metadata(atPath path: String) async throws -> RemuxSFTPFileMetadata { + let name = (path as NSString).lastPathComponent + guard let metadata = listings.last?.first(where: { $0.name == name })?.metadata else { + throw RemuxSFTPClientError.noSuchFile(path) + } + return metadata + } + + func linkMetadata(atPath path: String) async throws -> RemuxSFTPFileMetadata { + try await metadata(atPath: path) + } + + func withFile( + atPath path: String, + _ operation: @Sendable (RemuxSFTPReadableFile) async throws -> ReturnValue + ) async throws -> ReturnValue { + throw RemuxSFTPClientError.noSuchFile(path) + } + + func createDirectory(atPath path: String) async throws { + throw RemuxSFTPClientError.noSuchFile(path) + } + + func uploadFile( + from localURL: URL, + to remotePath: String, + progress: @escaping RemuxSFTPFileUploadProgressHandler + ) async throws { + throw RemuxSFTPClientError.noSuchFile(remotePath) + } + + func renameItem(from sourcePath: String, to destinationPath: String) async throws { + throw RemuxSFTPClientError.noSuchFile(sourcePath) + } + + func removeFile(atPath path: String) async throws { + throw RemuxSFTPClientError.noSuchFile(path) + } + + func removeEmptyDirectory(atPath path: String) async throws { + throw RemuxSFTPClientError.noSuchFile(path) + } +} + +private actor FileProviderTestRefreshCounter { + private var refreshCount = 0 + private var waiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func record() { + refreshCount += 1 + let ready = waiters.filter { refreshCount >= $0.count } + waiters.removeAll { refreshCount >= $0.count } + ready.forEach { $0.continuation.resume() } + } + + func wait(for count: Int) async { + guard refreshCount < count else { return } + await withCheckedContinuation { continuation in + waiters.append((count, continuation)) + } + } + + func count() -> Int { + refreshCount + } +} + +private actor FileProviderTestPollingClock: FileProviderPollingClock { + private struct Waiter { + let id: UUID + let duration: Duration + let continuation: CheckedContinuation + } + + private var currentTime: Duration = .zero + private var durations: [Duration] = [] + private var refreshStartTimes: [Duration] = [] + private var waiters: [Waiter] = [] + + func now() -> Duration { + currentTime + } + + func sleep(for duration: Duration) async throws { + try Task.checkCancellation() + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + durations.append(duration) + waiters.append( + Waiter( + id: id, + duration: duration, + continuation: continuation + ) + ) + } + } onCancel: { + Task { + await self.cancel(id: id) + } + } + } + + func requestedDurations() -> [Duration] { + durations + } + + func recordRefreshStart() { + refreshStartTimes.append(currentTime) + } + + func recordedRefreshStartTimes() -> [Duration] { + refreshStartTimes + } + + func elapse(_ duration: Duration) { + currentTime += duration + } + + func advance() { + guard !waiters.isEmpty else { return } + let waiter = waiters.removeFirst() + currentTime += waiter.duration + waiter.continuation.resume() + } + + func cancelAll() { + let waiters = self.waiters + self.waiters.removeAll() + waiters.forEach { $0.continuation.resume(throwing: CancellationError()) } + } + + private func cancel(id: UUID) { + guard let index = waiters.firstIndex(where: { $0.id == id }) else { + return + } + let waiter = waiters.remove(at: index) + waiter.continuation.resume(throwing: CancellationError()) + } +} diff --git a/RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift b/RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift new file mode 100644 index 00000000..2b9d9cb5 --- /dev/null +++ b/RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift @@ -0,0 +1,689 @@ +@preconcurrency import Citadel +import Foundation +import NIO +import XCTest + +@testable import Remux + +final class RemuxSFTPReadOnlyClientTests: XCTestCase { + func testFileProviderWriteOperationsCallExactCitadelRequests() async throws { + let connection = FakeCitadelSFTPConnection(writableFile: RecordingUploadSource()) + let client = makeClient(connection: connection) + let source = try temporaryFile(contents: Data("new".utf8)) + defer { try? FileManager.default.removeItem(at: source) } + + try await client.createDirectory(atPath: "/home/me/new") + try await client.uploadFile( + from: source, + to: "/home/me/.remux-upload-fixture", + progress: { _ in } + ) + try await client.renameItem( + from: "/home/me/.remux-upload-fixture", + to: "/home/me/report.txt" + ) + try await client.removeFile(atPath: "/home/me/report.txt") + try await client.removeEmptyDirectory(atPath: "/home/me/new") + + let mutations = await connection.mutations() + XCTAssertEqual(mutations, [ + .mkdir("/home/me/new"), + .openWrite( + "/home/me/.remux-upload-fixture", + flags: [.write, .create, .forceCreate] + ), + .rename("/home/me/.remux-upload-fixture", "/home/me/report.txt"), + .removeFile("/home/me/report.txt"), + .rmdir("/home/me/new"), + ]) + } + + func testFileProviderWritePermissionDeniedNormalizesToTypedError() { + XCTAssertEqual( + RemuxCitadelSFTPClient.normalizedWriteError(for: .permissionDenied), + .permissionDenied + ) + } + + func testFileProviderAmbiguousWriteFailureNormalizesToUnsupportedMutation() { + XCTAssertEqual( + RemuxCitadelSFTPClient.normalizedWriteError(for: .failure), + .unsupportedMutation + ) + } + + func testFileProviderUploadCancellationClosesRemoteHandleWithoutMutations() async throws { + let source = CancellableUploadSource() + let connection = FakeCitadelSFTPConnection(writableFile: source) + let client = makeClient(connection: connection) + let localFile = try temporaryFile(contents: Data("new".utf8)) + defer { try? FileManager.default.removeItem(at: localFile) } + + let task = Task { + try await client.uploadFile( + from: localFile, + to: "/home/me/.remux-upload-fixture", + progress: { _ in } + ) + } + + for _ in 0..<1_000 { + if await source.writeCount() == 1 { break } + try await Task.sleep(for: .milliseconds(1)) + } + let writeCount = await source.writeCount() + XCTAssertEqual(writeCount, 1) + task.cancel() + + do { + try await task.value + XCTFail("cancelled upload should throw") + } catch is CancellationError { + } + + let closeCount = await source.closeCount() + let mutations = await connection.mutations() + XCTAssertEqual(closeCount, 1) + XCTAssertEqual(mutations, [ + .openWrite( + "/home/me/.remux-upload-fixture", + flags: [.write, .create, .forceCreate] + ), + ]) + } + + func testFileProviderUploadFailsWhenRemoteDestinationAlreadyExists() async throws { + let source = RecordingUploadSource() + let connection = FakeCitadelSFTPConnection( + writableFile: source, + existingWritePaths: ["/home/me/report.txt"] + ) + let client = makeClient(connection: connection) + let localFile = try temporaryFile(contents: Data("new".utf8)) + defer { try? FileManager.default.removeItem(at: localFile) } + + do { + try await client.uploadFile( + from: localFile, + to: "/home/me/report.txt", + progress: { _ in } + ) + XCTFail("existing remote destination should reject a strict upload") + } catch is FakeCitadelSFTPConnectionError { + } + + let mutations = await connection.mutations() + let writeCount = await source.writeCount() + XCTAssertEqual(mutations, [ + .openWrite( + "/home/me/report.txt", + flags: [.write, .create, .forceCreate] + ), + ]) + XCTAssertEqual(writeCount, 0) + } + + func testDirectoryListingFlattensResponsesDropsDotEntriesAndClassifiesModes() async throws { + // Catches returning Citadel response batches without flattening, hiding dotfiles, + // or comparing full permission values instead of the POSIX file-type bits. + let connection = FakeCitadelSFTPConnection( + directoryResponses: [ + ".": [ + RemuxCitadelSFTPDirectoryResponse( + components: [ + component(".", permissions: 0o040755), + component("folder", permissions: 0o040750), + component(".env", permissions: 0o100600), + ] + ), + RemuxCitadelSFTPDirectoryResponse( + components: [ + component("..", permissions: 0o040755), + component("link", permissions: 0o120777), + component("socket", permissions: 0o140600), + ] + ), + ], + ] + ) + let client = makeClient(connection: connection) + + let entries = try await client.listDirectory(atPath: ".") + + XCTAssertEqual(entries.map(\.name), ["folder", ".env", "link", "socket"]) + XCTAssertEqual(entries.first(named: "folder")?.metadata.type, .directory) + XCTAssertEqual(entries.first(named: ".env")?.metadata.type, .regular) + XCTAssertEqual(entries.first(named: "link")?.metadata.type, .symbolicLink) + XCTAssertEqual(entries.first(named: "socket")?.metadata.type, .other) + } + + func testLinkMetadataListsParentAndMatchesBasenameWithoutFollowingLink() async throws { + // Catches implementing link metadata with stat/getAttributes, which follows links, + // or listing the link path itself instead of its parent. + let expectedDate = Date(timeIntervalSince1970: 1_721_234_567) + let connection = FakeCitadelSFTPConnection( + directoryResponses: [ + "/home/demo": [ + RemuxCitadelSFTPDirectoryResponse( + components: [ + component( + "link", + size: 12, + permissions: 0o120777, + modificationDate: expectedDate + ), + ] + ), + ], + ] + ) + let client = makeClient(connection: connection) + + let metadata = try await client.linkMetadata(atPath: "/home/demo/link") + + XCTAssertEqual( + metadata, + RemuxSFTPFileMetadata( + size: 12, + permissions: 0o120777, + modificationDate: expectedDate, + type: .symbolicLink + ) + ) + let listedPaths = await connection.listedPaths() + let attributePaths = await connection.attributePaths() + XCTAssertEqual(listedPaths, ["/home/demo"]) + XCTAssertEqual(attributePaths, []) + } + + func testLinkMetadataThrowsTypedMissingFileWhenBasenameIsAbsent() async throws { + // Catches treating a missing directory entry as default metadata or an empty result. + let connection = FakeCitadelSFTPConnection( + directoryResponses: [ + "/home/demo": [ + RemuxCitadelSFTPDirectoryResponse( + components: [component("other", permissions: 0o100600)] + ), + ], + ] + ) + let client = makeClient(connection: connection) + + do { + _ = try await client.linkMetadata(atPath: "/home/demo/missing") + XCTFail("missing basename should throw") + } catch let error as RemuxSFTPClientError { + XCTAssertEqual(error, .noSuchFile("/home/demo/missing")) + } + } + + func testListDirectoryPropagatesTypedMissingPath() async throws { + // Catches swallowing a missing remote directory and returning an empty listing. + let connection = FakeCitadelSFTPConnection( + directoryFailures: ["/missing": .noSuchFile("/missing")] + ) + let client = makeClient(connection: connection) + + do { + _ = try await client.listDirectory(atPath: "/missing") + XCTFail("missing directory should throw") + } catch let error as RemuxSFTPClientError { + XCTAssertEqual(error, .noSuchFile("/missing")) + } + } + + func testListDirectoryTimesOutThroughCitadelOperationBoundary() async throws { + // Catches bypassing the concrete client's operation timeout for directory reads. + let connection = FakeCitadelSFTPConnection( + directoryDelay: Duration.seconds(30) + ) + let client = makeClient( + connection: connection, + operationTimeout: .milliseconds(20) + ) + + do { + _ = try await client.listDirectory(atPath: ".") + XCTFail("stalled listing should time out") + } catch let error as RemuxSFTPClientError { + XCTAssertEqual(error, .operationTimedOut) + } + } + + func testDownloadUsesBoundedMonotonicReadsReportsProgressAndWritesBytes() async throws { + // Catches unbounded reads, repeated offsets, non-cumulative progress, or dropping + // a final short chunk. + let firstChunk = Data(repeating: 1, count: RemuxSFTPReadableFile.maximumChunkLength) + let finalChunk = Data([2]) + let source = RecordingDownloadSource(chunks: [firstChunk, finalChunk, Data()]) + let progress = Int64Recorder() + let client = makeClient( + connection: FakeCitadelSFTPConnection(readableFile: source) + ) + let destination = temporaryURL() + defer { try? FileManager.default.removeItem(at: destination) } + + try await client.downloadFile(atPath: "/large", to: destination) { value in + await progress.append(value) + } + + let requests = await source.requests() + let progressValues = await progress.values() + XCTAssertEqual( + requests, + [ + .init(offset: 0, length: RemuxSFTPReadableFile.maximumChunkLength), + .init( + offset: UInt64(RemuxSFTPReadableFile.maximumChunkLength), + length: RemuxSFTPReadableFile.maximumChunkLength + ), + .init( + offset: UInt64(RemuxSFTPReadableFile.maximumChunkLength + 1), + length: RemuxSFTPReadableFile.maximumChunkLength + ), + ] + ) + XCTAssertEqual( + progressValues, + [ + Int64(RemuxSFTPReadableFile.maximumChunkLength), + Int64(RemuxSFTPReadableFile.maximumChunkLength + 1), + ] + ) + XCTAssertEqual( + try Data(contentsOf: destination), + firstChunk + finalChunk + ) + let closeCount = await source.closeCount() + XCTAssertEqual(closeCount, 1) + } + + func testDownloadPropagatesTimeoutAndRemovesPartialFile() async throws { + // Catches retaining a corrupt partial file or translating away the remote timeout. + let source = TimeoutDownloadSource() + let client = makeClient( + connection: FakeCitadelSFTPConnection(readableFile: source) + ) + let destination = temporaryURL() + + do { + try await client.downloadFile(atPath: "/stalls", to: destination) { _ in } + XCTFail("remote timeout should throw") + } catch let error as RemuxSFTPClientError { + XCTAssertEqual(error, .operationTimedOut) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: destination.path)) + let closeCount = await source.closeCount() + XCTAssertEqual(closeCount, 1) + } + + func testDownloadCancellationRemovesPartialFile() async throws { + // Catches checking cancellation only before the first remote read or leaving the + // already-written prefix behind when a later read is cancelled. + let source = CancellableDownloadSource() + let client = makeClient( + connection: FakeCitadelSFTPConnection(readableFile: source) + ) + let destination = temporaryURL() + let task = Task { + try await client.downloadFile(atPath: "/cancel", to: destination) { _ in } + } + + for _ in 0..<1_000 { + if await source.readCount() == 2 { break } + try await Task.sleep(for: .milliseconds(1)) + } + let readCount = await source.readCount() + XCTAssertEqual(readCount, 2) + task.cancel() + + do { + try await task.value + XCTFail("cancelled download should throw") + } catch is CancellationError { + } + XCTAssertFalse(FileManager.default.fileExists(atPath: destination.path)) + let closeCount = await source.closeCount() + XCTAssertEqual(closeCount, 1) + } + + private func makeClient( + connection: FakeCitadelSFTPConnection, + operationTimeout: TimeAmount = .seconds(1) + ) -> RemuxCitadelSFTPClient { + RemuxCitadelSFTPClient( + connection: connection, + chunkSize: 4 * 1024 * 1024, + operationTimeout: operationTimeout, + leaseState: RemuxSFTPLeaseTeardown(closeBorrowedChild: {}) + ) + } + + private func component( + _ filename: String, + size: UInt64? = nil, + permissions: UInt32?, + modificationDate: Date? = nil + ) -> RemuxCitadelSFTPDirectoryComponent { + RemuxCitadelSFTPDirectoryComponent( + filename: filename, + attributes: RemuxCitadelSFTPAttributes( + size: size, + permissions: permissions, + modificationDate: modificationDate + ) + ) + } + + private func temporaryURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + } + + private func temporaryFile(contents: Data) throws -> URL { + let url = temporaryURL() + try contents.write(to: url) + return url + } +} + +private extension Array where Element == RemuxSFTPDirectoryEntry { + func first(named name: String) -> RemuxSFTPDirectoryEntry? { + first { $0.name == name } + } +} + +private struct DownloadReadRequest: Equatable, Sendable { + let offset: UInt64 + let length: Int +} + +private actor RecordingDownloadSource: RemuxCitadelSFTPFile { + private var chunks: [Data] + private var recordedRequests: [DownloadReadRequest] = [] + private var recordedCloseCount = 0 + + init(chunks: [Data]) { + self.chunks = chunks + } + + func readData(from offset: UInt64, length: UInt32) -> Data { + recordedRequests.append(.init(offset: offset, length: Int(length))) + return chunks.removeFirst() + } + + func writeDataPipelined( + _ data: Data, + at offset: UInt64, + maxInFlight: Int + ) throws { + throw FakeCitadelSFTPConnectionError.unexpectedFileWrite + } + + func close() { + recordedCloseCount += 1 + } + + func requests() -> [DownloadReadRequest] { + recordedRequests + } + + func closeCount() -> Int { + recordedCloseCount + } +} + +private actor TimeoutDownloadSource: RemuxCitadelSFTPFile { + private var count = 0 + private var recordedCloseCount = 0 + + func readData(from offset: UInt64, length: UInt32) throws -> Data { + count += 1 + if count == 1 { + return Data([1]) + } + throw RemuxSFTPClientError.operationTimedOut + } + + func writeDataPipelined( + _ data: Data, + at offset: UInt64, + maxInFlight: Int + ) throws { + throw FakeCitadelSFTPConnectionError.unexpectedFileWrite + } + + func close() { + recordedCloseCount += 1 + } + + func closeCount() -> Int { + recordedCloseCount + } +} + +private actor CancellableDownloadSource: RemuxCitadelSFTPFile { + private var count = 0 + private var recordedCloseCount = 0 + + func readData(from offset: UInt64, length: UInt32) async throws -> Data { + count += 1 + if count == 1 { + return Data([1]) + } + try await Task.sleep(for: .seconds(30)) + return Data() + } + + func writeDataPipelined( + _ data: Data, + at offset: UInt64, + maxInFlight: Int + ) throws { + throw FakeCitadelSFTPConnectionError.unexpectedFileWrite + } + + func close() { + recordedCloseCount += 1 + } + + func readCount() -> Int { + count + } + + func closeCount() -> Int { + recordedCloseCount + } +} + +private actor RecordingUploadSource: RemuxCitadelSFTPFile { + private var recordedWriteCount = 0 + + func readData(from offset: UInt64, length: UInt32) throws -> Data { + throw FakeCitadelSFTPConnectionError.unexpectedFileRead + } + + func writeDataPipelined( + _ data: Data, + at offset: UInt64, + maxInFlight: Int + ) { + recordedWriteCount += 1 + } + + func close() {} + + func writeCount() -> Int { + recordedWriteCount + } +} + +private actor CancellableUploadSource: RemuxCitadelSFTPFile { + private var recordedWriteCount = 0 + private var recordedCloseCount = 0 + + func readData(from offset: UInt64, length: UInt32) throws -> Data { + throw FakeCitadelSFTPConnectionError.unexpectedFileRead + } + + func writeDataPipelined( + _ data: Data, + at offset: UInt64, + maxInFlight: Int + ) async throws { + recordedWriteCount += 1 + try await Task.sleep(for: .seconds(30)) + } + + func close() { + recordedCloseCount += 1 + } + + func writeCount() -> Int { + recordedWriteCount + } + + func closeCount() -> Int { + recordedCloseCount + } +} + +private actor Int64Recorder { + private var recordedValues: [Int64] = [] + + func append(_ value: Int64) { + recordedValues.append(value) + } + + func values() -> [Int64] { + recordedValues + } +} + +private actor FakeCitadelSFTPConnection: RemuxCitadelSFTPConnection { + private let directoryResponses: [String: [RemuxCitadelSFTPDirectoryResponse]] + private let directoryFailures: [String: RemuxSFTPClientError] + private let directoryDelay: Duration? + private let readableFile: (any RemuxCitadelSFTPFile)? + private let writableFile: (any RemuxCitadelSFTPFile)? + private let existingWritePaths: Set + private let mutationFailure: Error? + private var recordedListedPaths: [String] = [] + private var recordedAttributePaths: [String] = [] + private var recordedMutations: [Mutation] = [] + + init( + directoryResponses: [String: [RemuxCitadelSFTPDirectoryResponse]] = [:], + directoryFailures: [String: RemuxSFTPClientError] = [:], + directoryDelay: Duration? = nil, + readableFile: (any RemuxCitadelSFTPFile)? = nil, + writableFile: (any RemuxCitadelSFTPFile)? = nil, + existingWritePaths: Set = [], + mutationFailure: Error? = nil + ) { + self.directoryResponses = directoryResponses + self.directoryFailures = directoryFailures + self.directoryDelay = directoryDelay + self.readableFile = readableFile + self.writableFile = writableFile + self.existingWritePaths = existingWritePaths + self.mutationFailure = mutationFailure + } + + func remuxRealPath(atPath path: String) -> String { + path + } + + func remuxListDirectory( + atPath path: String + ) async throws -> [RemuxCitadelSFTPDirectoryResponse] { + recordedListedPaths.append(path) + if let directoryDelay { + try await Task.sleep(for: directoryDelay) + } + if let failure = directoryFailures[path] { + throw failure + } + return directoryResponses[path] ?? [] + } + + func remuxGetAttributes(atPath path: String) -> RemuxCitadelSFTPAttributes { + recordedAttributePaths.append(path) + return RemuxCitadelSFTPAttributes( + size: nil, + permissions: nil, + modificationDate: nil + ) + } + + func remuxOpenFileForReading( + atPath path: String + ) async throws -> any RemuxCitadelSFTPFile { + guard let readableFile else { + throw FakeCitadelSFTPConnectionError.unexpectedFileOpen + } + return readableFile + } + + func remuxOpenFileForWriting( + atPath path: String, + flags: SFTPOpenFileFlags + ) async throws -> any RemuxCitadelSFTPFile { + recordedMutations.append(.openWrite(path, flags: flags)) + if let mutationFailure { throw mutationFailure } + if existingWritePaths.contains(path), flags.contains(.forceCreate) { + throw FakeCitadelSFTPConnectionError.destinationAlreadyExists + } + guard let writableFile else { + throw FakeCitadelSFTPConnectionError.unexpectedFileOpen + } + return writableFile + } + + func remuxCreateDirectory(atPath path: String) throws { + recordedMutations.append(.mkdir(path)) + if let mutationFailure { throw mutationFailure } + } + + func remuxRename(from sourcePath: String, to destinationPath: String) throws { + recordedMutations.append(.rename(sourcePath, destinationPath)) + if let mutationFailure { throw mutationFailure } + } + + func remuxRemove(atPath path: String) throws { + recordedMutations.append(.removeFile(path)) + if let mutationFailure { throw mutationFailure } + } + + func remuxRemoveDirectory(atPath path: String) throws { + recordedMutations.append(.rmdir(path)) + if let mutationFailure { throw mutationFailure } + } + + func listedPaths() -> [String] { + recordedListedPaths + } + + func attributePaths() -> [String] { + recordedAttributePaths + } + + func mutations() -> [Mutation] { + recordedMutations + } + + enum Mutation: Equatable { + case mkdir(String) + case openWrite(String, flags: SFTPOpenFileFlags) + case rename(String, String) + case removeFile(String) + case rmdir(String) + } +} + +private enum FakeCitadelSFTPConnectionError: Error { + case unexpectedFileOpen + case unexpectedFileRead + case unexpectedFileWrite + case destinationAlreadyExists +} diff --git a/RemuxAppTests/TerminalPreviewFileLoaderTests.swift b/RemuxAppTests/TerminalPreviewFileLoaderTests.swift index e8b47fe6..70b4aab2 100644 --- a/RemuxAppTests/TerminalPreviewFileLoaderTests.swift +++ b/RemuxAppTests/TerminalPreviewFileLoaderTests.swift @@ -210,6 +210,14 @@ private struct PreviewSFTPClient: RemuxSFTPReadOnlyClient { var delayFromOffset: UInt64? = nil var maximumReturnedChunkLength: Int? = nil + func realPath(atPath path: String) async throws -> String { + path + } + + func listDirectory(atPath path: String) async throws -> [RemuxSFTPDirectoryEntry] { + [] + } + func metadata(atPath path: String) async throws -> RemuxSFTPFileMetadata { RemuxSFTPFileMetadata( size: declaredSize, @@ -218,6 +226,10 @@ private struct PreviewSFTPClient: RemuxSFTPReadOnlyClient { ) } + func linkMetadata(atPath path: String) async throws -> RemuxSFTPFileMetadata { + try await metadata(atPath: path) + } + func withFile( atPath path: String, _ operation: @Sendable (RemuxSFTPReadableFile) async throws -> ReturnValue diff --git a/project.yml b/project.yml index 8035fe46..004794f2 100644 --- a/project.yml +++ b/project.yml @@ -38,6 +38,7 @@ targets: settings: base: ASSETCATALOG_COMPILER_APPICON_NAME: Remux + CODE_SIGN_ENTITLEMENTS: RemuxApp/Remux.entitlements SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO TARGETED_DEVICE_FAMILY: "1" preBuildScripts: @@ -49,6 +50,7 @@ targets: - framework: ../ghostty-remux-upstream-rebuild/macos/GhosttyKit.xcframework embed: false - package: Citadel + - sdk: FileProvider.framework info: path: RemuxApp/Info.plist properties: @@ -58,6 +60,8 @@ targets: ITSAppUsesNonExemptEncryption: false NSMicrophoneUsageDescription: Remux uses the microphone so you can dictate messages. NSSpeechRecognitionUsageDescription: Remux uses on-device speech recognition to turn your dictation into text. + RemuxApplicationKeychainAccessGroup: $(AppIdentifierPrefix)dev.remux.app + RemuxSharedKeychainAccessGroup: $(AppIdentifierPrefix)dev.remux.shared UILaunchScreen: {} UIApplicationSupportsIndirectInputEvents: true UISupportedInterfaceOrientations: