From 40e4a45e4afe60f21316e09513b52497c4674bd9 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 30 Jul 2026 09:47:29 -0700 Subject: [PATCH 01/16] fileprovider: configure shared app and keychain storage Configure Remux with the accepted shared App Group root and explicit application and shared Keychain access groups. Keep the credential service identity centralized while allowing the existing Keychain store to target either access group through the accepted structured query path. Preserve the exact accepted F1 test boundary: shared-root resolution, explicit access-group query construction, and shared configuration lookup. The focused suite first failed at the expected missing symbols, then passed 3 tests with 0 failures and 0 skips. A Remux simulator build also succeeded. Generate the app entitlement, plist values, and Xcode project membership from the accepted configuration with XcodeGen 2.44.1; consecutive generations were deterministic. Shared-state migration, live application/shared Keychain separation, and all File Provider extension wiring remain deferred to F2 and later branches. --- Remux.xcodeproj/project.pbxproj | 10 +++- RemuxApp/Info.plist | 4 ++ RemuxApp/Remux.entitlements | 15 ++++++ .../Persistence/ApplicationStorage.swift | 47 +++++++++++++++++++ .../Persistence/SSHCredentialStore.swift | 29 ++++++++++-- .../FileProviderSharedStorageTests.swift | 36 ++++++++++++++ project.yml | 3 ++ 7 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 RemuxApp/Remux.entitlements create mode 100644 RemuxAppTests/FileProviderSharedStorageTests.swift diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index d2b378a1..2c80482e 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -107,6 +107,7 @@ 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 */; }; @@ -242,7 +243,7 @@ 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 = ""; }; 1970DDCD2CEF7C163B953BAC /* SSHAuthResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHAuthResolver.swift; sourceTree = ""; }; @@ -314,6 +315,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 = ""; }; @@ -511,6 +513,7 @@ 6DF29AE5BA0A3DD1E7A9E9C5 /* ActiveSessionSwitcherProjectionTests.swift */, 28EE543D2B247AE6C6A8C472 /* ConnectionProfileRepositoryTests.swift */, DFD9AF0371D15A892B000530 /* DebugConnectionProfileSeederTests.swift */, + 6BEAB20D60BA0EDED606847A /* FileProviderSharedStorageTests.swift */, 2A1CB6877AF169792137FA56 /* GhosttyAttachmentImageMarkupRendererTests.swift */, 6B657FFBCF25A6A1A77FD231 /* GhosttyAttachmentImagePreviewDataTests.swift */, 84DD89E37AC083383B988185 /* GhosttyAttachmentPasteboardSnapshotTests.swift */, @@ -854,6 +857,7 @@ }; }; buildConfigurationList = F118F8E44563891091219E31 /* Build configuration list for PBXProject "Remux" */; + compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -866,7 +870,6 @@ 63D5E4632C8CA0BBCE0D90A0 /* XCRemoteSwiftPackageReference "Citadel" */, ); preferredProjectObjectVersion = 77; - productRefGroup = F94500072A3763A6590AF480 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -1060,6 +1063,7 @@ 00D89A7D58890CC2E420D6E6 /* ActiveSessionSwitcherProjectionTests.swift in Sources */, 8F75231F2CF7E597F4D0347F /* ConnectionProfileRepositoryTests.swift in Sources */, 434C420D221F1CAF2A633602 /* DebugConnectionProfileSeederTests.swift in Sources */, + 7BA64A5EF27BE1C503CAC3B8 /* FileProviderSharedStorageTests.swift in Sources */, EE488E8B043F3160004A3C20 /* GhosttyAttachmentImageMarkupRendererTests.swift in Sources */, BCFDD04E99F80E73053BE38A /* GhosttyAttachmentImagePreviewDataTests.swift in Sources */, 7C99EDFDCE3B0927D26C1029 /* GhosttyAttachmentPasteboardSnapshotTests.swift in Sources */, @@ -1355,6 +1359,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 +1401,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/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/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/RemuxAppTests/FileProviderSharedStorageTests.swift b/RemuxAppTests/FileProviderSharedStorageTests.swift new file mode 100644 index 00000000..2232484c --- /dev/null +++ b/RemuxAppTests/FileProviderSharedStorageTests.swift @@ -0,0 +1,36 @@ +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" + ) + } +} diff --git a/project.yml b/project.yml index 8035fe46..137884d5 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: @@ -58,6 +59,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: From 226b68c554fc90d7a0a61bae9d603d4ed3b69745 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 30 Jul 2026 10:03:34 -0700 Subject: [PATCH 02/16] fileprovider: migrate profiles and SSH state into shared storage Copy profiles, credentials, and hostname-bound trust into the shared container without deleting application-local source state. Verify copied credentials and repository/trust snapshots before atomically writing the migration marker, so failed attempts remain retryable and completed attempts are idempotent. Expose only the application/shared Keychain store factory at this layer. Migration construction, authoritative shared-repository selection, and lifecycle activation remain intentionally absent for the final app-integration layer. Preserve the SSH setup rollback-only trust APIs while adding bulk migration access and binding accepted trust to both server identity and hostname. Verification: accepted tests produced the expected missing-helper compile RED; the focused migration, shared-storage, and trusted-host suites passed 11 tests with 0 failures and 0 skips; the Remux simulator build succeeded. XcodeGen 2.44.1 was deterministic, runtime production delta is 124 lines, and all complete blobs/hunks match their accepted provenance. --- Remux.xcodeproj/project.pbxproj | 8 + .../Sources/App/RemuxAppDependencies.swift | 23 ++ .../FileProviderSharedStorageMigrator.swift | 83 +++++++ .../Persistence/TrustedHostStore.swift | 18 +- ...leProviderSharedStorageMigratorTests.swift | 221 ++++++++++++++++++ .../FileProviderSharedStorageTests.swift | 18 ++ ...minalDisconnectReasonClassifierTests.swift | 58 +++++ 7 files changed, 427 insertions(+), 2 deletions(-) create mode 100644 RemuxApp/Sources/Persistence/FileProviderSharedStorageMigrator.swift create mode 100644 RemuxAppTests/FileProviderSharedStorageMigratorTests.swift diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index 2c80482e..812d0630 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 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 */; }; 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 */; }; @@ -174,6 +175,7 @@ D78DD9D2C761CC45DB856D42 /* GhosttyTopLevelSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88095738876B31835109065A /* GhosttyTopLevelSurface.swift */; }; D7E2EF5FBDBD0CC24FC29257 /* ShortcutExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B77D12E9FC8E4FF183EC209 /* ShortcutExecutorTests.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 */; }; DEC0360F7ED54DE71B345B60 /* GhosttyTerminalPresentationProjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D407500AB027C206B65EE7C /* GhosttyTerminalPresentationProjector.swift */; }; DED4088C1635F130FC578C91 /* GhosttyTmuxPrefixInputBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A9F4B4B0260131778AB2BA5 /* GhosttyTmuxPrefixInputBufferTests.swift */; }; @@ -329,6 +331,7 @@ 771B767054F02C52F17EB92B /* StarterShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarterShortcuts.swift; sourceTree = ""; }; 78CD307268384537F5CF1219 /* TmuxPaneSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxPaneSurface.swift; sourceTree = ""; }; 7C826602D9C963622FB26A40 /* TerminalThemePreviewRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalThemePreviewRenderer.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 = ""; }; 820136FA2586D2054D312C3D /* SSHPrivateKeyInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPrivateKeyInspector.swift; sourceTree = ""; }; @@ -418,6 +421,7 @@ 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 = ""; }; @@ -513,6 +517,7 @@ 6DF29AE5BA0A3DD1E7A9E9C5 /* ActiveSessionSwitcherProjectionTests.swift */, 28EE543D2B247AE6C6A8C472 /* ConnectionProfileRepositoryTests.swift */, DFD9AF0371D15A892B000530 /* DebugConnectionProfileSeederTests.swift */, + EC00AAA8C4518CE1F9530C00 /* FileProviderSharedStorageMigratorTests.swift */, 6BEAB20D60BA0EDED606847A /* FileProviderSharedStorageTests.swift */, 2A1CB6877AF169792137FA56 /* GhosttyAttachmentImageMarkupRendererTests.swift */, 6B657FFBCF25A6A1A77FD231 /* GhosttyAttachmentImagePreviewDataTests.swift */, @@ -622,6 +627,7 @@ children = ( AE5B67646A0F14933B3FD8D8 /* ApplicationStorage.swift */, 48AC2CE228C16D7E9DCD09BE /* ConnectionProfileRepository.swift */, + 7F7619C21FCE4B90EA93C0B2 /* FileProviderSharedStorageMigrator.swift */, 0C7EBCE00319FF2879275BDD /* JSONFileStore.swift */, DA9987EA1F7B5FDC3A50ECEA /* ShortcutStore.swift */, AD50E6815BED6CB2E3B793C9 /* SSHCredentialStore.swift */, @@ -927,6 +933,7 @@ E35A4C6758E1D06E01EE5826 /* ConnectionProfileRepository.swift in Sources */, 39A58132CBB849E2CDF37BC2 /* DebugConnectionProfileSeeder.swift in Sources */, 3266F0AE9A1F836618FF115D /* DeterministicTmuxControlTransport.swift in Sources */, + 0F4E56668E1D070DA2549277 /* FileProviderSharedStorageMigrator.swift in Sources */, A7AFC27CD8E0B30AFF1A32BF /* GhosttyAttachmentImageMarkupEditor.swift in Sources */, 4C8C5E176A192D50E70F86FE /* GhosttyAttachmentImagePreviewData.swift in Sources */, 5588618C234196DDFB933E26 /* GhosttyAttachmentInteractiveImagePreview.swift in Sources */, @@ -1063,6 +1070,7 @@ 00D89A7D58890CC2E420D6E6 /* ActiveSessionSwitcherProjectionTests.swift in Sources */, 8F75231F2CF7E597F4D0347F /* ConnectionProfileRepositoryTests.swift in Sources */, 434C420D221F1CAF2A633602 /* DebugConnectionProfileSeederTests.swift in Sources */, + DB3525FC134F5E53968FBB63 /* FileProviderSharedStorageMigratorTests.swift in Sources */, 7BA64A5EF27BE1C503CAC3B8 /* FileProviderSharedStorageTests.swift in Sources */, EE488E8B043F3160004A3C20 /* GhosttyAttachmentImageMarkupRendererTests.swift in Sources */, BCFDD04E99F80E73053BE38A /* GhosttyAttachmentImagePreviewDataTests.swift in Sources */, 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/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/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/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 index 2232484c..269422cb 100644 --- a/RemuxAppTests/FileProviderSharedStorageTests.swift +++ b/RemuxAppTests/FileProviderSharedStorageTests.swift @@ -33,4 +33,22 @@ final class FileProviderSharedStorageTests: XCTestCase { "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/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() From efb4e9708527f29969b1091f3fcb86466b8e358d Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 30 Jul 2026 10:22:50 -0700 Subject: [PATCH 03/16] fileprovider: model deterministic SSH domain reconciliation Transplant the accepted inactive File Provider domain model exactly as reviewed. Eligible records require a saved SSH credential and trusted host identity matching both server ID and hostname. UUID-derived domain identifiers remain stable, display-name changes reconcile as remove/add pairs, and a FIFO actor gate serializes concurrent callers. Keep this leaf deliberately unreachable: it links FileProvider.framework into the app target but adds no dependency factory, live instance, RootModel or lifecycle invocation, host-mutation hook, extension target, or compatibility path. Registry and storage failures propagate after the gate is released; no new retry or cleanup behavior is introduced. The accepted three-test blob covers eligibility, deterministic add/rename/remove behavior with concurrent reconciliation serialized to one mutation, and host-change removal until replacement hostname trust. The tests-first build failed at the expected missing F3 types. The final focused suite passed 3/3 with no failures or skips, and the Remux iPhone 17 simulator build succeeded. XcodeGen 2.44.1 generated the same PBX blob twice. --- Remux.xcodeproj/project.pbxproj | 20 ++ .../FileProviderDomainReconciler.swift | 148 +++++++++++ .../FileProviderDomainReconcilerTests.swift | 238 ++++++++++++++++++ project.yml | 1 + 4 files changed, 407 insertions(+) create mode 100644 RemuxApp/Sources/FileProvider/FileProviderDomainReconciler.swift create mode 100644 RemuxAppTests/FileProviderDomainReconcilerTests.swift diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index 812d0630..39105295 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 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 */; }; + 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 */; }; @@ -153,6 +154,7 @@ 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 */; }; BE129D63D6BDCAA96AC4A8CF /* GhosttyTerminalResponderFocusPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB312A306DE375B66AB05D39 /* GhosttyTerminalResponderFocusPolicyTests.swift */; }; @@ -177,6 +179,7 @@ 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 */; }; @@ -355,6 +358,7 @@ 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 = ""; }; @@ -367,10 +371,12 @@ 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 = ""; }; + 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 = ""; }; @@ -447,6 +453,7 @@ files = ( E312086F6A463AC00BC93C8E /* GhosttyKit.xcframework in Frameworks */, EE0A712F52F48C1AB61D7AEC /* Citadel in Frameworks */, + BA54398BF907C951435CA359 /* FileProvider.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -517,6 +524,7 @@ 6DF29AE5BA0A3DD1E7A9E9C5 /* ActiveSessionSwitcherProjectionTests.swift */, 28EE543D2B247AE6C6A8C472 /* ConnectionProfileRepositoryTests.swift */, DFD9AF0371D15A892B000530 /* DebugConnectionProfileSeederTests.swift */, + AC73E66BB7BA53B5E6E31B31 /* FileProviderDomainReconcilerTests.swift */, EC00AAA8C4518CE1F9530C00 /* FileProviderSharedStorageMigratorTests.swift */, 6BEAB20D60BA0EDED606847A /* FileProviderSharedStorageTests.swift */, 2A1CB6877AF169792137FA56 /* GhosttyAttachmentImageMarkupRendererTests.swift */, @@ -617,6 +625,7 @@ 3AB596FFF4943E6A358B8717 /* Frameworks */ = { isa = PBXGroup; children = ( + B338421BEF0BEA78717BB3D5 /* FileProvider.framework */, 0CB12DC1EC78CE1C1286BA4E /* GhosttyKit.xcframework */, ); name = Frameworks; @@ -647,6 +656,14 @@ path = RemuxApp; sourceTree = ""; }; + 8114E55BD2CD34EEF8661EFC /* FileProvider */ = { + isa = PBXGroup; + children = ( + 9B50C6E14DA1AD375C55E17B /* FileProviderDomainReconciler.swift */, + ); + path = FileProvider; + sourceTree = ""; + }; 8940332584E6E630AC566417 /* RemuxAppUITests */ = { isa = PBXGroup; children = ( @@ -674,6 +691,7 @@ children = ( 32334451C65B7C4F00E30040 /* App */, D9A30ED1878BFD3CD31E92C7 /* Domain */, + 8114E55BD2CD34EEF8661EFC /* FileProvider */, ECBBAB0FEBB89BD77EBA3C07 /* Ghostty */, 5533378BF561DDFC4C936F16 /* Persistence */, 8FABD5271C2C8D005DA63033 /* Preview */, @@ -933,6 +951,7 @@ E35A4C6758E1D06E01EE5826 /* ConnectionProfileRepository.swift in Sources */, 39A58132CBB849E2CDF37BC2 /* DebugConnectionProfileSeeder.swift in Sources */, 3266F0AE9A1F836618FF115D /* DeterministicTmuxControlTransport.swift in Sources */, + 15636E0B3091E161F61321C1 /* FileProviderDomainReconciler.swift in Sources */, 0F4E56668E1D070DA2549277 /* FileProviderSharedStorageMigrator.swift in Sources */, A7AFC27CD8E0B30AFF1A32BF /* GhosttyAttachmentImageMarkupEditor.swift in Sources */, 4C8C5E176A192D50E70F86FE /* GhosttyAttachmentImagePreviewData.swift in Sources */, @@ -1070,6 +1089,7 @@ 00D89A7D58890CC2E420D6E6 /* ActiveSessionSwitcherProjectionTests.swift in Sources */, 8F75231F2CF7E597F4D0347F /* ConnectionProfileRepositoryTests.swift in Sources */, 434C420D221F1CAF2A633602 /* DebugConnectionProfileSeederTests.swift in Sources */, + DE5BB5328EF5F61261C1B842 /* FileProviderDomainReconcilerTests.swift in Sources */, DB3525FC134F5E53968FBB63 /* FileProviderSharedStorageMigratorTests.swift in Sources */, 7BA64A5EF27BE1C503CAC3B8 /* FileProviderSharedStorageTests.swift in Sources */, EE488E8B043F3160004A3C20 /* GhosttyAttachmentImageMarkupRendererTests.swift in Sources */, 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/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/project.yml b/project.yml index 137884d5..004794f2 100644 --- a/project.yml +++ b/project.yml @@ -50,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: From d3610326c31cfb6d4bec203521fea8426d8cda76 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 30 Jul 2026 14:41:45 -0700 Subject: [PATCH 04/16] ssh: isolate startup tracing from extension builds Move transport startup tracing into a dedicated source that supplies the accepted no-op implementation when REMUX_FILE_PROVIDER_EXTENSION is active. Keep Ghostty tracing out of extension-conditioned SSH cleanup and SFTP setup, and expose root-key construction from the server and resolved-auth pair needed by extension callers. Regenerate deterministic app-target membership for the new source with XcodeGen 2.44.1. The existing SSH transport and exec selectors pass 74/74 and the normal iPhone 17 simulator build succeeds through XcodeBuildMCP with the required compiler wrappers. No test file changes are included. Global conditioned CLI builds remain a separate target-local extension gate because the setting propagates into SwiftPM dependencies and fails inside Crypto before Remux is compiled. --- Remux.xcodeproj/project.pbxproj | 4 + .../Sources/SSH/RemuxCitadelSFTPClient.swift | 8 ++ .../Sources/SSH/RemuxSSHRootService.swift | 20 +++- .../SSH/RemuxTransportStartupTrace.swift | 102 ++++++++++++++++++ .../Sources/Tmux/GhosttyRuntimeTrace.swift | 81 -------------- 5 files changed, 129 insertions(+), 86 deletions(-) create mode 100644 RemuxApp/Sources/SSH/RemuxTransportStartupTrace.swift diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index 39105295..a33245d4 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 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 */; }; @@ -337,6 +338,7 @@ 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 = ""; }; @@ -606,6 +608,7 @@ FC3F9F61A40DD02A7DD80980 /* RemuxSFTPClient.swift */, 810D178FA878A5BEA4265D7E /* RemuxSSHExecSession.swift */, CC23D872C331C7574737CCDA /* RemuxSSHRootService.swift */, + 81CCA16E5131F9DD6F87DE4E /* RemuxTransportStartupTrace.swift */, 4AD48AB84F508C3391380701 /* SSHAuthenticationMethodFactory.swift */, 23798EF07D4A939CF330686D /* SSHPublicKeyInstaller.swift */, FFD16711790E09A2358816EA /* SSHPublicKeyRemoteInstaller.swift */, @@ -1025,6 +1028,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 */, diff --git a/RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift b/RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift index 762298a8..33872af7 100644 --- a/RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift +++ b/RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift @@ -416,12 +416,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 +431,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 +557,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 +574,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/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 From de550b4b17ce1156733914a483c8a40435d8c741 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 30 Jul 2026 14:57:51 -0700 Subject: [PATCH 05/16] sftp: add bounded listing and download operations Define structured SFTP file type, metadata, directory-entry, readable-file, and read-only client values for listings and downloads. Bound downloads to monotonic chunks and ensure success, timeout, and cancellation close child handles and clean partial files. Reuse the existing connection lease and operation-timeout boundaries. Evidence: TerminalPreviewFileLoaderTests passed 7/7 at baseline; the focused suites retained the expected missing-read-layer compile failure before production and then passed 15/15; the normal iPhone 17 simulator build passed. XcodeGen 2.44.1 produced deterministic test membership. --- Remux.xcodeproj/project.pbxproj | 4 + .../Sources/SSH/RemuxCitadelSFTPClient.swift | 297 +++++++++-- RemuxApp/Sources/SSH/RemuxSFTPClient.swift | 92 ++++ .../RemuxSFTPReadOnlyClientTests.swift | 464 ++++++++++++++++++ .../TerminalPreviewFileLoaderTests.swift | 12 + 5 files changed, 826 insertions(+), 43 deletions(-) create mode 100644 RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index a33245d4..80716654 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -191,6 +191,7 @@ 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 */; }; @@ -370,6 +371,7 @@ 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 = ""; }; 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 = ""; }; @@ -571,6 +573,7 @@ 4B3782AB19C138622144EE40 /* RemuxPreparedTransportCoordinatorTests.swift */, 9C76A9A6D14624AC5E1155D8 /* RemuxRootModelTests.swift */, 73C4CC8673D390A85EC1741D /* RemuxSessionLiveForwardTests.swift */, + A833C1613C1CD45D1EB5B6AC /* RemuxSFTPReadOnlyClientTests.swift */, 87358B27991E5D2ECE199ECC /* RemuxSmokeTests.swift */, 9586DC1E9C276FB8513E59B4 /* RemuxSSHExecSessionTests.swift */, 8B77D12E9FC8E4FF183EC209 /* ShortcutExecutorTests.swift */, @@ -1137,6 +1140,7 @@ 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 */, diff --git a/RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift b/RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift index 33872af7..81ff2591 100644 --- a/RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift +++ b/RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift @@ -2,10 +2,122 @@ import Foundation import NIO +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) 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 +} + +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 + ) async throws -> any RemuxCitadelSFTPFile { + RemuxCitadelSFTPFileBox( + file: try await openFile( + filePath: path, + flags: [.write, .create, .truncate] + ) + ) + } + + 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) + } +} + struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { 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 +128,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 +150,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 +248,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) { @@ -105,10 +269,7 @@ struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { try? localFile.close() } - let remoteFile = try await openRemoteFile( - at: remotePath, - flags: [.write, .create, .truncate] - ) + let remoteFile = try await openRemoteFile(at: remotePath, mode: .write) do { var offset: UInt64 = 0 @@ -118,13 +279,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 ) @@ -144,14 +302,17 @@ struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { func renameFile(from temporaryPath: String, to finalPath: String) async throws { try await withOperationTimeout { - try await sftp.rename(at: temporaryPath, to: finalPath) + try await connection.remuxRename( + from: temporaryPath, + to: finalPath + ) } } 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 +321,24 @@ 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 + ) + } }, - 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 +350,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 +365,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 +405,54 @@ 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 + } +} + +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 { diff --git a/RemuxApp/Sources/SSH/RemuxSFTPClient.swift b/RemuxApp/Sources/SSH/RemuxSFTPClient.swift index 466c01f5..75ea9079 100644 --- a/RemuxApp/Sources/SSH/RemuxSFTPClient.swift +++ b/RemuxApp/Sources/SSH/RemuxSFTPClient.swift @@ -4,6 +4,7 @@ import NIO enum RemuxSFTPClientError: LocalizedError, Equatable, Sendable { case operationTimedOut case sessionUnavailable + case noSuchFile(String) case invalidReadLength(Int) case oversizedReadResult(requested: Int, actual: Int) @@ -13,6 +14,8 @@ 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 .invalidReadLength: return "The remote file read length is invalid." case .oversizedReadResult: @@ -21,10 +24,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 +97,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 diff --git a/RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift b/RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift new file mode 100644 index 00000000..a2a1685b --- /dev/null +++ b/RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift @@ -0,0 +1,464 @@ +import Foundation +import NIO +import XCTest + +@testable import Remux + +final class RemuxSFTPReadOnlyClientTests: XCTestCase { + 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 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 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 var recordedListedPaths: [String] = [] + private var recordedAttributePaths: [String] = [] + + init( + directoryResponses: [String: [RemuxCitadelSFTPDirectoryResponse]] = [:], + directoryFailures: [String: RemuxSFTPClientError] = [:], + directoryDelay: Duration? = nil, + readableFile: (any RemuxCitadelSFTPFile)? = nil + ) { + self.directoryResponses = directoryResponses + self.directoryFailures = directoryFailures + self.directoryDelay = directoryDelay + self.readableFile = readableFile + } + + 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 + ) async throws -> any RemuxCitadelSFTPFile { + throw FakeCitadelSFTPConnectionError.unexpectedFileOpen + } + + func remuxCreateDirectory(atPath path: String) {} + func remuxRename(from sourcePath: String, to destinationPath: String) {} + func remuxRemove(atPath path: String) {} + + func listedPaths() -> [String] { + recordedListedPaths + } + + func attributePaths() -> [String] { + recordedAttributePaths + } +} + +private enum FakeCitadelSFTPConnectionError: Error { + case unexpectedFileOpen + case unexpectedFileWrite +} 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 From b8669b93037214832a5bac4561f2c55948f3c2ca Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 30 Jul 2026 15:11:30 -0700 Subject: [PATCH 06/16] sftp: add strict File Provider write operations Implement strict exclusive SFTP upload creation together with explicit directory creation, rename, file removal, and empty-directory removal operations. Normalize Citadel write statuses into typed permission and unsupported-mutation errors. Close remote upload handles on cancellation without issuing later mutations, and keep terminal attachments on the shared SFTP upload behavior. Evidence: the write tests produced a compile failure on the absent contracts before the source changes; all 38 focused tests passed afterward; the normal Remux iPhone 17 simulator build passed with isolated DerivedData and the required compiler probes; exact blob, scope, line-count, project-file, and diff checks passed. --- .../Sources/SSH/RemuxCitadelSFTPClient.swift | 106 +++++++- RemuxApp/Sources/SSH/RemuxSFTPClient.swift | 18 ++ .../RemuxSFTPReadOnlyClientTests.swift | 237 +++++++++++++++++- 3 files changed, 343 insertions(+), 18 deletions(-) diff --git a/RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift b/RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift index 81ff2591..d62caea1 100644 --- a/RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift +++ b/RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift @@ -52,10 +52,14 @@ protocol RemuxCitadelSFTPConnection: Sendable { ) 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) 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 { @@ -91,12 +95,13 @@ extension SFTPClient: RemuxCitadelSFTPConnection { } func remuxOpenFileForWriting( - atPath path: String + atPath path: String, + flags: SFTPOpenFileFlags ) async throws -> any RemuxCitadelSFTPFile { RemuxCitadelSFTPFileBox( file: try await openFile( filePath: path, - flags: [.write, .create, .truncate] + flags: flags ) ) } @@ -112,9 +117,13 @@ extension SFTPClient: RemuxCitadelSFTPConnection { 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: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { +struct RemuxCitadelSFTPClient: RemuxSFTPFileProviderClient, RemuxSFTPReadOnlyClient, RemuxSFTPUploadClient { private static let pipelinedWriteMaxInFlight = 64 private let connection: any RemuxCitadelSFTPConnection @@ -259,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, @@ -269,7 +288,12 @@ struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { try? localFile.close() } - let remoteFile = try await openRemoteFile(at: remotePath, mode: .write) + let remoteFile: any RemuxCitadelSFTPFile + do { + remoteFile = try await openRemoteFile(at: remotePath, mode: .write) + } catch { + throw normalizedWriteError(error) + } do { var offset: UInt64 = 0 @@ -296,16 +320,44 @@ 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 connection.remuxRename( - from: 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) } } @@ -332,7 +384,8 @@ struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { ) case .write: return try await connection.remuxOpenFileForWriting( - atPath: remotePath + atPath: remotePath, + flags: [.write, .create, .forceCreate] ) } }, @@ -417,6 +470,35 @@ struct RemuxCitadelSFTPClient: RemuxSFTPUploadClient, RemuxSFTPReadOnlyClient { 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 { diff --git a/RemuxApp/Sources/SSH/RemuxSFTPClient.swift b/RemuxApp/Sources/SSH/RemuxSFTPClient.swift index 75ea9079..25b494fc 100644 --- a/RemuxApp/Sources/SSH/RemuxSFTPClient.swift +++ b/RemuxApp/Sources/SSH/RemuxSFTPClient.swift @@ -5,6 +5,8 @@ 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) @@ -16,6 +18,10 @@ enum RemuxSFTPClientError: LocalizedError, Equatable, Sendable { 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: @@ -169,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/RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift b/RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift index a2a1685b..2b9d9cb5 100644 --- a/RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift +++ b/RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift @@ -1,3 +1,4 @@ +@preconcurrency import Citadel import Foundation import NIO import XCTest @@ -5,6 +6,123 @@ 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. @@ -261,6 +379,12 @@ final class RemuxSFTPReadOnlyClientTests: XCTestCase { 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 { @@ -372,6 +496,58 @@ private actor CancellableDownloadSource: RemuxCitadelSFTPFile { } } +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] = [] @@ -389,19 +565,29 @@ private actor FakeCitadelSFTPConnection: RemuxCitadelSFTPConnection { 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 + 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 { @@ -440,14 +626,39 @@ private actor FakeCitadelSFTPConnection: RemuxCitadelSFTPConnection { } func remuxOpenFileForWriting( - atPath path: String + atPath path: String, + flags: SFTPOpenFileFlags ) async throws -> any RemuxCitadelSFTPFile { - throw FakeCitadelSFTPConnectionError.unexpectedFileOpen + 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 remuxCreateDirectory(atPath path: String) {} - func remuxRename(from sourcePath: String, to destinationPath: String) {} - func remuxRemove(atPath path: String) {} + func remuxRemoveDirectory(atPath path: String) throws { + recordedMutations.append(.rmdir(path)) + if let mutationFailure { throw mutationFailure } + } func listedPaths() -> [String] { recordedListedPaths @@ -456,9 +667,23 @@ private actor FakeCitadelSFTPConnection: RemuxCitadelSFTPConnection { 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 } From 1f9e70888504f28098bc6c9935118a5901085be6 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 30 Jul 2026 15:22:35 -0700 Subject: [PATCH 07/16] fileprovider: model contained remote paths and items Reconstruct the accepted remote-item model as one reviewable F7 commit directly on the strict SFTP write transport boundary. This moves only the immutable accepted bytes for canonical relative paths, File Provider identifier encoding, contained symlink projection, remote-item metadata/versioning, and their 12 focused tests. Accepted provenance: - FileProviderRemotePath.swift: 16813f6e195d2b9c175681073657ec21f1dd9182 - FileProviderRemoteItem.swift: 859c016d0e4cd79826bd1cd324fa0d48c4f42986 - FileProviderRemoteItemTests.swift: f8527a8b303841c7827c8e20e6740b53cb41e057 - source commits: df3cd2ca0c6172d2724aec2a23020a63cdc2fa9a, f2f82db2f2d2e1af5fe813b941fc7373dc18e803, and 9fcd3fdf9406dc7eed096345cbf0002206f0c9c4 Verification: - parent File Provider storage/domain suites: 10/10 passing - tests-first RED: FileProviderRemoteItemTests failed only for the absent accepted model types - GREEN: FileProviderRemoteItemTests 12/12 passing - normal iPhone 17 simulator build passed - XcodeGen 2.44.1 was deterministic across two generations - project.yml remains byte-identical to the parent - the 12 generated PBX membership lines are an accepted-oracle subset This deliberately excludes FileProviderRemoteService changes, stable opaque identity work, writable projection contracts, File Provider extension activation, compatibility behavior, and any newly authored product or test logic. --- Remux.xcodeproj/project.pbxproj | 12 ++ .../FileProvider/FileProviderRemoteItem.swift | 174 ++++++++++++++++ .../FileProvider/FileProviderRemotePath.swift | 140 +++++++++++++ .../FileProviderRemoteItemTests.swift | 196 ++++++++++++++++++ 4 files changed, 522 insertions(+) create mode 100644 RemuxApp/Sources/FileProvider/FileProviderRemoteItem.swift create mode 100644 RemuxApp/Sources/FileProvider/FileProviderRemotePath.swift create mode 100644 RemuxAppTests/FileProviderRemoteItemTests.swift diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index 80716654..c26cbc33 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ 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 */; }; @@ -124,6 +125,7 @@ 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 */; }; @@ -164,6 +166,7 @@ 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 */; }; 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 */; }; @@ -263,6 +266,7 @@ 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 = ""; }; @@ -335,6 +339,7 @@ 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 = ""; }; 7C826602D9C963622FB26A40 /* TerminalThemePreviewRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalThemePreviewRenderer.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 = ""; }; @@ -447,6 +452,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 */ @@ -529,6 +535,7 @@ 28EE543D2B247AE6C6A8C472 /* ConnectionProfileRepositoryTests.swift */, DFD9AF0371D15A892B000530 /* DebugConnectionProfileSeederTests.swift */, AC73E66BB7BA53B5E6E31B31 /* FileProviderDomainReconcilerTests.swift */, + 795B3162A540A5A29DEBED76 /* FileProviderRemoteItemTests.swift */, EC00AAA8C4518CE1F9530C00 /* FileProviderSharedStorageMigratorTests.swift */, 6BEAB20D60BA0EDED606847A /* FileProviderSharedStorageTests.swift */, 2A1CB6877AF169792137FA56 /* GhosttyAttachmentImageMarkupRendererTests.swift */, @@ -666,6 +673,8 @@ isa = PBXGroup; children = ( 9B50C6E14DA1AD375C55E17B /* FileProviderDomainReconciler.swift */, + 2585CF2DC8CEC5017488892D /* FileProviderRemoteItem.swift */, + FF66E730E8F051A08B20F833 /* FileProviderRemotePath.swift */, ); path = FileProvider; sourceTree = ""; @@ -958,6 +967,8 @@ 39A58132CBB849E2CDF37BC2 /* DebugConnectionProfileSeeder.swift in Sources */, 3266F0AE9A1F836618FF115D /* DeterministicTmuxControlTransport.swift in Sources */, 15636E0B3091E161F61321C1 /* FileProviderDomainReconciler.swift in Sources */, + C5F95736CD435CA56D834B5E /* FileProviderRemoteItem.swift in Sources */, + 8F80983038DB0393C3C27087 /* FileProviderRemotePath.swift in Sources */, 0F4E56668E1D070DA2549277 /* FileProviderSharedStorageMigrator.swift in Sources */, A7AFC27CD8E0B30AFF1A32BF /* GhosttyAttachmentImageMarkupEditor.swift in Sources */, 4C8C5E176A192D50E70F86FE /* GhosttyAttachmentImagePreviewData.swift in Sources */, @@ -1097,6 +1108,7 @@ 8F75231F2CF7E597F4D0347F /* ConnectionProfileRepositoryTests.swift in Sources */, 434C420D221F1CAF2A633602 /* DebugConnectionProfileSeederTests.swift in Sources */, DE5BB5328EF5F61261C1B842 /* FileProviderDomainReconcilerTests.swift in Sources */, + 236BD3D59A937BA6300DD043 /* FileProviderRemoteItemTests.swift in Sources */, DB3525FC134F5E53968FBB63 /* FileProviderSharedStorageMigratorTests.swift in Sources */, 7BA64A5EF27BE1C503CAC3B8 /* FileProviderSharedStorageTests.swift in Sources */, EE488E8B043F3160004A3C20 /* GhosttyAttachmentImageMarkupRendererTests.swift in Sources */, 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..16813f6e --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderRemotePath.swift @@ -0,0 +1,140 @@ +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 pathPrefix = "p:" + + func identifier(for path: FileProviderRemotePath) -> NSFileProviderItemIdentifier { + guard path != .root else { return .rootContainer } + + let encoded = Data(path.relative.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return NSFileProviderItemIdentifier(rawValue: Self.pathPrefix + encoded) + } + + func path(for identifier: NSFileProviderItemIdentifier) throws -> FileProviderRemotePath { + guard identifier == .rootContainer else { + let rawValue = identifier.rawValue + guard rawValue.hasPrefix(Self.pathPrefix) else { + throw FileProviderRemotePathError.invalidItemIdentifier + } + + let encoded = String(rawValue.dropFirst(Self.pathPrefix.count)) + guard !encoded.isEmpty, + encoded.unicodeScalars.allSatisfy(Self.isURLSafeBase64Scalar) + else { + throw FileProviderRemotePathError.invalidItemIdentifier + } + + let standardBase64 = encoded + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let paddingLength = (4 - standardBase64.count % 4) % 4 + let paddedBase64 = standardBase64 + String(repeating: "=", count: paddingLength) + guard let data = Data(base64Encoded: paddedBase64), + let relative = String(data: data, encoding: .utf8) + else { + throw FileProviderRemotePathError.invalidItemIdentifier + } + + let path: FileProviderRemotePath + do { + path = try FileProviderRemotePath(relative: relative) + } catch { + throw FileProviderRemotePathError.invalidItemIdentifier + } + + guard path != .root, self.identifier(for: path) == identifier else { + throw FileProviderRemotePathError.invalidItemIdentifier + } + return path + } + + return .root + } + + private static func isURLSafeBase64Scalar(_ scalar: Unicode.Scalar) -> Bool { + switch scalar.value { + case 45, 48...57, 65...90, 95, 97...122: + true + default: + false + } + } +} + +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/RemuxAppTests/FileProviderRemoteItemTests.swift b/RemuxAppTests/FileProviderRemoteItemTests.swift new file mode 100644 index 00000000..f8527a8b --- /dev/null +++ b/RemuxAppTests/FileProviderRemoteItemTests.swift @@ -0,0 +1,196 @@ +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 testIdentifierRoundTripsUnicodeAndRoot() throws { + let path = try FileProviderRemotePath(relative: "資料/a b.txt") + let identifier = codec.identifier(for: path) + + XCTAssertEqual(identifier.rawValue, "p:6LOH5paZL2EgYi50eHQ") + XCTAssertEqual(try codec.path(for: identifier), path) + XCTAssertEqual(codec.identifier(for: .root), .rootContainer) + XCTAssertEqual(try codec.path(for: .rootContainer), .root) + } + + func testIdentifierRejectsMalformedAndReservedRepresentations() throws { + XCTAssertThrowsError(try codec.path(for: NSFileProviderItemIdentifier(rawValue: "docs/readme"))) + XCTAssertThrowsError(try codec.path(for: NSFileProviderItemIdentifier(rawValue: "p:not base64"))) + XCTAssertThrowsError(try codec.path(for: NSFileProviderItemIdentifier(rawValue: "p:Li4vZXNjYXBl"))) + XCTAssertThrowsError(try codec.path(for: NSFileProviderItemIdentifier(rawValue: "p:"))) + } + + 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)) + } + + 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 + ) + } +} From b1578904d642aaa1ef28e65ce56cefb315a599b5 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 30 Jul 2026 15:34:42 -0700 Subject: [PATCH 08/16] fileprovider: assign stable opaque item identities Reconstruct the accepted F8 identity boundary as a direct child of the contained remote-item leaf. This is a move-only commit: the production and test files retain their immutable accepted blob bytes, and the project membership is the deterministic XcodeGen output. Move FileProviderRemotePath.swift blob 4f097dc30859e3b4c06dab2c8694f665f414d505, FileProviderItemProjection.swift blob 5d99e0fe817bc851bc7f59bd26926311f0cf5356, FileProviderItemIdentity.swift blob 6894a71c0d36c52feb890f5b10174e2ee87e4461, and FileProviderRemoteItemTests.swift blob b9bf19855c8257c53b52dafbb0f5268a73cfbade. The identity value blob composes accepted commits a8ea8cd192e7ee89a7ed0bb4f72e1898372d7a64, e9f19c93aa63e2eac671e9e8fdf2b27e3348f6ca, and 1f858609ba08a459548ed1a834c2ef18674b03a9; the projection blob composes a6cc7d3760751a3fb37a270dfc2990057796fd5b and a8ea8cd192e7ee89a7ed0bb4f72e1898372d7a64. The F7 focused baseline passed 12/12. Moving only the accepted F8 test blob produced the expected compile RED for the missing identity type and codec API. After moving the three production blobs, the same suite passed 12/12 and a normal iPhone 17 simulator build succeeded. XcodeGen 2.44.1 was deterministic across two runs, and all eight generated PBX additions are present in the accepted oracle. This leaf intentionally excludes writable projection capabilities, snapshot or mutation behavior, extension activation, live-host qualification, compatibility work, and any newly authored glue or tests. --- Remux.xcodeproj/project.pbxproj | 8 ++ .../FileProviderItemIdentity.swift | 51 ++++++++++++ .../FileProviderItemProjection.swift | 54 +++++++++++++ .../FileProvider/FileProviderRemotePath.swift | 79 ++++++------------- .../FileProviderRemoteItemTests.swift | 31 +++++--- 5 files changed, 155 insertions(+), 68 deletions(-) create mode 100644 RemuxApp/Sources/FileProvider/FileProviderItemIdentity.swift create mode 100644 RemuxApp/Sources/FileProvider/FileProviderItemProjection.swift diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index c26cbc33..9bd254d7 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 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 */; }; + 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 */; }; @@ -216,6 +217,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 */; }; @@ -295,6 +297,7 @@ 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 = ""; }; @@ -349,6 +352,7 @@ 8421B86729F6E46ABD20CBD5 /* GhosttyTerminalScreenModeling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalScreenModeling.swift; sourceTree = ""; }; 84DD89E37AC083383B988185 /* GhosttyAttachmentPasteboardSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAttachmentPasteboardSnapshotTests.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 = ""; }; @@ -673,6 +677,8 @@ isa = PBXGroup; children = ( 9B50C6E14DA1AD375C55E17B /* FileProviderDomainReconciler.swift */, + 46C317118AE1C94878179FBD /* FileProviderItemIdentity.swift */, + 85CEC108FE540369F1F15BF4 /* FileProviderItemProjection.swift */, 2585CF2DC8CEC5017488892D /* FileProviderRemoteItem.swift */, FF66E730E8F051A08B20F833 /* FileProviderRemotePath.swift */, ); @@ -967,6 +973,8 @@ 39A58132CBB849E2CDF37BC2 /* DebugConnectionProfileSeeder.swift in Sources */, 3266F0AE9A1F836618FF115D /* DeterministicTmuxControlTransport.swift in Sources */, 15636E0B3091E161F61321C1 /* FileProviderDomainReconciler.swift in Sources */, + 126385C505991783E10079F1 /* FileProviderItemIdentity.swift in Sources */, + F696F748589435EFB7894157 /* FileProviderItemProjection.swift in Sources */, C5F95736CD435CA56D834B5E /* FileProviderRemoteItem.swift in Sources */, 8F80983038DB0393C3C27087 /* FileProviderRemotePath.swift in Sources */, 0F4E56668E1D070DA2549277 /* FileProviderSharedStorageMigrator.swift in Sources */, 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..5d99e0fe --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderItemProjection.swift @@ -0,0 +1,54 @@ +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 = [.allowsReading] + 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 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/FileProviderRemotePath.swift b/RemuxApp/Sources/FileProvider/FileProviderRemotePath.swift index 16813f6e..4f097dc3 100644 --- a/RemuxApp/Sources/FileProvider/FileProviderRemotePath.swift +++ b/RemuxApp/Sources/FileProvider/FileProviderRemotePath.swift @@ -57,67 +57,32 @@ struct FileProviderRemotePath: Hashable, Codable, Sendable { } struct FileProviderItemIdentifierCodec: Sendable { - private static let pathPrefix = "p:" - - func identifier(for path: FileProviderRemotePath) -> NSFileProviderItemIdentifier { - guard path != .root else { return .rootContainer } - - let encoded = Data(path.relative.utf8) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - return NSFileProviderItemIdentifier(rawValue: Self.pathPrefix + encoded) - } - - func path(for identifier: NSFileProviderItemIdentifier) throws -> FileProviderRemotePath { - guard identifier == .rootContainer else { - let rawValue = identifier.rawValue - guard rawValue.hasPrefix(Self.pathPrefix) else { - throw FileProviderRemotePathError.invalidItemIdentifier - } - - let encoded = String(rawValue.dropFirst(Self.pathPrefix.count)) - guard !encoded.isEmpty, - encoded.unicodeScalars.allSatisfy(Self.isURLSafeBase64Scalar) - else { - throw FileProviderRemotePathError.invalidItemIdentifier - } - - let standardBase64 = encoded - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let paddingLength = (4 - standardBase64.count % 4) % 4 - let paddedBase64 = standardBase64 + String(repeating: "=", count: paddingLength) - guard let data = Data(base64Encoded: paddedBase64), - let relative = String(data: data, encoding: .utf8) - else { - throw FileProviderRemotePathError.invalidItemIdentifier - } - - let path: FileProviderRemotePath - do { - path = try FileProviderRemotePath(relative: relative) - } catch { - throw FileProviderRemotePathError.invalidItemIdentifier - } - - guard path != .root, self.identifier(for: path) == identifier else { - throw FileProviderRemotePathError.invalidItemIdentifier - } - return path + 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() + ) } - - return .root } - private static func isURLSafeBase64Scalar(_ scalar: Unicode.Scalar) -> Bool { - switch scalar.value { - case 45, 48...57, 65...90, 95, 97...122: - true - default: - false + 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) } } diff --git a/RemuxAppTests/FileProviderRemoteItemTests.swift b/RemuxAppTests/FileProviderRemoteItemTests.swift index f8527a8b..b9bf1985 100644 --- a/RemuxAppTests/FileProviderRemoteItemTests.swift +++ b/RemuxAppTests/FileProviderRemoteItemTests.swift @@ -30,21 +30,30 @@ final class FileProviderRemoteItemTests: XCTestCase { ) } - func testIdentifierRoundTripsUnicodeAndRoot() throws { - let path = try FileProviderRemotePath(relative: "資料/a b.txt") - let identifier = codec.identifier(for: path) + func testOpaqueIdentifierRoundTripsIdentityWithoutExposingPath() throws { + let identity = FileProviderItemIdentity.item( + UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + ) + + let identifier = codec.identifier(for: identity) - XCTAssertEqual(identifier.rawValue, "p:6LOH5paZL2EgYi50eHQ") - XCTAssertEqual(try codec.path(for: identifier), path) + 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) - XCTAssertEqual(try codec.path(for: .rootContainer), .root) } - func testIdentifierRejectsMalformedAndReservedRepresentations() throws { - XCTAssertThrowsError(try codec.path(for: NSFileProviderItemIdentifier(rawValue: "docs/readme"))) - XCTAssertThrowsError(try codec.path(for: NSFileProviderItemIdentifier(rawValue: "p:not base64"))) - XCTAssertThrowsError(try codec.path(for: NSFileProviderItemIdentifier(rawValue: "p:Li4vZXNjYXBl"))) - XCTAssertThrowsError(try codec.path(for: NSFileProviderItemIdentifier(rawValue: "p:"))) + 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 { From af9cba2c29f23aa6766f80d0a6b98a1c8b1791e7 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 30 Jul 2026 15:52:17 -0700 Subject: [PATCH 09/16] fileprovider: persist bounded snapshot generations Reconstruct the accepted snapshot-generation store and F6-compatible File Provider error mapper as one atomic review leaf. The accepted snapshot tests call the mapper, while the mapper consumes FileProviderSnapshotStoreError, so separating them would require an authored temporary seam. Move exact accepted blobs 159f2cbb09abb515ad76a801bec6517aa8253d3c and 26fc78b345b691d672fc41aaf25eaf0c68de4802 with exact test blobs 2e43e5624dd738e9e7537d535a40910231923f74 and e1d0586534f6b9faa8f85fe154885ba031060566. XcodeGen 2.44.1 deterministically adds only the 16 accepted source and test membership lines. Evidence: the F8 baseline passed 12/12; tests-first boundary discovery exposed the missing mapper dependency; the combined accepted tests then failed for only the absent store and mapper; the earlier accepted mapper proved non-exhaustive against the already-accepted F6 write transport; the exact F6-compatible mapper resolved that compile dependency; the final combined suites passed 22/22 and a normal iPhone 17 simulator build succeeded. This coherent 592-line production unit is the documented first-pass exception to the 500-line ceiling. It excludes local mutation commits, receipts, identity reservations, relocations, deleted identities, and later writable error cases owned by F10 and F13. No product, test, or project behavior was authored. --- Remux.xcodeproj/project.pbxproj | 16 + .../FileProviderErrorMapper.swift | 135 +++++ .../FileProviderSnapshotStore.swift | 457 +++++++++++++++++ .../FileProviderErrorMapperTests.swift | 155 ++++++ .../FileProviderSnapshotStoreTests.swift | 468 ++++++++++++++++++ 5 files changed, 1231 insertions(+) create mode 100644 RemuxApp/Sources/FileProvider/FileProviderErrorMapper.swift create mode 100644 RemuxApp/Sources/FileProvider/FileProviderSnapshotStore.swift create mode 100644 RemuxAppTests/FileProviderErrorMapperTests.swift create mode 100644 RemuxAppTests/FileProviderSnapshotStoreTests.swift diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index 9bd254d7..134b64ee 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -92,11 +92,13 @@ 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 */; }; 63982077EBCD656F6570F121 /* TerminalPreviewLiveWeb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4268A9BCEA43005C3CE56AF7 /* TerminalPreviewLiveWeb.swift */; }; 6548541B3AD17F7D70BFE8C0 /* TerminalPreviewLiveWebTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5437829CC09E381C6615908 /* TerminalPreviewLiveWebTests.swift */; }; @@ -116,6 +118,7 @@ 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 */; }; @@ -199,6 +202,7 @@ 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 */; }; @@ -263,6 +267,7 @@ 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 = ""; }; @@ -291,6 +296,7 @@ 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 = ""; }; @@ -308,6 +314,7 @@ 5087B7872EECF17538D23AF9 /* ConnectionPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionPresentation.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 = ""; }; 575EDAA61B2B7A0B241A5E4D /* TmuxSessionLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxSessionLink.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 = ""; }; @@ -376,6 +383,7 @@ 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 = ""; }; @@ -539,9 +547,11 @@ 28EE543D2B247AE6C6A8C472 /* ConnectionProfileRepositoryTests.swift */, DFD9AF0371D15A892B000530 /* DebugConnectionProfileSeederTests.swift */, AC73E66BB7BA53B5E6E31B31 /* FileProviderDomainReconcilerTests.swift */, + 208AD7FB82B0A5E6C853B322 /* FileProviderErrorMapperTests.swift */, 795B3162A540A5A29DEBED76 /* FileProviderRemoteItemTests.swift */, EC00AAA8C4518CE1F9530C00 /* FileProviderSharedStorageMigratorTests.swift */, 6BEAB20D60BA0EDED606847A /* FileProviderSharedStorageTests.swift */, + A39CC72773E8432EBC3126E4 /* FileProviderSnapshotStoreTests.swift */, 2A1CB6877AF169792137FA56 /* GhosttyAttachmentImageMarkupRendererTests.swift */, 6B657FFBCF25A6A1A77FD231 /* GhosttyAttachmentImagePreviewDataTests.swift */, 84DD89E37AC083383B988185 /* GhosttyAttachmentPasteboardSnapshotTests.swift */, @@ -677,10 +687,12 @@ isa = PBXGroup; children = ( 9B50C6E14DA1AD375C55E17B /* FileProviderDomainReconciler.swift */, + 55622A6FC71B6F2FEA79BCDA /* FileProviderErrorMapper.swift */, 46C317118AE1C94878179FBD /* FileProviderItemIdentity.swift */, 85CEC108FE540369F1F15BF4 /* FileProviderItemProjection.swift */, 2585CF2DC8CEC5017488892D /* FileProviderRemoteItem.swift */, FF66E730E8F051A08B20F833 /* FileProviderRemotePath.swift */, + 4369D3BB817295081F553170 /* FileProviderSnapshotStore.swift */, ); path = FileProvider; sourceTree = ""; @@ -973,11 +985,13 @@ 39A58132CBB849E2CDF37BC2 /* DebugConnectionProfileSeeder.swift in Sources */, 3266F0AE9A1F836618FF115D /* DeterministicTmuxControlTransport.swift in Sources */, 15636E0B3091E161F61321C1 /* FileProviderDomainReconciler.swift in Sources */, + 58AFE513F5DB3A622A1DFD2B /* FileProviderErrorMapper.swift in Sources */, 126385C505991783E10079F1 /* FileProviderItemIdentity.swift in Sources */, F696F748589435EFB7894157 /* FileProviderItemProjection.swift in Sources */, C5F95736CD435CA56D834B5E /* FileProviderRemoteItem.swift in Sources */, 8F80983038DB0393C3C27087 /* FileProviderRemotePath.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 */, @@ -1116,9 +1130,11 @@ 8F75231F2CF7E597F4D0347F /* ConnectionProfileRepositoryTests.swift in Sources */, 434C420D221F1CAF2A633602 /* DebugConnectionProfileSeederTests.swift in Sources */, DE5BB5328EF5F61261C1B842 /* FileProviderDomainReconcilerTests.swift in Sources */, + 808F5D8AF6DE8AC1A8802B5F /* FileProviderErrorMapperTests.swift in Sources */, 236BD3D59A937BA6300DD043 /* FileProviderRemoteItemTests.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 */, diff --git a/RemuxApp/Sources/FileProvider/FileProviderErrorMapper.swift b/RemuxApp/Sources/FileProvider/FileProviderErrorMapper.swift new file mode 100644 index 00000000..26fc78b3 --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderErrorMapper.swift @@ -0,0 +1,135 @@ +@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) + } + + 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/FileProviderSnapshotStore.swift b/RemuxApp/Sources/FileProvider/FileProviderSnapshotStore.swift new file mode 100644 index 00000000..159f2cbb --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderSnapshotStore.swift @@ -0,0 +1,457 @@ +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-v2.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 -> ( + anchor: NSFileProviderSyncAnchor, + items: [FileProviderIdentifiedItem], + delta: FileProviderSnapshotDelta + ) { + 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 let latest = state.generations.last, items == previousRemoteItems { + return ( + anchor: makeAnchor(namespace: state.namespace, generation: latest.generation), + items: previousItems, + delta: .init(updated: [], deleted: []) + ) + } + + 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 latest = PersistedGeneration(generation: nextGeneration, directories: directories) + state.generations.append(latest) + state.generations = Array(state.generations.suffix(retainedGenerationCount)) + let delta = makeDelta(from: previousItems, to: identifiedItems) + if hadPreviousGeneration, + !delta.updated.isEmpty || !delta.deleted.isEmpty + { + state.pendingSignals.removeAll { + $0.directory == directory + } + state.pendingSignals.append( + PersistedPendingSignal( + directory: directory, + generation: nextGeneration + ) + ) + state.pendingSignals.sort { + $0.directory.relative < $1.directory.relative + } + } + try Task.checkCancellation() + try save(state) + + return ( + anchor: makeAnchor(namespace: state.namespace, generation: nextGeneration), + items: identifiedItems, + delta: delta + ) + } + + func pendingWorkingSetSignalGeneration() throws -> UInt64? { + try loadState().pendingSignals + .map(\.generation) + .max() + } + + func acknowledgeWorkingSetSignal( + generation: UInt64 + ) throws { + var state = try loadState() + let remainingSignals = state.pendingSignals.filter { + $0.generation > generation + } + guard remainingSignals.count != state.pendingSignals.count else { + return + } + state.pendingSignals = remainingSignals + 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 { + 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 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 initial = try record(directory: .root, items: []) + return (anchor: initial.anchor, 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: [], + pendingSignals: [] + ) + } + 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 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 pendingSignals: [PersistedPendingSignal] +} + +private struct PersistedPendingSignal: Codable { + let directory: FileProviderRemotePath + let generation: 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/RemuxAppTests/FileProviderErrorMapperTests.swift b/RemuxAppTests/FileProviderErrorMapperTests.swift new file mode 100644 index 00000000..e1d05865 --- /dev/null +++ b/RemuxAppTests/FileProviderErrorMapperTests.swift @@ -0,0 +1,155 @@ +@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) + } + + 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/FileProviderSnapshotStoreTests.swift b/RemuxAppTests/FileProviderSnapshotStoreTests.swift new file mode 100644 index 00000000..2e43e562 --- /dev/null +++ b/RemuxAppTests/FileProviderSnapshotStoreTests.swift @@ -0,0 +1,468 @@ +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) + + let first = try await store.record(directory: .root, items: [b, a]) + let unchanged = try await store.record(directory: .root, items: [a, b]) + let changed = try await store.record(directory: .root, items: [c, aChanged]) + + XCTAssertEqual(first.anchor, unchanged.anchor) + XCTAssertEqual(first.delta.updated.map(\.remoteItem), [a, b]) + XCTAssertTrue(first.delta.deleted.isEmpty) + XCTAssertEqual(unchanged.delta, FileProviderSnapshotDelta(updated: [], deleted: [])) + XCTAssertEqual(changed.delta.updated.map(\.remoteItem), [aChanged, c]) + XCTAssertEqual(changed.delta.deleted, [first.items[1].itemIdentifier]) + XCTAssertEqual(generation(of: first.anchor), 1) + XCTAssertEqual(generation(of: changed.anchor), 2) + + let delta = try await store.delta(directory: .root, from: first.anchor) + XCTAssertEqual(delta.anchor, changed.anchor) + XCTAssertEqual(delta.delta, changed.delta) + } + + 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.items, second.items) + XCTAssertEqual( + first.items.first?.identity, + .item(UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000001")!) + ) + let path = try await store.path(for: first.items[0].itemIdentifier) + XCTAssertEqual(path, remote.path) + XCTAssertEqual( + try store.pathSynchronously(for: first.items[0].itemIdentifier), + remote.path + ) + } + + 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.items[0].parentIdentity, rootRecord.items[0].identity) + XCTAssertEqual( + FileProviderItemProjection( + item: nestedRecord.items[0], + rootDisplayName: "Fixture" + ).parentItemIdentifier, + rootRecord.items[0].itemIdentifier + ) + } + + 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 second = try await store.record(directory: .root, items: [renamed]) + + XCTAssertNotEqual(first.items[0].identity, second.items[0].identity) + XCTAssertEqual(second.delta.deleted, [first.items[0].itemIdentifier]) + } + + func testUnshippedLegacySnapshotFileIsIgnored() async throws { + try Data("legacy-path-identity-state".utf8).write( + to: root.appendingPathComponent("snapshot-generations.json") + ) + let store = FileProviderSnapshotStore(rootURL: root) + + let result = try await store.record( + directory: .root, + items: [try item(path: "report.txt")] + ) + + XCTAssertEqual(result.items.count, 1) + XCTAssertTrue( + FileManager.default.fileExists( + atPath: root.appendingPathComponent( + "snapshot-generations-v2.json" + ).path + ) + ) + } + + 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 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] + ) + XCTAssertEqual(unchanged.anchor, recorded.anchor) + 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 reloadedStore = FileProviderSnapshotStore(rootURL: root) + let delta = try await reloadedStore.delta(directory: .root, from: first.anchor) + XCTAssertEqual(delta.anchor, first.anchor) + XCTAssertEqual(delta.delta, FileProviderSnapshotDelta(updated: [], deleted: [])) + } + + func testEvictedMalformedAndForeignAnchorsThrowSyncAnchorExpired() async throws { + let store = FileProviderSnapshotStore(rootURL: root, retainedGenerationCount: 2) + let old = try await store.record(directory: .root, items: [try item(path: "a.txt")]).anchor + _ = 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")]) + let foreign = try await otherStore.record(directory: .root, items: [try item(path: "foreign.txt")]) + + await XCTAssertThrowsErrorAsync( + try await store.delta(directory: .root, from: foreign.anchor) + ) { 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) + + let expired = try await store.record( + directory: .root, + items: [rootItem] + ).anchor + let retained = try await store.record( + directory: directory, + items: [original] + ).anchor + let latest = try await store.record( + directory: directory, + items: [changed] + ).anchor + _ = try await otherStore.record(directory: .root, items: [rootItem]) + let foreign = try await otherStore.record( + directory: directory, + items: [original] + ).anchor + + 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 removed = try await store.record(directory: .root, items: []) + + XCTAssertEqual(generation(of: removed.anchor), generation(of: baseline.anchor) + 1) + let removedDelta = try await store.workingSetDelta(from: baseline.anchor) + XCTAssertEqual(removedDelta.anchor, removed.anchor) + XCTAssertTrue(removedDelta.delta.updated.isEmpty) + XCTAssertEqual( + removedDelta.delta.deleted, + [rootRecord.items[0], childRecord.items[0], baseline.items[0]] + .map(\.itemIdentifier) + .sorted { $0.rawValue < $1.rawValue } + ) + + let recreated = try await store.record(directory: .root, items: [nested]) + let recreatedSnapshot = try await store.workingSetSnapshot() + XCTAssertEqual(recreatedSnapshot.anchor, recreated.anchor) + XCTAssertEqual(recreatedSnapshot.items.map(\.remoteItem), [nested]) + let recreatedDelta = try await store.workingSetDelta(from: removed.anchor) + 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 changed = try await store.record(directory: .root, items: [renamed]) + + XCTAssertEqual(generation(of: changed.anchor), generation(of: baseline.anchor) + 1) + let delta = try await store.workingSetDelta(from: baseline.anchor) + XCTAssertEqual(delta.anchor, changed.anchor) + XCTAssertEqual(delta.delta.updated.map(\.remoteItem), [renamed]) + XCTAssertEqual( + delta.delta.deleted, + [rootRecord.items[0], childRecord.items[0], baseline.items[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 changed = try await store.record(directory: .root, items: [nestedFile]) + + XCTAssertEqual(generation(of: changed.anchor), generation(of: baseline.anchor) + 1) + let delta = try await store.workingSetDelta(from: baseline.anchor) + XCTAssertEqual(delta.anchor, changed.anchor) + XCTAssertEqual(delta.delta.updated.map(\.remoteItem), [nestedFile]) + XCTAssertEqual( + delta.delta.deleted, + [childRecord.items[0], baseline.items[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-v2.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) + } + } + + 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) + } + } +} From 98661f63a9ac77b1aa195a87eecb4d7860060aa5 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 30 Jul 2026 16:06:31 -0700 Subject: [PATCH 10/16] fileprovider: persist mutations and replay receipts atomically Move the accepted mutation-journal layer onto the bounded snapshot-generation parent without rewriting behavior. Snapshot mutations now persist identity reservations, relocations, deletions, refreshed directories, replay receipts, and opt-in pending signals as one generation or not at all. The commit contains only the exact accepted FileProviderSnapshotStore blob 5c319ca77d87b7095b176014d990a15cdb8669d1 and exact 23-test blob dd23955a5139432b02650df9c8fe64b69e229cf4, sourced from immutable commits e9f19c93, 234e3cf, 962c8b8, 28135e5, and 999e676. Relative to F9 it is +255/-1 production and +278/-0 tests; project.yml and the PBX project remain byte-identical. Verification: the F9 combined baseline passed 22/22; the accepted tests-first state failed only on missing mutation-journal APIs and associated types; the final snapshot and mapper suites passed 31/31; and the normal Remux simulator build succeeded. --- .../FileProviderSnapshotStore.swift | 322 +++++++++-- .../FileProviderSnapshotStoreTests.swift | 514 ++++++++++++++++-- 2 files changed, 729 insertions(+), 107 deletions(-) diff --git a/RemuxApp/Sources/FileProvider/FileProviderSnapshotStore.swift b/RemuxApp/Sources/FileProvider/FileProviderSnapshotStore.swift index 159f2cbb..3e9e4c11 100644 --- a/RemuxApp/Sources/FileProvider/FileProviderSnapshotStore.swift +++ b/RemuxApp/Sources/FileProvider/FileProviderSnapshotStore.swift @@ -14,7 +14,7 @@ enum FileProviderSnapshotStoreError: Error, Equatable, Sendable { } actor FileProviderSnapshotStore { - private static let stateFilename = "snapshot-generations-v2.json" + private static let stateFilename = "snapshot-generations-v3.json" private static let namespaceByteCount = 16 private static let generationByteCount = MemoryLayout.size @@ -46,11 +46,7 @@ actor FileProviderSnapshotStore { func record( directory: FileProviderRemotePath, items: [FileProviderRemoteItem] - ) throws -> ( - anchor: NSFileProviderSyncAnchor, - items: [FileProviderIdentifiedItem], - delta: FileProviderSnapshotDelta - ) { + ) throws -> [FileProviderIdentifiedItem] { try Task.checkCancellation() var state = try loadState() try Task.checkCancellation() @@ -59,12 +55,8 @@ actor FileProviderSnapshotStore { let previousItems = state.generations.last?.items(for: directory) ?? [] let previousRemoteItems = previousItems.map(\.remoteItem) - if let latest = state.generations.last, items == previousRemoteItems { - return ( - anchor: makeAnchor(namespace: state.namespace, generation: latest.generation), - items: previousItems, - delta: .init(updated: [], deleted: []) - ) + if state.generations.last != nil, items == previousRemoteItems { + return previousItems } let hadPreviousGeneration = state.generations.last != nil @@ -99,54 +91,128 @@ actor FileProviderSnapshotStore { 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) + let latest = PersistedGeneration( + generation: nextGeneration, + directories: directories + ) state.generations.append(latest) state.generations = Array(state.generations.suffix(retainedGenerationCount)) - let delta = makeDelta(from: previousItems, to: identifiedItems) - if hadPreviousGeneration, - !delta.updated.isEmpty || !delta.deleted.isEmpty - { - state.pendingSignals.removeAll { - $0.directory == directory - } - state.pendingSignals.append( - PersistedPendingSignal( - directory: directory, - generation: nextGeneration + 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 ) - state.pendingSignals.sort { - $0.directory.relative < $1.directory.relative + 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) - return ( - anchor: makeAnchor(namespace: state.namespace, generation: nextGeneration), - items: identifiedItems, - delta: delta - ) } func pendingWorkingSetSignalGeneration() throws -> UInt64? { - try loadState().pendingSignals - .map(\.generation) - .max() + try loadState().pendingWorkingSetSignalGeneration } func acknowledgeWorkingSetSignal( generation: UInt64 ) throws { var state = try loadState() - let remainingSignals = state.pendingSignals.filter { - $0.generation > generation - } - guard remainingSignals.count != state.pendingSignals.count else { + guard let pending = state.pendingWorkingSetSignalGeneration, + pending <= generation + else { return } - state.pendingSignals = remainingSignals + state.pendingWorkingSetSignalGeneration = nil try save(state) } @@ -163,6 +229,7 @@ actor FileProviderSnapshotStore { 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 { @@ -183,6 +250,17 @@ actor FileProviderSnapshotStore { .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 { @@ -206,8 +284,16 @@ actor FileProviderSnapshotStore { ) } - let initial = try record(directory: .root, items: []) - return (anchor: initial.anchor, items: []) + 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( @@ -263,7 +349,8 @@ actor FileProviderSnapshotStore { return PersistedState( namespace: UUID(), generations: [], - pendingSignals: [] + createAliases: [:], + pendingWorkingSetSignalGeneration: nil ) } let state = try decoder.decode(PersistedState.self, from: Data(contentsOf: stateURL)) @@ -369,6 +456,149 @@ actor FileProviderSnapshotStore { } } + 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] @@ -422,12 +652,8 @@ actor FileProviderSnapshotStore { private struct PersistedState: Codable { let namespace: UUID var generations: [PersistedGeneration] - var pendingSignals: [PersistedPendingSignal] -} - -private struct PersistedPendingSignal: Codable { - let directory: FileProviderRemotePath - let generation: UInt64 + var createAliases: [String: FileProviderItemIdentity] + var pendingWorkingSetSignalGeneration: UInt64? } private struct PersistedGeneration: Codable { diff --git a/RemuxAppTests/FileProviderSnapshotStoreTests.swift b/RemuxAppTests/FileProviderSnapshotStoreTests.swift index 2e43e562..cd96f09d 100644 --- a/RemuxAppTests/FileProviderSnapshotStoreTests.swift +++ b/RemuxAppTests/FileProviderSnapshotStoreTests.swift @@ -24,22 +24,32 @@ final class FileProviderSnapshotStoreTests: XCTestCase { 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(first.anchor, unchanged.anchor) - XCTAssertEqual(first.delta.updated.map(\.remoteItem), [a, b]) - XCTAssertTrue(first.delta.deleted.isEmpty) - XCTAssertEqual(unchanged.delta, FileProviderSnapshotDelta(updated: [], deleted: [])) - XCTAssertEqual(changed.delta.updated.map(\.remoteItem), [aChanged, c]) - XCTAssertEqual(changed.delta.deleted, [first.items[1].itemIdentifier]) - XCTAssertEqual(generation(of: first.anchor), 1) - XCTAssertEqual(generation(of: changed.anchor), 2) - - let delta = try await store.delta(directory: .root, from: first.anchor) - XCTAssertEqual(delta.anchor, changed.anchor) - XCTAssertEqual(delta.delta, changed.delta) + 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 { @@ -55,19 +65,28 @@ final class FileProviderSnapshotStoreTests: XCTestCase { let first = try await store.record(directory: .root, items: [remote]) let second = try await store.record(directory: .root, items: [remote]) - XCTAssertEqual(first.items, second.items) + XCTAssertEqual(first, second) XCTAssertEqual( - first.items.first?.identity, + first.first?.identity, .item(UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000001")!) ) - let path = try await store.path(for: first.items[0].itemIdentifier) + let path = try await store.path(for: first[0].itemIdentifier) XCTAssertEqual(path, remote.path) XCTAssertEqual( - try store.pathSynchronously(for: first.items[0].itemIdentifier), + 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")!, @@ -87,14 +106,337 @@ final class FileProviderSnapshotStoreTests: XCTestCase { items: [child] ) - XCTAssertEqual(nestedRecord.items[0].parentIdentity, rootRecord.items[0].identity) + XCTAssertEqual(nestedRecord[0].parentIdentity, rootRecord[0].identity) XCTAssertEqual( FileProviderItemProjection( - item: nestedRecord.items[0], + item: nestedRecord[0], rootDisplayName: "Fixture" ).parentItemIdentifier, - rootRecord.items[0].itemIdentifier + 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 { @@ -109,17 +451,21 @@ final class FileProviderSnapshotStoreTests: XCTestCase { 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.items[0].identity, second.items[0].identity) - XCTAssertEqual(second.delta.deleted, [first.items[0].itemIdentifier]) + XCTAssertNotEqual(first[0].identity, second[0].identity) + XCTAssertEqual(delta.anchor, secondAnchor) + XCTAssertEqual(delta.delta.deleted, [first[0].itemIdentifier]) } func testUnshippedLegacySnapshotFileIsIgnored() async throws { - try Data("legacy-path-identity-state".utf8).write( - to: root.appendingPathComponent("snapshot-generations.json") - ) + 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( @@ -127,14 +473,15 @@ final class FileProviderSnapshotStoreTests: XCTestCase { items: [try item(path: "report.txt")] ) - XCTAssertEqual(result.items.count, 1) + XCTAssertEqual(result.count, 1) XCTAssertTrue( FileManager.default.fileExists( atPath: root.appendingPathComponent( - "snapshot-generations-v2.json" + "snapshot-generations-v3.json" ).path ) ) + XCTAssertEqual(try Data(contentsOf: v2URL), v2Data) } func testRecordPersistsMetadataForFreshStoreInstance() async throws { @@ -148,6 +495,7 @@ final class FileProviderSnapshotStoreTests: XCTestCase { directory: directory, items: [document] ) + let recordedAnchor = try await requiredCurrentAnchor(of: firstStore) let reloadedStore = FileProviderSnapshotStore(rootURL: root) let reloadedItems = try await reloadedStore.items(directory: directory) @@ -156,7 +504,9 @@ final class FileProviderSnapshotStoreTests: XCTestCase { directory: directory, items: [document] ) - XCTAssertEqual(unchanged.anchor, recorded.anchor) + 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") } @@ -167,16 +517,19 @@ final class FileProviderSnapshotStoreTests: XCTestCase { 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: first.anchor) - XCTAssertEqual(delta.anchor, first.anchor) + 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) - let old = try await store.record(directory: .root, items: [try item(path: "a.txt")]).anchor + _ = 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")]) @@ -204,10 +557,11 @@ final class FileProviderSnapshotStoreTests: XCTestCase { let otherStore = FileProviderSnapshotStore(rootURL: otherRoot) _ = try await store.record(directory: .root, items: [try item(path: "local.txt")]) - let foreign = try await otherStore.record(directory: .root, items: [try item(path: "foreign.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.anchor) + try await store.delta(directory: .root, from: foreign) ) { error in XCTAssertEqual(error as? FileProviderSnapshotStoreError, .syncAnchorExpired) } @@ -226,23 +580,27 @@ final class FileProviderSnapshotStoreTests: XCTestCase { let original = try item(path: "nested/item.txt", size: 1) let changed = try item(path: "nested/item.txt", size: 2) - let expired = try await store.record( + _ = try await store.record( directory: .root, items: [rootItem] - ).anchor - let retained = try await store.record( + ) + let expired = try await requiredCurrentAnchor(of: store) + _ = try await store.record( directory: directory, items: [original] - ).anchor - let latest = try await store.record( + ) + let retained = try await requiredCurrentAnchor(of: store) + _ = try await store.record( directory: directory, items: [changed] - ).anchor + ) + let latest = try await requiredCurrentAnchor(of: store) _ = try await otherStore.record(directory: .root, items: [rootItem]) - let foreign = try await otherStore.record( + _ = try await otherStore.record( directory: directory, items: [original] - ).anchor + ) + let foreign = try await requiredCurrentAnchor(of: otherStore) let delta = try await store.workingSetDelta(from: retained) XCTAssertEqual(delta.anchor, latest) @@ -278,25 +636,28 @@ final class FileProviderSnapshotStoreTests: XCTestCase { 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) - let removed = try await store.record(directory: .root, items: []) + _ = try await store.record(directory: .root, items: []) + let removedAnchor = try await requiredCurrentAnchor(of: store) - XCTAssertEqual(generation(of: removed.anchor), generation(of: baseline.anchor) + 1) - let removedDelta = try await store.workingSetDelta(from: baseline.anchor) - XCTAssertEqual(removedDelta.anchor, removed.anchor) + 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.items[0], childRecord.items[0], baseline.items[0]] + [rootRecord[0], childRecord[0], baseline[0]] .map(\.itemIdentifier) .sorted { $0.rawValue < $1.rawValue } ) - let recreated = try await store.record(directory: .root, items: [nested]) + _ = try await store.record(directory: .root, items: [nested]) + let recreatedAnchor = try await requiredCurrentAnchor(of: store) let recreatedSnapshot = try await store.workingSetSnapshot() - XCTAssertEqual(recreatedSnapshot.anchor, recreated.anchor) + XCTAssertEqual(recreatedSnapshot.anchor, recreatedAnchor) XCTAssertEqual(recreatedSnapshot.items.map(\.remoteItem), [nested]) - let recreatedDelta = try await store.workingSetDelta(from: removed.anchor) + let recreatedDelta = try await store.workingSetDelta(from: removedAnchor) XCTAssertEqual(recreatedDelta.delta.updated.map(\.remoteItem), [nested]) XCTAssertTrue(recreatedDelta.delta.deleted.isEmpty) } @@ -313,16 +674,18 @@ final class FileProviderSnapshotStoreTests: XCTestCase { 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) - let changed = try await store.record(directory: .root, items: [renamed]) + _ = try await store.record(directory: .root, items: [renamed]) + let changedAnchor = try await requiredCurrentAnchor(of: store) - XCTAssertEqual(generation(of: changed.anchor), generation(of: baseline.anchor) + 1) - let delta = try await store.workingSetDelta(from: baseline.anchor) - XCTAssertEqual(delta.anchor, changed.anchor) + 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.items[0], childRecord.items[0], baseline.items[0]] + [rootRecord[0], childRecord[0], baseline[0]] .map(\.itemIdentifier) .sorted { $0.rawValue < $1.rawValue } ) @@ -342,16 +705,18 @@ final class FileProviderSnapshotStoreTests: XCTestCase { _ = 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) - let changed = try await store.record(directory: .root, items: [nestedFile]) + _ = try await store.record(directory: .root, items: [nestedFile]) + let changedAnchor = try await requiredCurrentAnchor(of: store) - XCTAssertEqual(generation(of: changed.anchor), generation(of: baseline.anchor) + 1) - let delta = try await store.workingSetDelta(from: baseline.anchor) - XCTAssertEqual(delta.anchor, changed.anchor) + 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.items[0], baseline.items[0]] + [childRecord[0], baseline[0]] .map(\.itemIdentifier) .sorted { $0.rawValue < $1.rawValue } ) @@ -363,7 +728,7 @@ final class FileProviderSnapshotStoreTests: XCTestCase { 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-v2.json") + 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] @@ -390,6 +755,37 @@ final class FileProviderSnapshotStoreTests: XCTestCase { } } + 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, From 4750dc251eb91805fef1f363cebe8f60cd9fe0a1 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 30 Jul 2026 16:20:17 -0700 Subject: [PATCH 11/16] fileprovider: serialize refreshes and mutations per domain Reconstruct the accepted per-domain operation coordinator as an atomic stack layer on the exact F10 mutation-journal parent. The coordinator preserves same-directory refresh coalescing, serial refresh/mutation ordering, waiter-owned cancellation, and the existing DEBUG behavior gates without adding later enumeration or signaling contracts. The production and test files are byte-identical to accepted blobs c5270849f85bf1e40f0463cb11e91c7878779dd5 and 50abd4486be588f7faeea7b00048521632db4e8b. Their provenance is the immutable accepted sequence 5109406b9724fa5d42b1fe1d52bc88581d5846fa, e8ec5cff2e171cce346bb8934bbee5a464731dee, and b0b0b27d7e82a795b1d50b5b98c7390ef14e7857, with accepted test stabilization from 5396170a1a54464796464a7b80c7441ba5bcad5a and 43c75cec128031ab43ce1414e415fbb01c9614d7. XcodeGen contributes only the eight expected project-membership lines; project.yml is unchanged. Verification before commit: F10 snapshot/error-mapper baseline 31/31; test-first compile RED confined to absent coordinator/polling types; coordinator GREEN 6/6; combined F10+F11 regression 37/37; normal Remux iPhone simulator build succeeded. --- Remux.xcodeproj/project.pbxproj | 8 + ...leProviderDomainOperationCoordinator.swift | 83 ++++++ ...viderDomainOperationCoordinatorTests.swift | 258 ++++++++++++++++++ 3 files changed, 349 insertions(+) create mode 100644 RemuxApp/Sources/FileProvider/FileProviderDomainOperationCoordinator.swift create mode 100644 RemuxAppTests/FileProviderDomainOperationCoordinatorTests.swift diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index 134b64ee..af455bc0 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ 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 */; }; 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 */; }; @@ -178,6 +179,7 @@ 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 */; }; @@ -262,6 +264,7 @@ 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; }; @@ -315,6 +318,7 @@ 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 = ""; }; 5A245FF40A1998FA618E6370 /* RemuxPreparedTransportCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxPreparedTransportCacheTests.swift; sourceTree = ""; }; 5A9A68C878571836AC7EDA41 /* GhosttyAttachmentStagingStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAttachmentStagingStoreTests.swift; sourceTree = ""; }; @@ -546,6 +550,7 @@ 6DF29AE5BA0A3DD1E7A9E9C5 /* ActiveSessionSwitcherProjectionTests.swift */, 28EE543D2B247AE6C6A8C472 /* ConnectionProfileRepositoryTests.swift */, DFD9AF0371D15A892B000530 /* DebugConnectionProfileSeederTests.swift */, + 16B659789EF103E965A513C0 /* FileProviderDomainOperationCoordinatorTests.swift */, AC73E66BB7BA53B5E6E31B31 /* FileProviderDomainReconcilerTests.swift */, 208AD7FB82B0A5E6C853B322 /* FileProviderErrorMapperTests.swift */, 795B3162A540A5A29DEBED76 /* FileProviderRemoteItemTests.swift */, @@ -686,6 +691,7 @@ 8114E55BD2CD34EEF8661EFC /* FileProvider */ = { isa = PBXGroup; children = ( + 5603BFAD5D9D83093B0ED030 /* FileProviderDomainOperationCoordinator.swift */, 9B50C6E14DA1AD375C55E17B /* FileProviderDomainReconciler.swift */, 55622A6FC71B6F2FEA79BCDA /* FileProviderErrorMapper.swift */, 46C317118AE1C94878179FBD /* FileProviderItemIdentity.swift */, @@ -984,6 +990,7 @@ E35A4C6758E1D06E01EE5826 /* ConnectionProfileRepository.swift in Sources */, 39A58132CBB849E2CDF37BC2 /* DebugConnectionProfileSeeder.swift in Sources */, 3266F0AE9A1F836618FF115D /* DeterministicTmuxControlTransport.swift in Sources */, + D4B1DF99E07CE5B888F58CA5 /* FileProviderDomainOperationCoordinator.swift in Sources */, 15636E0B3091E161F61321C1 /* FileProviderDomainReconciler.swift in Sources */, 58AFE513F5DB3A622A1DFD2B /* FileProviderErrorMapper.swift in Sources */, 126385C505991783E10079F1 /* FileProviderItemIdentity.swift in Sources */, @@ -1129,6 +1136,7 @@ 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 */, 236BD3D59A937BA6300DD043 /* FileProviderRemoteItemTests.swift in Sources */, 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/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) + } +} From 1a816419ccf4011c4e712cca4c1b46c568685b4c Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 30 Jul 2026 16:31:45 -0700 Subject: [PATCH 12/16] fileprovider: read through contained short-lived SFTP sessions Reconstruct the reviewed remote-read boundary from accepted bytes. The remote service and its tests come from the archived read-only implementation, while the Citadel provider composes the accepted read-only operation signature with the reconciled shared SSHAuthenticationMethodFactory call. Keep the provider scoped to RemuxSFTPReadOnlyClient and do not restore the obsolete RemuxSSHAuthenticationMethodFactory wrapper. Seed only the two accepted contract tests for password authentication and root-key construction. The replicated-extension-core test is intentionally omitted here and remains deferred to F21. This commit adds only the two production files, two test files, and deterministic XcodeGen membership. Verification covered the 37-test F11 baseline, an expected test-first compile failure on absent F12 types, the 51-test focused and SFTP regression gate, and a normal Remux simulator build. --- Remux.xcodeproj/project.pbxproj | 16 + ...ileProviderCitadelSFTPClientProvider.swift | 61 ++ .../FileProviderRemoteService.swift | 296 +++++++++ .../FileProviderRemoteServiceTests.swift | 617 ++++++++++++++++++ .../RemuxFileProviderContractTests.swift | 71 ++ 5 files changed, 1061 insertions(+) create mode 100644 RemuxApp/Sources/FileProvider/FileProviderCitadelSFTPClientProvider.swift create mode 100644 RemuxApp/Sources/FileProvider/FileProviderRemoteService.swift create mode 100644 RemuxAppTests/FileProviderRemoteServiceTests.swift create mode 100644 RemuxAppTests/RemuxFileProviderContractTests.swift diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index af455bc0..b4ac2fb0 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 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 */; }; @@ -82,6 +83,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 */; }; @@ -141,6 +143,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 */; }; @@ -148,6 +151,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 */; }; @@ -291,6 +295,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 = ""; }; @@ -323,6 +328,7 @@ 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 = ""; }; @@ -362,6 +368,7 @@ 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 = ""; }; @@ -446,6 +453,7 @@ 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 = ""; }; @@ -554,6 +562,7 @@ AC73E66BB7BA53B5E6E31B31 /* FileProviderDomainReconcilerTests.swift */, 208AD7FB82B0A5E6C853B322 /* FileProviderErrorMapperTests.swift */, 795B3162A540A5A29DEBED76 /* FileProviderRemoteItemTests.swift */, + E5E36D7E11A5C14870D1D35D /* FileProviderRemoteServiceTests.swift */, EC00AAA8C4518CE1F9530C00 /* FileProviderSharedStorageMigratorTests.swift */, 6BEAB20D60BA0EDED606847A /* FileProviderSharedStorageTests.swift */, A39CC72773E8432EBC3126E4 /* FileProviderSnapshotStoreTests.swift */, @@ -593,6 +602,7 @@ 603A62637F1E7C43FD9A1370 /* PanePreviewLayoutTests.swift */, 01FC67667E36CB7A186EFC68 /* RemuxActiveSessionCollectionTests.swift */, 03F3C1F4357FDC042F5B6E5C /* RemuxActiveSessionRuntimeReducerTests.swift */, + 37C20B4F77DEB78FC989C616 /* RemuxFileProviderContractTests.swift */, C7313F7C3049C269572F59B5 /* RemuxLibrarySSHPrewarmCoordinatorTests.swift */, F165D54E7110056429A070F7 /* RemuxLibrarySSHPrewarmPlannerTests.swift */, 5A245FF40A1998FA618E6370 /* RemuxPreparedTransportCacheTests.swift */, @@ -691,6 +701,7 @@ 8114E55BD2CD34EEF8661EFC /* FileProvider */ = { isa = PBXGroup; children = ( + 850AA4E5683E7E55F09E929C /* FileProviderCitadelSFTPClientProvider.swift */, 5603BFAD5D9D83093B0ED030 /* FileProviderDomainOperationCoordinator.swift */, 9B50C6E14DA1AD375C55E17B /* FileProviderDomainReconciler.swift */, 55622A6FC71B6F2FEA79BCDA /* FileProviderErrorMapper.swift */, @@ -698,6 +709,7 @@ 85CEC108FE540369F1F15BF4 /* FileProviderItemProjection.swift */, 2585CF2DC8CEC5017488892D /* FileProviderRemoteItem.swift */, FF66E730E8F051A08B20F833 /* FileProviderRemotePath.swift */, + 5DB7086AFA61A884FC2D874C /* FileProviderRemoteService.swift */, 4369D3BB817295081F553170 /* FileProviderSnapshotStore.swift */, ); path = FileProvider; @@ -990,6 +1002,7 @@ 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 */, 58AFE513F5DB3A622A1DFD2B /* FileProviderErrorMapper.swift in Sources */, @@ -997,6 +1010,7 @@ F696F748589435EFB7894157 /* FileProviderItemProjection.swift in Sources */, C5F95736CD435CA56D834B5E /* FileProviderRemoteItem.swift in Sources */, 8F80983038DB0393C3C27087 /* FileProviderRemotePath.swift in Sources */, + 4F979ED3B4BBD23818B7D8C6 /* FileProviderRemoteService.swift in Sources */, 0F4E56668E1D070DA2549277 /* FileProviderSharedStorageMigrator.swift in Sources */, 5EA4F47B4AD597835C78A229 /* FileProviderSnapshotStore.swift in Sources */, A7AFC27CD8E0B30AFF1A32BF /* GhosttyAttachmentImageMarkupEditor.swift in Sources */, @@ -1140,6 +1154,7 @@ DE5BB5328EF5F61261C1B842 /* FileProviderDomainReconcilerTests.swift in Sources */, 808F5D8AF6DE8AC1A8802B5F /* FileProviderErrorMapperTests.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 */, @@ -1179,6 +1194,7 @@ 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 */, diff --git a/RemuxApp/Sources/FileProvider/FileProviderCitadelSFTPClientProvider.swift b/RemuxApp/Sources/FileProvider/FileProviderCitadelSFTPClientProvider.swift new file mode 100644 index 00000000..905c3acb --- /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 RemuxSFTPReadOnlyClient) 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/FileProviderRemoteService.swift b/RemuxApp/Sources/FileProvider/FileProviderRemoteService.swift new file mode 100644 index 00000000..08c8b424 --- /dev/null +++ b/RemuxApp/Sources/FileProvider/FileProviderRemoteService.swift @@ -0,0 +1,296 @@ +import Foundation + +enum FileProviderRemoteServiceError: Error, Equatable, Sendable { + case invalidDomainIdentifier + case serverNotFound +} + +protocol FileProviderSFTPClientProviding: Sendable { + func withClient( + server: SavedServer, + authentication: ResolvedSSHAuth, + operation: @Sendable (any RemuxSFTPReadOnlyClient) async throws -> Value + ) async throws -> Value + + func closeIdleConnections(forServerID serverID: SavedServer.ID) async +} + +struct FileProviderRemoteFetchProgress: Equatable, Sendable { + let totalByteCount: Int64 + let completedByteCount: Int64 +} + +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 invalidate() async +} + +struct FileProviderRemoteService: FileProviderRemoteServicing { + private let domainIdentifier: String + private let profiles: any ConnectionProfileRepository + private let credentials: any SSHCredentialStore + private let clientProvider: any FileProviderSFTPClientProviding + private let safeLinkResolver = FileProviderSafeLinkResolver() + + 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 withClient { client in + let canonicalHome = try await client.realPath(atPath: ".") + let canonicalDirectory = try await canonicalDirectory( + for: directory, + client: client, + home: canonicalHome + ) + 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 item(at path: FileProviderRemotePath) async throws -> FileProviderRemoteItem { + try await withClient { client in + let canonicalHome = try await client.realPath(atPath: ".") + let entry = if path == .root { + canonicalHome + } else { + try await canonicalEntry( + for: path, + client: client, + home: canonicalHome + ) + } + 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 fetch( + path: FileProviderRemotePath, + to localURL: URL, + progress: @escaping @Sendable (FileProviderRemoteFetchProgress) async -> Void + ) async throws -> FileProviderRemoteItem { + try await withClient { client in + try Task.checkCancellation() + let canonicalHome = try await client.realPath(atPath: ".") + let entry = try await canonicalEntry( + for: path, + client: client, + home: canonicalHome + ) + 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 invalidate() async { + guard let serverID = UUID(uuidString: domainIdentifier) else { + return + } + await clientProvider.closeIdleConnections(forServerID: serverID) + } + + private func withClient( + _ operation: @Sendable (any RemuxSFTPReadOnlyClient) 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 func canonicalDirectory( + for directory: FileProviderRemotePath, + client: any RemuxSFTPReadOnlyClient, + home canonicalHome: String + ) async throws -> String { + let requestedDirectory = try directory.remotePath( + beneath: canonicalHome + ) + guard directory != .root else { + return requestedDirectory + } + + let canonicalDirectory = try await client.realPath( + atPath: requestedDirectory + ) + try safeLinkResolver.ensureContained( + canonicalDirectory, + home: canonicalHome + ) + return canonicalDirectory + } + + private func canonicalEntry( + for path: FileProviderRemotePath, + client: any RemuxSFTPReadOnlyClient, + home canonicalHome: String + ) async throws -> String { + guard path != .root, + let name = path.relative.split(separator: "/").last.map(String.init) + else { + return canonicalHome + } + + let parentRelative = (path.relative as NSString) + .deletingLastPathComponent + let parent = try FileProviderRemotePath(relative: parentRelative) + let canonicalParent = try await canonicalDirectory( + for: parent, + client: client, + home: canonicalHome + ) + return try append(component: name, to: canonicalParent) + } + + private func append( + component: String, + to canonicalDirectory: String + ) throws -> String { + try FileProviderPathValidation.validateCanonicalAbsolute( + canonicalDirectory + ) + guard isValidChildName(component) else { + throw FileProviderRemotePathError.invalidRelativePath + } + guard canonicalDirectory != "/" else { + return "/\(component)" + } + return "\(canonicalDirectory)/\(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/RemuxAppTests/FileProviderRemoteServiceTests.swift b/RemuxAppTests/FileProviderRemoteServiceTests.swift new file mode 100644 index 00000000..aa6cd820 --- /dev/null +++ b/RemuxAppTests/FileProviderRemoteServiceTests.swift @@ -0,0 +1,617 @@ +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 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: + RemuxSFTPReadOnlyClient +{ + 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 { + } +} + +struct FileProviderRemoteServiceFixture { + let service: FileProviderRemoteService + let clientProvider: FileProviderTestSFTPClientProvider + 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 RemuxSFTPReadOnlyClient + ) 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 clientProvider = FileProviderTestSFTPClientProvider(client: client) + return FileProviderRemoteServiceFixture( + service: FileProviderRemoteService( + domainIdentifier: server.id.uuidString.lowercased(), + profiles: profiles, + credentials: credentials, + clientProvider: clientProvider + ), + clientProvider: clientProvider, + rootURL: root, + server: server + ) + } +} + +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 RemuxSFTPReadOnlyClient + private let lock = NSLock() + private var calls = 0 + private var closedServers: [SavedServer.ID] = [] + + init(client: any RemuxSFTPReadOnlyClient) { + 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 RemuxSFTPReadOnlyClient) 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 +} + +final class FileProviderTestSFTPClient: RemuxSFTPReadOnlyClient, @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)? + + 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..] = [] + 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/RemuxFileProviderContractTests.swift b/RemuxAppTests/RemuxFileProviderContractTests.swift new file mode 100644 index 00000000..5885bd9e --- /dev/null +++ b/RemuxAppTests/RemuxFileProviderContractTests.swift @@ -0,0 +1,71 @@ +@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") + } +} From 48596b914ad94ab33a631a6387f96a771e810509 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 30 Jul 2026 16:49:12 -0700 Subject: [PATCH 13/16] fileprovider: define writable request and error contracts Reconstruct F13 strictly by moving the immutable accepted source and test blobs named in the File Provider stack plan. Add the request, validator, and initial SDK-item contracts; extend the accepted error mapper and writable item projection; and carry the exact validator, mapper, and remote-item test boundaries. The accepted tests moved first and produced the expected compile-only RED for the absent mutation validator contracts. After moving the accepted production bytes, the focused validator, mapper, and remote-item gate passed 30 tests, the retained F12 remote-service and shared-contract regression gate passed 13 tests, and the normal Remux simulator build succeeded. Regenerate the Xcode project with XcodeGen 2.44.1. project.yml remains byte-identical and the project file contains exactly the 16 accepted-oracle membership additions. No product behavior, test behavior, compatibility code, glue, formatting cleanup, or remote state was authored or mutated. --- Remux.xcodeproj/project.pbxproj | 16 +++ .../FileProviderErrorMapper.swift | 20 ++++ .../FileProviderItemProjection.swift | 21 +++- .../FileProviderMutationRequest.swift | 27 +++++ .../FileProviderMutationValidator.swift | 110 +++++++++++++++++ .../FileProvider/FileProviderSDKItem.swift | 37 ++++++ .../FileProviderErrorMapperTests.swift | 52 ++++++++ .../FileProviderMutationValidatorTests.swift | 113 ++++++++++++++++++ .../FileProviderRemoteItemTests.swift | 49 ++++++++ 9 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 RemuxApp/Sources/FileProvider/FileProviderMutationRequest.swift create mode 100644 RemuxApp/Sources/FileProvider/FileProviderMutationValidator.swift create mode 100644 RemuxApp/Sources/FileProvider/FileProviderSDKItem.swift create mode 100644 RemuxAppTests/FileProviderMutationValidatorTests.swift diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index b4ac2fb0..26eaa446 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -56,6 +56,7 @@ 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 */; }; @@ -103,6 +104,7 @@ 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 */; }; @@ -169,6 +171,7 @@ 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 */; }; @@ -176,6 +179,7 @@ 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 */; }; @@ -320,6 +324,7 @@ 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 = ""; }; @@ -408,6 +413,7 @@ 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 = ""; }; @@ -422,6 +428,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 = ""; }; @@ -432,6 +439,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 = ""; }; @@ -561,6 +569,7 @@ 16B659789EF103E965A513C0 /* FileProviderDomainOperationCoordinatorTests.swift */, AC73E66BB7BA53B5E6E31B31 /* FileProviderDomainReconcilerTests.swift */, 208AD7FB82B0A5E6C853B322 /* FileProviderErrorMapperTests.swift */, + D3E60D2CC9BE153BDB8F5E3F /* FileProviderMutationValidatorTests.swift */, 795B3162A540A5A29DEBED76 /* FileProviderRemoteItemTests.swift */, E5E36D7E11A5C14870D1D35D /* FileProviderRemoteServiceTests.swift */, EC00AAA8C4518CE1F9530C00 /* FileProviderSharedStorageMigratorTests.swift */, @@ -707,9 +716,12 @@ 55622A6FC71B6F2FEA79BCDA /* FileProviderErrorMapper.swift */, 46C317118AE1C94878179FBD /* FileProviderItemIdentity.swift */, 85CEC108FE540369F1F15BF4 /* FileProviderItemProjection.swift */, + 5133DB5A0970D94CC2A8241C /* FileProviderMutationRequest.swift */, + B1CC46C61A0973AD4282BF1F /* FileProviderMutationValidator.swift */, 2585CF2DC8CEC5017488892D /* FileProviderRemoteItem.swift */, FF66E730E8F051A08B20F833 /* FileProviderRemotePath.swift */, 5DB7086AFA61A884FC2D874C /* FileProviderRemoteService.swift */, + C682B5372D1AC1D7C99D6B2C /* FileProviderSDKItem.swift */, 4369D3BB817295081F553170 /* FileProviderSnapshotStore.swift */, ); path = FileProvider; @@ -1008,9 +1020,12 @@ 58AFE513F5DB3A622A1DFD2B /* FileProviderErrorMapper.swift in Sources */, 126385C505991783E10079F1 /* FileProviderItemIdentity.swift in Sources */, F696F748589435EFB7894157 /* FileProviderItemProjection.swift in Sources */, + 61A9669449C8D38F4BB549D2 /* FileProviderMutationRequest.swift in Sources */, + BDAFCD98A1EC648F04A2C569 /* FileProviderMutationValidator.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 */, @@ -1153,6 +1168,7 @@ 377949211A56AF7C2A97E38E /* FileProviderDomainOperationCoordinatorTests.swift in Sources */, DE5BB5328EF5F61261C1B842 /* FileProviderDomainReconcilerTests.swift in Sources */, 808F5D8AF6DE8AC1A8802B5F /* FileProviderErrorMapperTests.swift in Sources */, + C90D9F9D9A64855E4FDABF63 /* FileProviderMutationValidatorTests.swift in Sources */, 236BD3D59A937BA6300DD043 /* FileProviderRemoteItemTests.swift in Sources */, 118AFD1A427E42BA889C1902 /* FileProviderRemoteServiceTests.swift in Sources */, DB3525FC134F5E53968FBB63 /* FileProviderSharedStorageMigratorTests.swift in Sources */, diff --git a/RemuxApp/Sources/FileProvider/FileProviderErrorMapper.swift b/RemuxApp/Sources/FileProvider/FileProviderErrorMapper.swift index 26fc78b3..4f32e3ce 100644 --- a/RemuxApp/Sources/FileProvider/FileProviderErrorMapper.swift +++ b/RemuxApp/Sources/FileProvider/FileProviderErrorMapper.swift @@ -91,6 +91,26 @@ enum FileProviderErrorMapper { 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) } diff --git a/RemuxApp/Sources/FileProvider/FileProviderItemProjection.swift b/RemuxApp/Sources/FileProvider/FileProviderItemProjection.swift index 5d99e0fe..dd230ba3 100644 --- a/RemuxApp/Sources/FileProvider/FileProviderItemProjection.swift +++ b/RemuxApp/Sources/FileProvider/FileProviderItemProjection.swift @@ -21,7 +21,7 @@ struct FileProviderItemProjection: @unchecked Sendable { self.contentType = Self.contentType(for: remoteItem) self.documentSize = remoteItem.size.map(NSNumber.init(value:)) self.contentModificationDate = remoteItem.modificationDate - self.capabilities = [.allowsReading] + self.capabilities = Self.capabilities(for: remoteItem) self.itemVersion = NSFileProviderItemVersion( contentVersion: remoteItem.contentVersion, metadataVersion: remoteItem.metadataVersion @@ -42,6 +42,25 @@ struct FileProviderItemProjection: @unchecked Sendable { } } + 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, 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/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/RemuxAppTests/FileProviderErrorMapperTests.swift b/RemuxAppTests/FileProviderErrorMapperTests.swift index e1d05865..f62b1db2 100644 --- a/RemuxAppTests/FileProviderErrorMapperTests.swift +++ b/RemuxAppTests/FileProviderErrorMapperTests.swift @@ -129,6 +129,58 @@ final class FileProviderErrorMapperTests: XCTestCase { 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, 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 index b9bf1985..343804c5 100644 --- a/RemuxAppTests/FileProviderRemoteItemTests.swift +++ b/RemuxAppTests/FileProviderRemoteItemTests.swift @@ -183,6 +183,55 @@ final class FileProviderRemoteItemTests: XCTestCase { 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, From 5916b2c4d63325ae38bacfd20388b2d584e9d0f3 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 30 Jul 2026 17:04:22 -0700 Subject: [PATCH 14/16] fileprovider: enumerate snapshots with cancellable polling Add the accepted read-side enumeration layer. FileProviderEnumeratorCore owns initial refresh, anchored deltas, working-set aggregation, and acknowledgement only after signal delivery. FileProviderPollingLoop owns immediate polling, start-to-start cadence, and invalidation that detaches sleepers and drains or cancels in-flight refresh work. This is a move-only reconstruction from immutable accepted bytes: enumerator blob 7cf2e7a21a4f2e69afd8e9779131567fc2ec8209, polling blob ee5fc49976b053a42cf00133c9ab95dcf3195c20, and 1,332-line contract-test blob d5d5458a3fdaa7f905c7bfeece93f0cba655dbb5. XcodeGen contributes only the eight accepted project-membership lines. Evidence: the F13 baseline passed 19 tests; the tests-first compile failed only on absent enumerator and polling contracts; the focused contract suite passed 23 tests; the combined contract, coordinator, and remote-service gate passed 40 tests; and the normal Remux simulator build succeeded. project.yml and both standalone regression suites remain byte-identical. --- Remux.xcodeproj/project.pbxproj | 8 + .../FileProviderEnumeratorCore.swift | 132 ++ .../FileProviderPollingLoop.swift | 74 + .../RemuxFileProviderContractTests.swift | 1261 +++++++++++++++++ 4 files changed, 1475 insertions(+) create mode 100644 RemuxApp/Sources/FileProvider/FileProviderEnumeratorCore.swift create mode 100644 RemuxApp/Sources/FileProvider/FileProviderPollingLoop.swift diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index 26eaa446..cc6deb6b 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -161,6 +161,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 */; }; @@ -194,6 +195,7 @@ 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 */; }; @@ -365,6 +367,7 @@ 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 = ""; }; 7F7619C21FCE4B90EA93C0B2 /* FileProviderSharedStorageMigrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderSharedStorageMigrator.swift; sourceTree = ""; }; 810D178FA878A5BEA4265D7E /* RemuxSSHExecSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxSSHExecSession.swift; sourceTree = ""; }; @@ -405,6 +408,7 @@ 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 = ""; }; @@ -713,11 +717,13 @@ 850AA4E5683E7E55F09E929C /* FileProviderCitadelSFTPClientProvider.swift */, 5603BFAD5D9D83093B0ED030 /* FileProviderDomainOperationCoordinator.swift */, 9B50C6E14DA1AD375C55E17B /* FileProviderDomainReconciler.swift */, + A845252140A8277612A99297 /* FileProviderEnumeratorCore.swift */, 55622A6FC71B6F2FEA79BCDA /* FileProviderErrorMapper.swift */, 46C317118AE1C94878179FBD /* FileProviderItemIdentity.swift */, 85CEC108FE540369F1F15BF4 /* FileProviderItemProjection.swift */, 5133DB5A0970D94CC2A8241C /* FileProviderMutationRequest.swift */, B1CC46C61A0973AD4282BF1F /* FileProviderMutationValidator.swift */, + 79E3AAD928222C570371FE3A /* FileProviderPollingLoop.swift */, 2585CF2DC8CEC5017488892D /* FileProviderRemoteItem.swift */, FF66E730E8F051A08B20F833 /* FileProviderRemotePath.swift */, 5DB7086AFA61A884FC2D874C /* FileProviderRemoteService.swift */, @@ -1017,11 +1023,13 @@ 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 */, 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 */, 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/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/RemuxAppTests/RemuxFileProviderContractTests.swift b/RemuxAppTests/RemuxFileProviderContractTests.swift index 5885bd9e..d5d5458a 100644 --- a/RemuxAppTests/RemuxFileProviderContractTests.swift +++ b/RemuxAppTests/RemuxFileProviderContractTests.swift @@ -68,4 +68,1265 @@ final class RemuxFileProviderContractTests: XCTestCase { 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 + + 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 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: RemuxSFTPReadOnlyClient { + 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) + } +} + +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()) + } } From 83b6433055ea516dacb2ec403179b251ada819a8 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 30 Jul 2026 17:20:39 -0700 Subject: [PATCH 15/16] fileprovider: contain mutations in one cancellable SFTP session Reconstruct Task F15 from accepted immutable history on exact F14 parent 2d5796f060017c2e53b12640909992c90a3a562f. The remote service and Citadel provider are the pinned final blobs; the two test files are the pinned F15 intermediate compositions. No product behavior, tests, glue, compatibility, migration, cleanup, or formatting were authored. This layer owns one short-lived writable SFTP lease for contained item, list, create-directory, upload, rename, file removal, and empty-directory removal operations. It rejects escaping parents and symlink mutation sources, and preserves lifetime ownership so cancellation cannot complete before the registered child operation is cancelled and drained. Tests were moved first. The RED build failed only for absent withMutationAccess, FileProviderRemoteMutationAccess, and writable provider conformance. The final focused remote suite passed 17 tests, including six new mutation-session tests; the combined remote, contract, and SFTP regression gate passed 78 tests with no failures or skips; and the normal Remux simulator build succeeded. Direct boundary: production +247/-3 and tests +471/-7 across exactly four modified paths. Remux.xcodeproj/project.pbxproj and project.yml remain byte-identical to F14. The accepted F21 invalidation test and later mutation-core behavior remain deferred. --- ...ileProviderCitadelSFTPClientProvider.swift | 2 +- .../FileProviderRemoteService.swift | 431 ++++++++++------- .../FileProviderRemoteServiceTests.swift | 444 +++++++++++++++++- .../RemuxFileProviderContractTests.swift | 34 +- 4 files changed, 735 insertions(+), 176 deletions(-) diff --git a/RemuxApp/Sources/FileProvider/FileProviderCitadelSFTPClientProvider.swift b/RemuxApp/Sources/FileProvider/FileProviderCitadelSFTPClientProvider.swift index 905c3acb..6b5ef6fb 100644 --- a/RemuxApp/Sources/FileProvider/FileProviderCitadelSFTPClientProvider.swift +++ b/RemuxApp/Sources/FileProvider/FileProviderCitadelSFTPClientProvider.swift @@ -22,7 +22,7 @@ struct FileProviderCitadelSFTPClientProvider: FileProviderSFTPClientProviding { func withClient( server: SavedServer, authentication: ResolvedSSHAuth, - operation: @Sendable (any RemuxSFTPReadOnlyClient) async throws -> Value + operation: @Sendable (any RemuxSFTPFileProviderClient) async throws -> Value ) async throws -> Value { let credential: SSHCredential switch authentication.credential { diff --git a/RemuxApp/Sources/FileProvider/FileProviderRemoteService.swift b/RemuxApp/Sources/FileProvider/FileProviderRemoteService.swift index 08c8b424..f69d4ff1 100644 --- a/RemuxApp/Sources/FileProvider/FileProviderRemoteService.swift +++ b/RemuxApp/Sources/FileProvider/FileProviderRemoteService.swift @@ -9,7 +9,7 @@ protocol FileProviderSFTPClientProviding: Sendable { func withClient( server: SavedServer, authentication: ResolvedSSHAuth, - operation: @Sendable (any RemuxSFTPReadOnlyClient) async throws -> Value + operation: @Sendable (any RemuxSFTPFileProviderClient) async throws -> Value ) async throws -> Value func closeIdleConnections(forServerID serverID: SavedServer.ID) async @@ -20,6 +20,23 @@ struct FileProviderRemoteFetchProgress: Equatable, Sendable { 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] @@ -28,6 +45,9 @@ protocol FileProviderRemoteServicing: Sendable { 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 } @@ -36,7 +56,6 @@ struct FileProviderRemoteService: FileProviderRemoteServicing { private let profiles: any ConnectionProfileRepository private let credentials: any SSHCredentialStore private let clientProvider: any FileProviderSFTPClientProviding - private let safeLinkResolver = FileProviderSafeLinkResolver() init( domainIdentifier: String, @@ -51,96 +70,14 @@ struct FileProviderRemoteService: FileProviderRemoteServicing { } func list(directory: FileProviderRemotePath) async throws -> [FileProviderRemoteItem] { - try await withClient { client in - let canonicalHome = try await client.realPath(atPath: ".") - let canonicalDirectory = try await canonicalDirectory( - for: directory, - client: client, - home: canonicalHome - ) - 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 + try await withAccess { access in + try await access.list(directory: directory) } } func item(at path: FileProviderRemotePath) async throws -> FileProviderRemoteItem { - try await withClient { client in - let canonicalHome = try await client.realPath(atPath: ".") - let entry = if path == .root { - canonicalHome - } else { - try await canonicalEntry( - for: path, - client: client, - home: canonicalHome - ) - } - 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) - } + try await withAccess { access in + try await access.item(at: path) } } @@ -149,43 +86,16 @@ struct FileProviderRemoteService: FileProviderRemoteServicing { to localURL: URL, progress: @escaping @Sendable (FileProviderRemoteFetchProgress) async -> Void ) async throws -> FileProviderRemoteItem { - try await withClient { client in - try Task.checkCancellation() - let canonicalHome = try await client.realPath(atPath: ".") - let entry = try await canonicalEntry( - for: path, - client: client, - home: canonicalHome - ) - 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) + 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) } } @@ -196,8 +106,24 @@ struct FileProviderRemoteService: FileProviderRemoteServicing { 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 RemuxSFTPReadOnlyClient) async throws -> Value + _ operation: @Sendable (any RemuxSFTPFileProviderClient) async throws -> Value ) async throws -> Value { let snapshot = try await profiles.loadSnapshot() guard let serverID = UUID(uuidString: domainIdentifier) else { @@ -214,74 +140,243 @@ struct FileProviderRemoteService: FileProviderRemoteServicing { operation: operation ) } +} - private func canonicalDirectory( - for directory: FileProviderRemotePath, - client: any RemuxSFTPReadOnlyClient, - home canonicalHome: String - ) async throws -> String { - let requestedDirectory = try directory.remotePath( - beneath: canonicalHome - ) - guard directory != .root else { - return requestedDirectory +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)) + } } - let canonicalDirectory = try await client.realPath( - atPath: requestedDirectory + 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( - canonicalDirectory, + canonicalEntry, home: canonicalHome ) - return canonicalDirectory + 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 + ) } - private func canonicalEntry( - for path: FileProviderRemotePath, - client: any RemuxSFTPReadOnlyClient, - home canonicalHome: String - ) async throws -> String { + 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 { - return canonicalHome + throw FileProviderRemotePathError.invalidRelativePath } - - let parentRelative = (path.relative as NSString) - .deletingLastPathComponent - let parent = try FileProviderRemotePath(relative: parentRelative) - let canonicalParent = try await canonicalDirectory( - for: parent, - client: client, - home: canonicalHome + let parent = try FileProviderRemotePath( + relative: (path.relative as NSString).deletingLastPathComponent ) - return try append(component: name, to: canonicalParent) + return try append(component: name, to: try await canonicalDirectory(for: parent)) } - private func append( - component: String, - to canonicalDirectory: String - ) throws -> String { - try FileProviderPathValidation.validateCanonicalAbsolute( - canonicalDirectory + 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 } - guard canonicalDirectory != "/" else { - return "/\(component)" - } - return "\(canonicalDirectory)/\(component)" + return directory == "/" ? "/\(component)" : "\(directory)/\(component)" } private func childPath( named name: String, beneath directory: FileProviderRemotePath ) -> FileProviderRemotePath? { - guard isValidChildName(name) else { - return nil - } + guard isValidChildName(name) else { return nil } let relative = directory.relative.isEmpty ? name : "\(directory.relative)/\(name)" return try? FileProviderRemotePath(relative: relative) } diff --git a/RemuxAppTests/FileProviderRemoteServiceTests.swift b/RemuxAppTests/FileProviderRemoteServiceTests.swift index aa6cd820..0e697c06 100644 --- a/RemuxAppTests/FileProviderRemoteServiceTests.swift +++ b/RemuxAppTests/FileProviderRemoteServiceTests.swift @@ -55,6 +55,179 @@ final class FileProviderRemoteServiceTests: XCTestCase { 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() @@ -289,7 +462,7 @@ final class FileProviderRemoteServiceTests: XCTestCase { } private actor FileProviderFinalComponentSwapSFTPClient: - RemuxSFTPReadOnlyClient + RemuxSFTPFileProviderClient { private let home: String private let requestedFile: String @@ -358,10 +531,47 @@ private actor FileProviderFinalComponentSwapSFTPClient: } ) } + + 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 func XCTAssertThrowsErrorAsync( + _ 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 { @@ -369,12 +579,15 @@ private func XCTAssertThrowsErrorAsync( _ = 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 @@ -427,7 +640,7 @@ struct FileProviderRemoteServiceFixture { } static func make( - client: any RemuxSFTPReadOnlyClient + client: any RemuxSFTPFileProviderClient ) async throws -> FileProviderRemoteServiceFixture { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -444,6 +657,9 @@ struct FileProviderRemoteServiceFixture { 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( @@ -453,10 +669,46 @@ struct FileProviderRemoteServiceFixture { 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 { @@ -476,12 +728,12 @@ actor FileProviderTestCredentialStore: SSHCredentialStore { } final class FileProviderTestSFTPClientProvider: FileProviderSFTPClientProviding, @unchecked Sendable { - let client: any RemuxSFTPReadOnlyClient + let client: any RemuxSFTPFileProviderClient private let lock = NSLock() private var calls = 0 private var closedServers: [SavedServer.ID] = [] - init(client: any RemuxSFTPReadOnlyClient) { + init(client: any RemuxSFTPFileProviderClient) { self.client = client } @@ -496,7 +748,7 @@ final class FileProviderTestSFTPClientProvider: FileProviderSFTPClientProviding, func withClient( server: SavedServer, authentication: ResolvedSSHAuth, - operation: @Sendable (any RemuxSFTPReadOnlyClient) async throws -> Value + operation: @Sendable (any RemuxSFTPFileProviderClient) async throws -> Value ) async throws -> Value { lock.withLock { calls += 1 @@ -522,12 +774,22 @@ enum FileProviderTestSFTPFailure: Error { case unresolved } -final class FileProviderTestSFTPClient: RemuxSFTPReadOnlyClient, @unchecked Sendable { +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], @@ -590,6 +852,176 @@ final class FileProviderTestSFTPClient: RemuxSFTPReadOnlyClient, @unchecked Send } return try await operation(file) } + + func createDirectory(atPath path: String) async throws { + lock.withLock { + recordedMutations.append(.createDirectory(path)) + } + } + + func uploadFile( + from localURL: URL, + to remotePath: String, + progress: @escaping RemuxSFTPFileUploadProgressHandler + ) async throws { + lock.withLock { + recordedMutations.append(.uploadFile(remotePath)) + } + await progress(Int64(try Data(contentsOf: localURL).count)) + } + + func renameItem(from sourcePath: String, to destinationPath: String) async throws { + lock.withLock { + recordedMutations.append(.renameItem(sourcePath, destinationPath)) + } + } + + func removeFile(atPath path: String) async throws { + lock.withLock { + recordedMutations.append(.removeFile(path)) + } + } + + func removeEmptyDirectory(atPath path: String) async throws { + lock.withLock { + recordedMutations.append(.removeEmptyDirectory(path)) + } + } + + func mutations() async -> [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 { diff --git a/RemuxAppTests/RemuxFileProviderContractTests.swift b/RemuxAppTests/RemuxFileProviderContractTests.swift index d5d5458a..f79dd2b0 100644 --- a/RemuxAppTests/RemuxFileProviderContractTests.swift +++ b/RemuxAppTests/RemuxFileProviderContractTests.swift @@ -916,6 +916,7 @@ private actor FileProviderTestSequencedRemoteService: FileProviderRemoteServicin private let listings: [[FileProviderRemoteItem]] private let firstRefreshGate: FileProviderTestRefreshGate? private var nextListingIndex = 0 + private var mutationAccessCallCount = 0 init( listings: [[FileProviderRemoteItem]], @@ -949,6 +950,13 @@ private actor FileProviderTestSequencedRemoteService: FileProviderRemoteServicin func invalidate() { } + func withMutationAccess( + _ operation: @Sendable (any FileProviderRemoteMutationAccess) async throws -> Value + ) throws -> Value { + mutationAccessCallCount += 1 + throw RemuxSFTPClientError.noSuchFile("mutation access") + } + func listCallCount() -> Int { nextListingIndex } @@ -1187,7 +1195,7 @@ private actor FileProviderFailingOnceSignaler: } } -private actor FileProviderSequencedSFTPClient: RemuxSFTPReadOnlyClient { +private actor FileProviderSequencedSFTPClient: RemuxSFTPFileProviderClient { private let home: String private let listings: [[RemuxSFTPDirectoryEntry]] private var listingIndex = 0 @@ -1231,6 +1239,30 @@ private actor FileProviderSequencedSFTPClient: RemuxSFTPReadOnlyClient { ) 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 { From 1d0e2f08d4f054506f4dbbdd1edda409a45a2750 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 30 Jul 2026 17:34:57 -0700 Subject: [PATCH 16/16] fileprovider: create remote items with replay-safe commits Reconstruct the accepted F16 create-only mutation core on the exact F15 parent. The core reserves opaque identity before remote creation, uses strict temporary-upload and rename sequencing, and commits authoritative snapshot state and replay receipts only at the accepted boundary. Move only immutable accepted bytes: FileProviderMutationCore.swift is exact blob f83882b7d4ce1e6de1eadf4640ddd7911537cc7f; FileProviderMutationCoreTests.swift is exact composed blob 0ed234881f7e75c454f2a74011271b5db437d4e4 from natural blob 51fb63cf472cba82421a2244a6cf48371dc9dd27 ranges 1-228, 327-328, 337-346, and 351-514. XcodeGen 2.44.1 adds only the eight deterministic accepted-oracle source and test membership lines; project.yml is unchanged. Tests first failed only for absent FileProviderMutationCore and FileProviderCreateMutationError contracts. The accepted implementation then passed FileProviderMutationCoreTests 12/12 and the combined mutation-core, snapshot-store, and operation-coordinator gate 41/41. A normal Remux iPhone 17 iOS 26.5 simulator build also succeeded. The accepted suite covers replay, collision and symlink rejection, upload/rename cleanup, progress, cancellation before rename, and authoritative commit after rename. --- Remux.xcodeproj/project.pbxproj | 8 + .../FileProviderMutationCore.swift | 185 ++++++++ .../FileProviderMutationCoreTests.swift | 404 ++++++++++++++++++ 3 files changed, 597 insertions(+) create mode 100644 RemuxApp/Sources/FileProvider/FileProviderMutationCore.swift create mode 100644 RemuxAppTests/FileProviderMutationCoreTests.swift diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index cc6deb6b..ec69d9b0 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ 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 */; }; @@ -130,6 +131,7 @@ 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 */; }; @@ -332,6 +334,7 @@ 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 = ""; }; @@ -369,6 +372,7 @@ 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 = ""; }; @@ -573,6 +577,7 @@ 16B659789EF103E965A513C0 /* FileProviderDomainOperationCoordinatorTests.swift */, AC73E66BB7BA53B5E6E31B31 /* FileProviderDomainReconcilerTests.swift */, 208AD7FB82B0A5E6C853B322 /* FileProviderErrorMapperTests.swift */, + 590F8C3561F59A464ADA9CB9 /* FileProviderMutationCoreTests.swift */, D3E60D2CC9BE153BDB8F5E3F /* FileProviderMutationValidatorTests.swift */, 795B3162A540A5A29DEBED76 /* FileProviderRemoteItemTests.swift */, E5E36D7E11A5C14870D1D35D /* FileProviderRemoteServiceTests.swift */, @@ -721,6 +726,7 @@ 55622A6FC71B6F2FEA79BCDA /* FileProviderErrorMapper.swift */, 46C317118AE1C94878179FBD /* FileProviderItemIdentity.swift */, 85CEC108FE540369F1F15BF4 /* FileProviderItemProjection.swift */, + 7E641D81DB0036E0EDEEC810 /* FileProviderMutationCore.swift */, 5133DB5A0970D94CC2A8241C /* FileProviderMutationRequest.swift */, B1CC46C61A0973AD4282BF1F /* FileProviderMutationValidator.swift */, 79E3AAD928222C570371FE3A /* FileProviderPollingLoop.swift */, @@ -1027,6 +1033,7 @@ 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 */, @@ -1176,6 +1183,7 @@ 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 */, 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/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)")) } +}