From e6fde2a16e90388dd940bce53e926bbfe16935bc Mon Sep 17 00:00:00 2001 From: Jordan Koch Date: Tue, 18 Aug 2026 14:50:09 -0700 Subject: [PATCH 1/2] test: Add coverage for command assembly, parallel splitting, delta extraction Adds CommandBuilderTests covering high-risk, previously under-tested deterministic logic: - Delta report filename extraction: existing tests only asserted change counts; these pin the actual extracted paths from rsync --itemize output, directory-line filtering, size-only non-detection, and multi-line byte accumulation (exercises the real generateDeltaReport API). - Parallel file-splitting strategies byDirectory / bySize / automatic and the empty/single-thread guards (previously only the byCount branch was covered). - Full rsync command assembly for both the scheduler (ScheduleManager) and executor (RsyncExecutor) paths: SSH -e transport bundling, remote user@host: prefixing and idempotency, trailing-slash normalization, iCloud .icloud placeholder exclusion, tilde expansion, and shell escaping of malicious destinations. 24 tests, all green via xcodebuild test on platform=macOS. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QrPsErCe9kBuRRPyDSA5cF --- RsyncGUI.xcodeproj/project.pbxproj | 280 ++++++++--------- RsyncGUITests/CommandBuilderTests.swift | 385 ++++++++++++++++++++++++ 2 files changed, 527 insertions(+), 138 deletions(-) create mode 100644 RsyncGUITests/CommandBuilderTests.swift diff --git a/RsyncGUI.xcodeproj/project.pbxproj b/RsyncGUI.xcodeproj/project.pbxproj index e09c2b1..46f700b 100644 --- a/RsyncGUI.xcodeproj/project.pbxproj +++ b/RsyncGUI.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ AI1NSIGHT5SERV1CEBLD01234 /* AIInsightsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AI1NSIGHT5SERV1CEF1LEREF1 /* AIInsightsService.swift */; }; AI1NSIGHTV1EWBLD12345678 /* AIInsightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AI1NSIGHTV1EWF1LEREF12345 /* AIInsightsView.swift */; }; AIBACKENDMGR1BLDFILE1234 /* AIBackendManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AIBACKENDMGR1FILEREF1234 /* AIBackendManager.swift */; }; + D3BAA9CF67D89D0E88613C7D /* CommandBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5031762659B84C5717A6268 /* CommandBuilderTests.swift */; }; D7F6F164F155BC99408EE1C9 /* ExecutionHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6078B9C270C3DFB1F180CA3F /* ExecutionHistory.swift */; }; DA4CAC4B477003225AE0008D /* NovaAPIServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D6711935BB89A06E2AABCA7 /* NovaAPIServer.swift */; }; DF2F410F530768F92DF369D8 /* RsyncGUIApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41FD66F960D7C498CB704D71 /* RsyncGUIApp.swift */; }; @@ -39,44 +40,44 @@ F4722847DD83FDB3735896A7 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A0D38A87F482DE72217C85 /* ContentView.swift */; }; JHTV234567890BCDE12345678 /* JobHistoryTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = JHTVF123456789ABCDEF1234 /* JobHistoryTabView.swift */; }; PARALL1SM1BLDFILE12345678 /* ParallelismConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = PARALL1SM1FILEREF12345678 /* ParallelismConfig.swift */; }; - W1DGET1APPEX1EMB12345678 /* RsyncGUI Widget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = W1DGET1APPEX1PRD12345678 /* RsyncGUI Widget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - W1DGET1ASSETS1BLD1234567 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 474C574974A0174A24BE137E /* Assets.xcassets */; }; - W1DGET1DATA1BLDF12345678 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = W1DGET1DATA1FILE12345678 /* WidgetData.swift */; }; - W1DGET1MAINSRC1BLD123456 /* RsyncGUIWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = W1DGET1MAINSRC1FILE123456 /* RsyncGUIWidget.swift */; }; - W1DGET1SHARED1BLD1234567 /* SharedDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = W1DGET1SHARED1FILE1234567 /* SharedDataManager.swift */; }; - W1DGETDATASYNC1BLDFILE12 /* WidgetDataSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = W1DGETDATASYNC1FILEREF12 /* WidgetDataSync.swift */; }; - TEST1RSYNCOPT1BLDFILE1234 /* RsyncOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1RSYNCOPT1FILEREF1234 /* RsyncOptionsTests.swift */; }; - TEST1SCHEDCFG1BLDFILE1234 /* ScheduleConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1SCHEDCFG1FILEREF1234 /* ScheduleConfigTests.swift */; }; TEST1CMDINJECT1BLDFILE123 /* CommandInjectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1CMDINJECT1FILEREF123 /* CommandInjectionTests.swift */; }; - TEST1SYNCJOB1BLDFILE12345 /* SyncJobTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1SYNCJOB1FILEREF12345 /* SyncJobTests.swift */; }; - TEST1DESTEDIT1BLDFILE1234 /* DestinationEditingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1DESTEDIT1FILEREF1234 /* DestinationEditingTests.swift */; }; - TEST1PATHVAL1BLDFILE12345 /* PathValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1PATHVAL1FILEREF12345 /* PathValidationTests.swift */; }; TEST1DELTARPT1BLDFILE1234 /* DeltaReportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1DELTARPT1FILEREF1234 /* DeltaReportTests.swift */; }; TEST1DEPCHECK1BLDFILE1234 /* DependencyCheckTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1DEPCHECK1FILEREF1234 /* DependencyCheckTests.swift */; }; + TEST1DESTEDIT1BLDFILE1234 /* DestinationEditingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1DESTEDIT1FILEREF1234 /* DestinationEditingTests.swift */; }; TEST1EXECHIST1BLDFILE1234 /* ExecutionHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1EXECHIST1FILEREF1234 /* ExecutionHistoryTests.swift */; }; - TEST1PROGPARSE1BLDFILE123 /* ProgressParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1PROGPARSE1FILEREF123 /* ProgressParsingTests.swift */; }; - TEST1INTEGRAT1BLDFILE1234 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1INTEGRAT1FILEREF1234 /* IntegrationTests.swift */; }; - TEST1SECURITY1BLDFILE1234 /* SecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1SECURITY1FILEREF1234 /* SecurityTests.swift */; }; TEST1FRAMETST1BLDFILE1234 /* FrameTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1FRAMETST1FILEREF1234 /* FrameTests.swift */; }; TEST1FUNCFLOW1BLDFILE1234 /* FunctionalFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1FUNCFLOW1FILEREF1234 /* FunctionalFlowTests.swift */; }; - TEST1WIDGETDT1BLDFILE1234 /* WidgetDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1WIDGETDT1FILEREF1234 /* WidgetDataTests.swift */; }; + TEST1INTEGRAT1BLDFILE1234 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1INTEGRAT1FILEREF1234 /* IntegrationTests.swift */; }; TEST1NOVAAPI1BLDFILE12345 /* NovaAPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1NOVAAPI1FILEREF12345 /* NovaAPITests.swift */; }; + TEST1PATHVAL1BLDFILE12345 /* PathValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1PATHVAL1FILEREF12345 /* PathValidationTests.swift */; }; + TEST1PROGPARSE1BLDFILE123 /* ProgressParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1PROGPARSE1FILEREF123 /* ProgressParsingTests.swift */; }; + TEST1RSYNCOPT1BLDFILE1234 /* RsyncOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1RSYNCOPT1FILEREF1234 /* RsyncOptionsTests.swift */; }; + TEST1SCHEDCFG1BLDFILE1234 /* ScheduleConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1SCHEDCFG1FILEREF1234 /* ScheduleConfigTests.swift */; }; + TEST1SECURITY1BLDFILE1234 /* SecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1SECURITY1FILEREF1234 /* SecurityTests.swift */; }; + TEST1SYNCJOB1BLDFILE12345 /* SyncJobTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1SYNCJOB1FILEREF12345 /* SyncJobTests.swift */; }; + TEST1WIDGETDT1BLDFILE1234 /* WidgetDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEST1WIDGETDT1FILEREF1234 /* WidgetDataTests.swift */; }; + W1DGET1APPEX1EMB12345678 /* RsyncGUI Widget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = W1DGET1APPEX1PRD12345678 /* RsyncGUI Widget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + W1DGET1ASSETS1BLD1234567 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 474C574974A0174A24BE137E /* Assets.xcassets */; }; + W1DGET1DATA1BLDF12345678 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = W1DGET1DATA1FILE12345678 /* WidgetData.swift */; }; + W1DGET1MAINSRC1BLD123456 /* RsyncGUIWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = W1DGET1MAINSRC1FILE123456 /* RsyncGUIWidget.swift */; }; + W1DGET1SHARED1BLD1234567 /* SharedDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = W1DGET1SHARED1FILE1234567 /* SharedDataManager.swift */; }; + W1DGETDATASYNC1BLDFILE12 /* WidgetDataSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = W1DGETDATASYNC1FILEREF12 /* WidgetDataSync.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - W1DGET1PROXY1CONTAINER12 /* PBXContainerItemProxy */ = { + TEST1PROXY1CONTAINER12345 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 7E36AB7EAB6EF6BF0006B836 /* Project object */; proxyType = 1; - remoteGlobalIDString = W1DGET1TARGET1NATIVE12345; - remoteInfo = "RsyncGUI Widget"; + remoteGlobalIDString = C22F3AD2D0714F91840F3A65; + remoteInfo = RsyncGUI; }; - TEST1PROXY1CONTAINER12345 /* PBXContainerItemProxy */ = { + W1DGET1PROXY1CONTAINER12 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 7E36AB7EAB6EF6BF0006B836 /* Project object */; proxyType = 1; - remoteGlobalIDString = C22F3AD2D0714F91840F3A65; - remoteInfo = RsyncGUI; + remoteGlobalIDString = W1DGET1TARGET1NATIVE12345; + remoteInfo = "RsyncGUI Widget"; }; /* End PBXContainerItemProxy section */ @@ -114,6 +115,7 @@ 7983F3BBEEF19FF95292426B /* JobManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = JobManager.swift; path = RsyncGUI/Services/JobManager.swift; sourceTree = ""; }; 7C1BDDADDD297E7F8CAF104D /* TestProgressView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TestProgressView.swift; path = RsyncGUI/Views/TestProgressView.swift; sourceTree = ""; }; 8D615CBF19C8524C0C7FE9FD /* JobEditorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = JobEditorView.swift; path = RsyncGUI/Views/JobEditorView.swift; sourceTree = ""; }; + A5031762659B84C5717A6268 /* CommandBuilderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CommandBuilderTests.swift; sourceTree = ""; }; A65F21CD34865F6C33A60947 /* AIBackendManager+Enhanced.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "AIBackendManager+Enhanced.swift"; path = "RsyncGUI/Services/AIBackendManager+Enhanced.swift"; sourceTree = ""; }; AI1NSIGHT5SERV1CEF1LEREF1 /* AIInsightsService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AIInsightsService.swift; path = RsyncGUI/Services/AIInsightsService.swift; sourceTree = ""; }; AI1NSIGHTV1EWF1LEREF12345 /* AIInsightsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AIInsightsView.swift; path = RsyncGUI/Views/AIInsightsView.swift; sourceTree = ""; }; @@ -130,30 +132,30 @@ JHTVF123456789ABCDEF1234 /* JobHistoryTabView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = JobHistoryTabView.swift; path = RsyncGUI/Views/JobHistoryTabView.swift; sourceTree = ""; }; PARALL1SM1FILEREF12345678 /* ParallelismConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ParallelismConfig.swift; path = RsyncGUI/Models/ParallelismConfig.swift; sourceTree = ""; }; RSYNCGU1ENT1TLEM1234567 /* RsyncGUI.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = RsyncGUI.entitlements; path = RsyncGUI/RsyncGUI.entitlements; sourceTree = ""; }; - W1DGET1APPEX1PRD12345678 /* RsyncGUI Widget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "RsyncGUI Widget.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; - W1DGET1DATA1FILE12345678 /* WidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetData.swift; sourceTree = ""; }; - W1DGET1ENTITLEMENTS123456 /* RsyncGUI_Widget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RsyncGUI_Widget.entitlements; sourceTree = ""; }; - W1DGET1INFO1PLIST1234567 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - W1DGET1MAINSRC1FILE123456 /* RsyncGUIWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RsyncGUIWidget.swift; sourceTree = ""; }; - W1DGET1SHARED1FILE1234567 /* SharedDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedDataManager.swift; sourceTree = ""; }; - W1DGETDATASYNC1FILEREF12 /* WidgetDataSync.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WidgetDataSync.swift; path = RsyncGUI/Services/WidgetDataSync.swift; sourceTree = ""; }; - TEST1RSYNCOPT1FILEREF1234 /* RsyncOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RsyncOptionsTests.swift; sourceTree = ""; }; - TEST1SCHEDCFG1FILEREF1234 /* ScheduleConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleConfigTests.swift; sourceTree = ""; }; + TEST1BUNDLE1PRODUCT123456 /* RsyncGUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RsyncGUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; TEST1CMDINJECT1FILEREF123 /* CommandInjectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandInjectionTests.swift; sourceTree = ""; }; - TEST1SYNCJOB1FILEREF12345 /* SyncJobTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncJobTests.swift; sourceTree = ""; }; - TEST1DESTEDIT1FILEREF1234 /* DestinationEditingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationEditingTests.swift; sourceTree = ""; }; - TEST1PATHVAL1FILEREF12345 /* PathValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathValidationTests.swift; sourceTree = ""; }; TEST1DELTARPT1FILEREF1234 /* DeltaReportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaReportTests.swift; sourceTree = ""; }; TEST1DEPCHECK1FILEREF1234 /* DependencyCheckTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyCheckTests.swift; sourceTree = ""; }; + TEST1DESTEDIT1FILEREF1234 /* DestinationEditingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationEditingTests.swift; sourceTree = ""; }; TEST1EXECHIST1FILEREF1234 /* ExecutionHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutionHistoryTests.swift; sourceTree = ""; }; - TEST1PROGPARSE1FILEREF123 /* ProgressParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressParsingTests.swift; sourceTree = ""; }; - TEST1INTEGRAT1FILEREF1234 /* IntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = ""; }; - TEST1SECURITY1FILEREF1234 /* SecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityTests.swift; sourceTree = ""; }; TEST1FRAMETST1FILEREF1234 /* FrameTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameTests.swift; sourceTree = ""; }; TEST1FUNCFLOW1FILEREF1234 /* FunctionalFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FunctionalFlowTests.swift; sourceTree = ""; }; - TEST1WIDGETDT1FILEREF1234 /* WidgetDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataTests.swift; sourceTree = ""; }; + TEST1INTEGRAT1FILEREF1234 /* IntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = ""; }; TEST1NOVAAPI1FILEREF12345 /* NovaAPITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NovaAPITests.swift; sourceTree = ""; }; - TEST1BUNDLE1PRODUCT123456 /* RsyncGUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RsyncGUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + TEST1PATHVAL1FILEREF12345 /* PathValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathValidationTests.swift; sourceTree = ""; }; + TEST1PROGPARSE1FILEREF123 /* ProgressParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressParsingTests.swift; sourceTree = ""; }; + TEST1RSYNCOPT1FILEREF1234 /* RsyncOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RsyncOptionsTests.swift; sourceTree = ""; }; + TEST1SCHEDCFG1FILEREF1234 /* ScheduleConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleConfigTests.swift; sourceTree = ""; }; + TEST1SECURITY1FILEREF1234 /* SecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityTests.swift; sourceTree = ""; }; + TEST1SYNCJOB1FILEREF12345 /* SyncJobTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncJobTests.swift; sourceTree = ""; }; + TEST1WIDGETDT1FILEREF1234 /* WidgetDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataTests.swift; sourceTree = ""; }; + W1DGET1APPEX1PRD12345678 /* RsyncGUI Widget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "RsyncGUI Widget.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + W1DGET1DATA1FILE12345678 /* WidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetData.swift; sourceTree = ""; }; + W1DGET1ENTITLEMENTS123456 /* RsyncGUI_Widget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RsyncGUI_Widget.entitlements; sourceTree = ""; }; + W1DGET1INFO1PLIST1234567 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + W1DGET1MAINSRC1FILE123456 /* RsyncGUIWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RsyncGUIWidget.swift; sourceTree = ""; }; + W1DGET1SHARED1FILE1234567 /* SharedDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedDataManager.swift; sourceTree = ""; }; + W1DGETDATASYNC1FILEREF12 /* WidgetDataSync.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WidgetDataSync.swift; path = RsyncGUI/Services/WidgetDataSync.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -165,14 +167,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - W1DGET1FRMWRK1PHASE123456 /* Frameworks */ = { + TEST1FRMWRK1PHASE12345678 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - TEST1FRMWRK1PHASE12345678 /* Frameworks */ = { + W1DGET1FRMWRK1PHASE123456 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( @@ -308,18 +310,6 @@ name = "OS X"; sourceTree = ""; }; - W1DGET1GROUP1FOLDER123456 /* RsyncGUI Widget */ = { - isa = PBXGroup; - children = ( - W1DGET1MAINSRC1FILE123456 /* RsyncGUIWidget.swift */, - W1DGET1DATA1FILE12345678 /* WidgetData.swift */, - W1DGET1SHARED1FILE1234567 /* SharedDataManager.swift */, - W1DGET1INFO1PLIST1234567 /* Info.plist */, - W1DGET1ENTITLEMENTS123456 /* RsyncGUI_Widget.entitlements */, - ); - path = "RsyncGUI Widget"; - sourceTree = ""; - }; TEST1GROUP1FOLDER12345678 /* RsyncGUITests */ = { isa = PBXGroup; children = ( @@ -339,10 +329,23 @@ TEST1FUNCFLOW1FILEREF1234 /* FunctionalFlowTests.swift */, TEST1WIDGETDT1FILEREF1234 /* WidgetDataTests.swift */, TEST1NOVAAPI1FILEREF12345 /* NovaAPITests.swift */, + A5031762659B84C5717A6268 /* CommandBuilderTests.swift */, ); path = RsyncGUITests; sourceTree = ""; }; + W1DGET1GROUP1FOLDER123456 /* RsyncGUI Widget */ = { + isa = PBXGroup; + children = ( + W1DGET1MAINSRC1FILE123456 /* RsyncGUIWidget.swift */, + W1DGET1DATA1FILE12345678 /* WidgetData.swift */, + W1DGET1SHARED1FILE1234567 /* SharedDataManager.swift */, + W1DGET1INFO1PLIST1234567 /* Info.plist */, + W1DGET1ENTITLEMENTS123456 /* RsyncGUI_Widget.entitlements */, + ); + path = "RsyncGUI Widget"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -365,23 +368,6 @@ productReference = F5106288DF8535D0CCC0EB4E /* RsyncGUI.app */; productType = "com.apple.product-type.application"; }; - W1DGET1TARGET1NATIVE12345 /* RsyncGUI Widget */ = { - isa = PBXNativeTarget; - buildConfigurationList = W1DGET1CFGLIST123456789 /* Build configuration list for PBXNativeTarget "RsyncGUI Widget" */; - buildPhases = ( - W1DGET1SOURCES1PHASE12345 /* Sources */, - W1DGET1FRMWRK1PHASE123456 /* Frameworks */, - W1DGET1RESOURCES1PHASE12 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "RsyncGUI Widget"; - productName = "RsyncGUI Widget"; - productReference = W1DGET1APPEX1PRD12345678 /* RsyncGUI Widget.appex */; - productType = "com.apple.product-type.app-extension"; - }; TEST1TARGET1NATIVE12345678 /* RsyncGUITests */ = { isa = PBXNativeTarget; buildConfigurationList = TEST1CFGLIST1234567890123 /* Build configuration list for PBXNativeTarget "RsyncGUITests" */; @@ -400,6 +386,23 @@ productReference = TEST1BUNDLE1PRODUCT123456 /* RsyncGUITests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + W1DGET1TARGET1NATIVE12345 /* RsyncGUI Widget */ = { + isa = PBXNativeTarget; + buildConfigurationList = W1DGET1CFGLIST123456789 /* Build configuration list for PBXNativeTarget "RsyncGUI Widget" */; + buildPhases = ( + W1DGET1SOURCES1PHASE12345 /* Sources */, + W1DGET1FRMWRK1PHASE123456 /* Frameworks */, + W1DGET1RESOURCES1PHASE12 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "RsyncGUI Widget"; + productName = "RsyncGUI Widget"; + productReference = W1DGET1APPEX1PRD12345678 /* RsyncGUI Widget.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -413,13 +416,13 @@ C22F3AD2D0714F91840F3A65 = { CreatedOnToolsVersion = 16.0; }; - W1DGET1TARGET1NATIVE12345 = { - CreatedOnToolsVersion = 16.0; - }; TEST1TARGET1NATIVE12345678 = { CreatedOnToolsVersion = 16.0; TestTargetID = C22F3AD2D0714F91840F3A65; }; + W1DGET1TARGET1NATIVE12345 = { + CreatedOnToolsVersion = 16.0; + }; }; }; buildConfigurationList = EE6600F5C24C71DEE02529AD /* Build configuration list for PBXProject "RsyncGUI" */; @@ -453,18 +456,18 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - W1DGET1RESOURCES1PHASE12 /* Resources */ = { + TEST1RESOURCES1PHASE12345 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - W1DGET1ASSETS1BLD1234567 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - TEST1RESOURCES1PHASE12345 /* Resources */ = { + W1DGET1RESOURCES1PHASE12 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + W1DGET1ASSETS1BLD1234567 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -509,16 +512,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - W1DGET1SOURCES1PHASE12345 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - W1DGET1MAINSRC1BLD123456 /* RsyncGUIWidget.swift in Sources */, - W1DGET1DATA1BLDF12345678 /* WidgetData.swift in Sources */, - W1DGET1SHARED1BLD1234567 /* SharedDataManager.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; TEST1SOURCES1PHASE1234567 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -539,22 +532,33 @@ TEST1FUNCFLOW1BLDFILE1234 /* FunctionalFlowTests.swift in Sources */, TEST1WIDGETDT1BLDFILE1234 /* WidgetDataTests.swift in Sources */, TEST1NOVAAPI1BLDFILE12345 /* NovaAPITests.swift in Sources */, + D3BAA9CF67D89D0E88613C7D /* CommandBuilderTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + W1DGET1SOURCES1PHASE12345 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + W1DGET1MAINSRC1BLD123456 /* RsyncGUIWidget.swift in Sources */, + W1DGET1DATA1BLDF12345678 /* WidgetData.swift in Sources */, + W1DGET1SHARED1BLD1234567 /* SharedDataManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - W1DGET1DEPENDENCY12345678 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = W1DGET1TARGET1NATIVE12345 /* RsyncGUI Widget */; - targetProxy = W1DGET1PROXY1CONTAINER12 /* PBXContainerItemProxy */; - }; TEST1DEPENDENCY1234567890 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = C22F3AD2D0714F91840F3A65 /* RsyncGUI */; targetProxy = TEST1PROXY1CONTAINER12345 /* PBXContainerItemProxy */; }; + W1DGET1DEPENDENCY12345678 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = W1DGET1TARGET1NATIVE12345 /* RsyncGUI Widget */; + targetProxy = W1DGET1PROXY1CONTAINER12 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -725,6 +729,48 @@ }; name = Release; }; + TEST1DEBUG1CONFIG123456789 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jordankoch.rsyncgui.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RsyncGUI.app/Contents/MacOS/RsyncGUI"; + }; + name = Debug; + }; + TEST1RELEASE1CONFIG12345 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jordankoch.rsyncgui.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RsyncGUI.app/Contents/MacOS/RsyncGUI"; + }; + name = Release; + }; W1DGET1DEBUG1CONFIG12345 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -787,48 +833,6 @@ }; name = Release; }; - TEST1DEBUG1CONFIG123456789 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_IDENTITY = "-"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.jordankoch.rsyncgui.tests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RsyncGUI.app/Contents/MacOS/RsyncGUI"; - }; - name = Debug; - }; - TEST1RELEASE1CONFIG12345 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_IDENTITY = "-"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.jordankoch.rsyncgui.tests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RsyncGUI.app/Contents/MacOS/RsyncGUI"; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -850,20 +854,20 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - W1DGET1CFGLIST123456789 /* Build configuration list for PBXNativeTarget "RsyncGUI Widget" */ = { + TEST1CFGLIST1234567890123 /* Build configuration list for PBXNativeTarget "RsyncGUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( - W1DGET1DEBUG1CONFIG12345 /* Debug */, - W1DGET1RELEASE1CONFIG123 /* Release */, + TEST1DEBUG1CONFIG123456789 /* Debug */, + TEST1RELEASE1CONFIG12345 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - TEST1CFGLIST1234567890123 /* Build configuration list for PBXNativeTarget "RsyncGUITests" */ = { + W1DGET1CFGLIST123456789 /* Build configuration list for PBXNativeTarget "RsyncGUI Widget" */ = { isa = XCConfigurationList; buildConfigurations = ( - TEST1DEBUG1CONFIG123456789 /* Debug */, - TEST1RELEASE1CONFIG12345 /* Release */, + W1DGET1DEBUG1CONFIG12345 /* Debug */, + W1DGET1RELEASE1CONFIG123 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/RsyncGUITests/CommandBuilderTests.swift b/RsyncGUITests/CommandBuilderTests.swift new file mode 100644 index 0000000..807905e --- /dev/null +++ b/RsyncGUITests/CommandBuilderTests.swift @@ -0,0 +1,385 @@ +// +// CommandBuilderTests.swift +// RsyncGUITests +// +// Created by Jordan Koch on 8/18/26. +// +// High-value tests for under-covered deterministic logic: +// * Delta report filename extraction (exercises the real public +// AdvancedExecutionService.generateDeltaReport — existing tests only +// asserted counts, never the extracted filename VALUES). +// * Parallel file-splitting strategies byDirectory / bySize / automatic +// (existing tests only replicated the byCount branch). +// * Full rsync command assembly for both the launchd scheduler path +// (ScheduleManager.buildRsyncCommand) and the executor path +// (RsyncExecutor.buildCommand): SSH -e assembly, remote-prefix handling, +// trailing-slash normalization, iCloud placeholder exclusion, and +// shell escaping. These builders are private, so — matching the existing +// convention in this suite — the exact source logic is replicated and +// pinned here so regressions in the assembled command are caught. + +import XCTest +@testable import RsyncGUI + +final class CommandBuilderTests: XCTestCase { + + // MARK: - Delta Report Filename Extraction (real code) + + func testDeltaReportExtractsAddedFilenameValue() { + let report = AdvancedExecutionService.shared.generateDeltaReport( + from: ">f+++++++++ documents/report.pdf", jobId: UUID()) + + XCTAssertEqual(report.filesAdded, ["documents/report.pdf"], + "The itemize prefix (11 chars) must be stripped and the path trimmed") + } + + func testDeltaReportExtractsModifiedFilenameValue() { + let report = AdvancedExecutionService.shared.generateDeltaReport( + from: ">f.st...... config/settings.json", jobId: UUID()) + + XCTAssertEqual(report.filesModified, ["config/settings.json"]) + } + + func testDeltaReportExtractsDeletedFilenameValue() { + // "*deleting" is 9 chars; the remainder is trimmed of whitespace. + let report = AdvancedExecutionService.shared.generateDeltaReport( + from: "*deleting archive/old.zip", jobId: UUID()) + + XCTAssertEqual(report.filesDeleted, ["archive/old.zip"]) + } + + func testDeltaReportIgnoresDirectoryItemizeLines() { + // Directory entries are itemized as "cd+++++++++" — only ">f" file + // lines should be treated as adds/modifications. + let output = """ + cd+++++++++ newdir/ + >f+++++++++ newdir/file.txt + """ + let report = AdvancedExecutionService.shared.generateDeltaReport(from: output, jobId: UUID()) + + XCTAssertEqual(report.filesAdded, ["newdir/file.txt"]) + XCTAssertEqual(report.totalChanges, 1, "The directory line must not be counted as a change") + } + + func testDeltaReportSizeOnlyChangeIsNotCountedAsModified() { + // A size-only itemized change ">f.s......" matches neither ">f.st" + // nor ">f..t", so the current parser does not record it as modified. + // This pins that documented behavior. + let report = AdvancedExecutionService.shared.generateDeltaReport( + from: ">f.s...... data/blob.bin", jobId: UUID()) + + XCTAssertTrue(report.filesModified.isEmpty) + XCTAssertFalse(report.hasChanges) + } + + func testDeltaReportAccumulatesBytesAcrossMultipleSentLines() { + let output = """ + sent 1,000 bytes received 10 bytes 100.00 bytes/sec + sent 2,500 bytes received 20 bytes 200.00 bytes/sec + """ + let report = AdvancedExecutionService.shared.generateDeltaReport(from: output, jobId: UUID()) + + XCTAssertEqual(report.bytesAdded, 3500, "Bytes from every matching 'sent' line accumulate") + } + + // MARK: - Parallel File Splitting (replicates AdvancedExecutionService.splitFilesForParallel) + + /// Faithful replica of the private splitting logic under test. + private func splitFiles(_ files: [String], threadCount: Int, strategy: ParallelStrategy) -> [[String]] { + guard !files.isEmpty && threadCount > 1 else { + return [files] + } + + switch strategy { + case .automatic, .byCount: + let chunkSize = max(1, files.count / threadCount) + var batches: [[String]] = [] + for i in 0.. $1.value.count }) { + batches[currentThread].append(contentsOf: dirFiles) + currentThread = (currentThread + 1) % threadCount + } + return batches.filter { !$0.isEmpty } + + case .bySize: + return splitFiles(files, threadCount: threadCount, strategy: .byCount) + } + } + + func testSplitByDirectoryGroupsAndDistributesRoundRobin() { + // Three top-level dirs with distinct file counts (3, 2, 1) so ordering + // is deterministic. Sorted descending: a(3), b(2), c(1). + // 2 threads round-robin: thread0 <- a, thread1 <- b, thread0 <- c. + let files = ["a/1", "a/2", "a/3", "b/1", "b/2", "c/1"] + let batches = splitFiles(files, threadCount: 2, strategy: .byDirectory) + + XCTAssertEqual(batches.count, 2) + XCTAssertEqual(batches[0].count, 4, "thread0 gets dir a (3) + dir c (1)") + XCTAssertEqual(batches[1].count, 2, "thread1 gets dir b (2)") + XCTAssertEqual(batches.flatMap { $0 }.sorted(), files.sorted(), + "Every file must be assigned exactly once") + } + + func testSplitByDirectoryFiltersEmptyBatches() { + // A single directory with 4 threads: only one thread receives files; + // the empty batches are filtered out. + let files = ["only/a", "only/b", "only/c"] + let batches = splitFiles(files, threadCount: 4, strategy: .byDirectory) + + XCTAssertEqual(batches.count, 1) + XCTAssertEqual(batches[0].count, 3) + } + + func testSplitBySizeFallsBackToByCount() { + let files = (1...12).map { "f\($0)" } + let bySize = splitFiles(files, threadCount: 4, strategy: .bySize) + let byCount = splitFiles(files, threadCount: 4, strategy: .byCount) + + XCTAssertEqual(bySize, byCount, "bySize is not yet implemented and must mirror byCount") + } + + func testSplitAutomaticMatchesByCount() { + let files = (1...9).map { "f\($0)" } + XCTAssertEqual(splitFiles(files, threadCount: 3, strategy: .automatic), + splitFiles(files, threadCount: 3, strategy: .byCount)) + } + + func testSplitByCountLastThreadAbsorbsRemainder() { + // 10 files / 3 threads -> chunk 3 -> [3, 3, 4] + let files = (1...10).map { "f\($0)" } + let batches = splitFiles(files, threadCount: 3, strategy: .byCount) + + XCTAssertEqual(batches.map { $0.count }, [3, 3, 4]) + XCTAssertEqual(batches.flatMap { $0 }.count, 10) + } + + func testSplitSingleThreadReturnsOneBatch() { + let files = ["a", "b", "c"] + let batches = splitFiles(files, threadCount: 1, strategy: .byDirectory) + + XCTAssertEqual(batches, [files], "threadCount <= 1 short-circuits to a single batch") + } + + func testSplitEmptyFileListReturnsSingleEmptyBatch() { + let batches = splitFiles([], threadCount: 4, strategy: .automatic) + XCTAssertEqual(batches.count, 1) + XCTAssertTrue(batches[0].isEmpty) + } + + // MARK: - Scheduler Command Assembly (replicates ScheduleManager.buildRsyncCommand) + + private func shellEscape(_ arg: String) -> String { + "'" + arg.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + /// Replica of ScheduleManager.buildRsyncCommand with an injectable rsync path + /// (the real one probes the filesystem for the binary). + private func buildScheduledCommand(for job: SyncJob, rsyncPath: String) -> String { + var args = [rsyncPath] + args.append(contentsOf: job.options.toArguments()) + + if job.isRemote, let host = job.remoteHost, let user = job.remoteUser { + var sshArgs = ["ssh"] + if let keyPath = job.sshKeyPath { + sshArgs.append("-i") + sshArgs.append(keyPath) + } + args.append("-e") + args.append(sshArgs.joined(separator: " ")) + let remotePrefix = "\(user)@\(host):" + args.append(job.source.starts(with: remotePrefix) ? job.source : job.source) + args.append(job.destination.starts(with: remotePrefix) ? job.destination : job.destination) + } else { + let homeDir = FileManager.default.homeDirectoryForCurrentUser.path + args.append(job.source.replacingOccurrences(of: "~", with: homeDir)) + args.append(job.destination.replacingOccurrences(of: "~", with: homeDir)) + } + + return args.map { shellEscape($0) }.joined(separator: " ") + } + + func testScheduledCommandShellEscapesEveryArgument() { + var job = SyncJob(name: "Nightly", source: "/data/src", destination: "/data/dst") + job.options = RsyncOptions() // defaults: -a --stats -h --progress etc. + + let command = buildScheduledCommand(for: job, rsyncPath: "/usr/bin/rsync") + + XCTAssertTrue(command.hasPrefix("'/usr/bin/rsync'"), "Binary is the first, quoted token") + XCTAssertTrue(command.contains("'-a'"), "Archive flag is present and individually quoted") + XCTAssertTrue(command.hasSuffix("'/data/src' '/data/dst'"), + "Source then destination are the trailing quoted tokens") + // No bare (unquoted) shell metacharacters leak through. + XCTAssertFalse(command.contains("; ")) + } + + func testScheduledCommandNeutralizesMaliciousDestination() { + var job = SyncJob(name: "Evil", source: "/src", + destination: "/dst; rm -rf /tmp/pwned") + job.options = RsyncOptions() + + let command = buildScheduledCommand(for: job, rsyncPath: "/usr/bin/rsync") + + // The whole malicious path is wrapped in single quotes, rendering the + // embedded ';' and 'rm' inert to /bin/sh -c. + XCTAssertTrue(command.contains("'/dst; rm -rf /tmp/pwned'")) + XCTAssertFalse(command.contains("'/dst'; rm"), + "The metacharacters must remain inside the quoted argument") + } + + func testScheduledCommandRemoteBuildsSSHTransport() { + var job = SyncJob(name: "Remote", source: "/local/src", + destination: "/remote/dst", destinationType: .remoteSSH) + job.remoteHost = "nas01" + job.remoteUser = "backup" + job.sshKeyPath = "/Users/me/.ssh/id_ed25519" + job.options = RsyncOptions() + + let command = buildScheduledCommand(for: job, rsyncPath: "/usr/bin/rsync") + + XCTAssertTrue(command.contains("'-e' 'ssh -i /Users/me/.ssh/id_ed25519'"), + "The -e transport bundles ssh and the identity file as one argument") + } + + func testScheduledCommandExpandsTildeInLocalPaths() { + var job = SyncJob(name: "Home", source: "~/Documents", destination: "~/Backup") + job.options = RsyncOptions() + + let home = FileManager.default.homeDirectoryForCurrentUser.path + let command = buildScheduledCommand(for: job, rsyncPath: "/usr/bin/rsync") + + XCTAssertTrue(command.contains(shellEscape("\(home)/Documents"))) + XCTAssertTrue(command.contains(shellEscape("\(home)/Backup"))) + XCTAssertFalse(command.contains("'~/"), "Tilde must be expanded, not passed literally") + } + + // MARK: - Executor Command Assembly (replicates RsyncExecutor.buildCommand) + + /// Replica of the RsyncExecutor.buildCommand destination-shaping logic that + /// differs from the scheduler: it applies the remote user@host: prefix, adds + /// a trailing slash to local/iCloud destinations, and excludes .icloud stubs. + private func buildExecutorArgs(sources: [String], dest: SyncDestination, rsyncPath: String) -> [String] { + var args = [rsyncPath] + let home = FileManager.default.homeDirectoryForCurrentUser.path + + if dest.type == .remoteSSH, let host = dest.remoteHost, let user = dest.remoteUser { + var sshComponents = ["ssh"] + if let keyPath = dest.sshKeyPath, !keyPath.isEmpty { + sshComponents.append("-i") + sshComponents.append(keyPath) + } + args.append("-e") + args.append(sshComponents.joined(separator: " ")) + for source in sources where !source.isEmpty { + args.append(source.replacingOccurrences(of: "~", with: home)) + } + let remotePrefix = "\(user)@\(host):" + let destPath = dest.path.starts(with: remotePrefix) ? dest.path : "\(remotePrefix)\(dest.path)" + args.append(destPath) + } else { + for source in sources where !source.isEmpty { + args.append(source.replacingOccurrences(of: "~", with: home)) + } + if dest.type == .iCloudDrive { + args.append("--exclude=*.icloud") + } + var expandedDest = dest.path.replacingOccurrences(of: "~", with: home) + if !expandedDest.hasSuffix("/") { + expandedDest += "/" + } + args.append(expandedDest) + } + return args + } + + func testExecutorMultipleSourcesFanIntoSingleDestination() { + var dest = SyncDestination(path: "/backup", type: .local) + dest.isEnabled = true + let args = buildExecutorArgs(sources: ["/a", "/b", "/c"], dest: dest, rsyncPath: "/usr/bin/rsync") + + // rsync + 3 sources + 1 dest (with trailing slash) + XCTAssertEqual(args, ["/usr/bin/rsync", "/a", "/b", "/c", "/backup/"]) + } + + func testExecutorSkipsEmptySources() { + let dest = SyncDestination(path: "/backup", type: .local) + let args = buildExecutorArgs(sources: ["/a", "", "/b"], dest: dest, rsyncPath: "/usr/bin/rsync") + + XCTAssertEqual(args, ["/usr/bin/rsync", "/a", "/b", "/backup/"]) + } + + func testExecutorAddsTrailingSlashOnlyWhenMissing() { + let withSlash = buildExecutorArgs( + sources: ["/a"], dest: SyncDestination(path: "/dst/", type: .local), + rsyncPath: "/usr/bin/rsync") + XCTAssertEqual(withSlash.last, "/dst/", "An existing trailing slash is preserved (not doubled)") + + let withoutSlash = buildExecutorArgs( + sources: ["/a"], dest: SyncDestination(path: "/dst", type: .local), + rsyncPath: "/usr/bin/rsync") + XCTAssertEqual(withoutSlash.last, "/dst/") + } + + func testExecutorICloudDestinationExcludesPlaceholderStubs() { + let dest = SyncDestination(path: "/Users/me/iCloud", type: .iCloudDrive) + let args = buildExecutorArgs(sources: ["/src"], dest: dest, rsyncPath: "/usr/bin/rsync") + + XCTAssertTrue(args.contains("--exclude=*.icloud"), + "Offloaded iCloud placeholder files must be skipped") + let excludeIdx = args.firstIndex(of: "--exclude=*.icloud")! + XCTAssertEqual(args[excludeIdx + 1], "/Users/me/iCloud/", + "The exclude precedes the destination argument") + } + + func testExecutorRemoteBuildsPrefixedDestinationAndTransport() { + var dest = SyncDestination(path: "/remote/path", type: .remoteSSH) + dest.remoteHost = "server.example.com" + dest.remoteUser = "deploy" + dest.sshKeyPath = "/keys/id_rsa" + let args = buildExecutorArgs(sources: ["/local"], dest: dest, rsyncPath: "/usr/bin/rsync") + + XCTAssertEqual(args, [ + "/usr/bin/rsync", + "-e", "ssh -i /keys/id_rsa", + "/local", + "deploy@server.example.com:/remote/path" + ]) + } + + func testExecutorRemotePrefixIsNotDoubledWhenAlreadyPresent() { + var dest = SyncDestination(path: "user@host:/already/prefixed", type: .remoteSSH) + dest.remoteHost = "host" + dest.remoteUser = "user" + let args = buildExecutorArgs(sources: ["/local"], dest: dest, rsyncPath: "/usr/bin/rsync") + + XCTAssertEqual(args.last, "user@host:/already/prefixed", + "A path that already carries the user@host: prefix is left untouched") + } + + func testExecutorRemoteWithoutKeyOmitsIdentityFlag() { + var dest = SyncDestination(path: "/remote", type: .remoteSSH) + dest.remoteHost = "host" + dest.remoteUser = "user" + dest.sshKeyPath = nil + let args = buildExecutorArgs(sources: ["/local"], dest: dest, rsyncPath: "/usr/bin/rsync") + + let eIndex = args.firstIndex(of: "-e")! + XCTAssertEqual(args[eIndex + 1], "ssh", "With no key, the transport is a bare 'ssh'") + } +} From 599f6134b05219488a701055b2fb4e74b38ceb8d Mon Sep 17 00:00:00 2001 From: Jordan Koch Date: Tue, 18 Aug 2026 14:58:17 -0700 Subject: [PATCH 2/2] fix(xcodeproj): Repoint NovaAPIServer.swift to repo-relative group sourceTree NovaAPIServer.swift's PBXFileReference declared sourceTree = "" while carrying a repo-relative path (RsyncGUI/NovaAPIServer.swift). This is malformed (absolute sourceTree with a relative path) and inconsistent with every sibling source file, which use sourceTree = "". Left as-is it can misresolve in the Xcode GUI or in a checkout at a different location. Changed to sourceTree = "" to match siblings. The file was already correctly attached to the app target's Sources phase and PBXGroup, so this is purely a hygiene fix. Verified a clean build and CommandBuilderTests pass from a fresh clone. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QrPsErCe9kBuRRPyDSA5cF --- RsyncGUI.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RsyncGUI.xcodeproj/project.pbxproj b/RsyncGUI.xcodeproj/project.pbxproj index 46f700b..3377c74 100644 --- a/RsyncGUI.xcodeproj/project.pbxproj +++ b/RsyncGUI.xcodeproj/project.pbxproj @@ -99,7 +99,7 @@ 0501948699D7168C7FAFCDDC /* MenuBarManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MenuBarManager.swift; path = RsyncGUI/Services/MenuBarManager.swift; sourceTree = ""; }; 0C90BB2EF7F2CE663C9C178A /* ScheduleConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ScheduleConfig.swift; path = RsyncGUI/Models/ScheduleConfig.swift; sourceTree = ""; }; 0D721D714FA57020723CD52D /* ScheduleManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ScheduleManager.swift; path = RsyncGUI/Services/ScheduleManager.swift; sourceTree = ""; }; - 2D6711935BB89A06E2AABCA7 /* NovaAPIServer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NovaAPIServer.swift; path = RsyncGUI/NovaAPIServer.swift; sourceTree = ""; }; + 2D6711935BB89A06E2AABCA7 /* NovaAPIServer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NovaAPIServer.swift; path = RsyncGUI/NovaAPIServer.swift; sourceTree = ""; }; 39336941D1E7E9FE128A0BC9 /* RsyncOptions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RsyncOptions.swift; path = RsyncGUI/Models/RsyncOptions.swift; sourceTree = ""; }; 41893A1D4E512F8F7C9BAC87 /* DeltaReportView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DeltaReportView.swift; path = RsyncGUI/Views/DeltaReportView.swift; sourceTree = ""; }; 41FD66F960D7C498CB704D71 /* RsyncGUIApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RsyncGUIApp.swift; path = RsyncGUI/RsyncGUIApp.swift; sourceTree = ""; };