From b2f3c5aef6f24979c6f7db3a6c4dc0f872da21e9 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 8 Sep 2026 15:22:19 +0100 Subject: [PATCH] Major update for stability --- .DS_Store | Bin 10244 -> 10244 bytes Docs/AUDIT.md | 36 ++ Docs/RELEASE.md | 87 ++++ README.md | 6 + RsyncManager.xcodeproj/project.pbxproj | 446 +++++++++++++----- .../UserInterfaceState.xcuserstate | Bin 15521 -> 16369 bytes RsyncManager/ContentView.swift | 19 + RsyncManager/Models/SyncJob.swift | 2 + RsyncManager/Models/SyncRun.swift | 21 + RsyncManager/Resources/PrivacyInfo.xcprivacy | 23 + RsyncManager/Resources/RsyncManagerHelp.md | 38 ++ RsyncManager/RsyncManagerApp.swift | 18 +- .../Services/BackgroundSyncManager.swift | 49 +- .../Services/RsyncCommandBuilder.swift | 210 ++++++++- RsyncManager/Services/RsyncLocator.swift | 29 +- RsyncManager/Services/RsyncRunner.swift | 28 +- RsyncManager/SettingsView.swift | 40 +- RsyncManager/Utilities/KeychainStore.swift | 38 +- RsyncManager/Views/Jobs/JobDetailView.swift | 37 +- RsyncManager/Views/Jobs/JobEditorView.swift | 41 +- RsyncManager/Views/Jobs/JobListView.swift | 18 +- RsyncManager/Views/Runs/RunHistoryView.swift | 18 +- Scripts/release-preflight.sh | 36 ++ Scripts/security-check.sh | 31 ++ 24 files changed, 1059 insertions(+), 212 deletions(-) create mode 100644 Docs/AUDIT.md create mode 100644 Docs/RELEASE.md create mode 100644 RsyncManager/Resources/PrivacyInfo.xcprivacy create mode 100755 Scripts/release-preflight.sh create mode 100755 Scripts/security-check.sh diff --git a/.DS_Store b/.DS_Store index 21b81042cc4300c91adee64f6d03b03e6ec54d9d..441be9383f1e8703fcd4518220cefaed08dca165 100644 GIT binary patch delta 48 ycmZn(XbISmC(JaxWpaVAJyV;) Archive**, then distribute using **Developer ID**. + +Alternatively: + +```bash +xcodebuild -project RsyncManager.xcodeproj -scheme RsyncManager -configuration Release -archivePath build/RsyncManager.xcarchive archive +``` + +Choose your Developer ID Application certificate/team in Xcode signing settings before archiving. + +## Verify signature + +```bash +codesign --verify --deep --strict --verbose=2 /path/to/RsyncManager.app +codesign -d --entitlements :- /path/to/RsyncManager.app +``` + +The Release build should show Hardened Runtime and should not contain `com.apple.security.get-task-allow`. + +## Notarise + +Store notarisation credentials in a Keychain profile once: + +```bash +xcrun notarytool store-credentials "RsyncManagerNotary" +``` + +Submit a signed ZIP/DMG/PKG: + +```bash +ditto -c -k --keepParent /path/to/RsyncManager.app RsyncManager.zip +xcrun notarytool submit RsyncManager.zip --keychain-profile "RsyncManagerNotary" --wait +``` + +Staple and validate: + +```bash +xcrun stapler staple /path/to/RsyncManager.app +xcrun stapler validate /path/to/RsyncManager.app +spctl --assess --type execute --verbose=4 /path/to/RsyncManager.app +``` + +## Pre-release functional security tests + +1. Local source nested inside destination is rejected. +2. Local destination nested inside source is rejected. +3. SSH host beginning with `-` is rejected. +4. SSH username containing spaces/control characters is rejected. +5. A `.pub` key cannot be selected as the effective private key. +6. Advanced arguments `-e`, `--rsh`, `--rsync-path`, `--password-file`, `--files-from`, `--log-file`, `--write-batch`, `--remove-source-files`, `--delete*` and `-M` are rejected, and malformed quoting is rejected. +7. Remote destination shell metacharacters are rejected before rsync is launched. +8. Scheduled/watch job using `--delete` cannot run until **Allow unattended delete** is explicitly enabled. +9. SSH to a previously unknown host does not silently bypass host-key checking. +10. Scheduled SSH job with unavailable authentication fails rather than prompting indefinitely. +11. History expiry removes old completed runs according to Settings. +12. A failed persistent SwiftData store opens Recovery Mode instead of immediately crashing. +13. `Scripts/security-check.sh` passes before every signed build. diff --git a/README.md b/README.md index 28735e3..fa98085 100644 --- a/README.md +++ b/README.md @@ -40,3 +40,9 @@ macOS 14 or later (SwiftData). Rsync Manager supports per-job scheduling (once, hourly, daily, weekly, monthly and custom minute intervals), a month calendar/agenda, and FSEvents-based file watching. Scheduled and watch-triggered runs are silent and always written to History with their trigger source. Background automation remains active while the application is running, including when its window is minimised, hidden or closed. The Automation settings include a native macOS Launch at Login option so the scheduler and watchers are available after sign-in. Quitting the application stops its in-process scheduler/watchers until it is launched again. + +## Production security hardening + +This project targets Developer ID signed/notarised direct distribution, not the Mac App Store. Current hardening includes Hardened Runtime, validated rsync executables, protected advanced rsync arguments, non-interactive SSH with timeouts/keepalives, normal SSH host-key verification, private-key/agent authentication only, unattended `--delete` authorisation, local source/destination overlap protection, sanitised/capped run logs, configurable history retention, a privacy manifest, and persistent-store recovery mode. + +See `Docs/RELEASE.md` and run `Scripts/security-check.sh` before producing a release archive. diff --git a/RsyncManager.xcodeproj/project.pbxproj b/RsyncManager.xcodeproj/project.pbxproj index 1a5e390..2299827 100644 --- a/RsyncManager.xcodeproj/project.pbxproj +++ b/RsyncManager.xcodeproj/project.pbxproj @@ -1,157 +1,383 @@ // !$*UTF8*$! { archiveVersion = 1; - classes = {}; + classes = { + }; objectVersion = 60; objects = { /* Begin PBXBuildFile section */ + 0AB9E3362F1DCE2312221B31 /* Models/SyncRun.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A5B87A4971CCE37EF1B979 /* Models/SyncRun.swift */; }; + 0BBFC488F7DFF17E315AEEF2 /* Utilities/FilePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8999F614BA12AF14ACCB5E3D /* Utilities/FilePicker.swift */; }; + 1FB249F959D6825750186C76 /* Views/Runs/RunHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD52560701DBE102AB8E8B73 /* Views/Runs/RunHistoryView.swift */; }; + 205FC2ECD561AE0B6EA8BE73 /* Models/SyncJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13136DBE7B701EB6D0CE478E /* Models/SyncJob.swift */; }; + 3D5AB53FDA192FBBA66D2CEF /* Views/Components/PathField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B5F691C6DE4B3B7224BE4B /* Views/Components/PathField.swift */; }; 3E6D35DAB1BD51D9E21E7B3F /* RsyncManagerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1944E5F7109545301358AD /* RsyncManagerApp.swift */; }; - FB73BE2A7887F32D512EC848 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D2D059072C1E33E64D7DCDF /* ContentView.swift */; }; - C24299D89D285E985C089C0F /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 344231C687A1F4BDC84AE838 /* SettingsView.swift */; }; - 205FC2ECD561AE0B6EA8BE73 /* SyncJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13136DBE7B701EB6D0CE478E /* SyncJob.swift */; }; - 0AB9E3362F1DCE2312221B31 /* SyncRun.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A5B87A4971CCE37EF1B979 /* SyncRun.swift */; }; - 93BD9B27AA1F883F0A05CE3A /* RsyncCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F618D81AA8D3052C88FDF93F /* RsyncCommandBuilder.swift */; }; - CC02106B9AF77855935487F6 /* RsyncRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F173223A0A15A648115C2B6 /* RsyncRunner.swift */; }; - 87F3C7BF9787C5EF1DFC58CE /* RsyncLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE0FFB276373DDA4C5A8E419 /* RsyncLocator.swift */; }; - 0BBFC488F7DFF17E315AEEF2 /* FilePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8999F614BA12AF14ACCB5E3D /* FilePicker.swift */; }; - D2009B9542E292B66BB8A0D4 /* StatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5919F7BA545585E4369602 /* StatusBadge.swift */; }; - 3D5AB53FDA192FBBA66D2CEF /* PathField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B5F691C6DE4B3B7224BE4B /* PathField.swift */; }; - 41CA3E36ADC08AE451912D92 /* FlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5790F644CCAAF49094D57D68 /* FlowLayout.swift */; }; - 593DB1791A00690020ADF82D /* JobEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA4F1E7C15E2FC1DCC85FEE /* JobEditorView.swift */; }; - 7C32064BC9CE29F5116ADE89 /* JobListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2639D73ED1EC5C164BC46AF /* JobListView.swift */; }; - DF2A7BB149EEC298F6207758 /* JobDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 002D9E0AA02D4B37F6BB05F6 /* JobDetailView.swift */; }; - 1FB249F959D6825750186C76 /* RunHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD52560701DBE102AB8E8B73 /* RunHistoryView.swift */; }; - F58FB754850EEF340B2E65FE /* RunLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39F11523D2F6BF2FBCD90FF /* RunLogView.swift */; }; - AA1000000000000000000001 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000001 /* KeychainStore.swift */; }; - AA1000000000000000000002 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000002 /* SplashView.swift */; }; + 41CA3E36ADC08AE451912D92 /* Views/Components/FlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5790F644CCAAF49094D57D68 /* Views/Components/FlowLayout.swift */; }; + 593DB1791A00690020ADF82D /* Views/Jobs/JobEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA4F1E7C15E2FC1DCC85FEE /* Views/Jobs/JobEditorView.swift */; }; + 7C32064BC9CE29F5116ADE89 /* Views/Jobs/JobListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2639D73ED1EC5C164BC46AF /* Views/Jobs/JobListView.swift */; }; + 87F3C7BF9787C5EF1DFC58CE /* Services/RsyncLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE0FFB276373DDA4C5A8E419 /* Services/RsyncLocator.swift */; }; + 93BD9B27AA1F883F0A05CE3A /* Services/RsyncCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F618D81AA8D3052C88FDF93F /* Services/RsyncCommandBuilder.swift */; }; + AA1000000000000000000001 /* Utilities/KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000001 /* Utilities/KeychainStore.swift */; }; + AA1000000000000000000002 /* Views/Components/SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000002 /* Views/Components/SplashView.swift */; }; AA1000000000000000000003 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000003 /* Assets.xcassets */; }; - AB1000000000000000000001 /* SyncSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2000000000000000000001 /* SyncSchedule.swift */; }; - AB1000000000000000000002 /* FileWatchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2000000000000000000002 /* FileWatchService.swift */; }; - AB1000000000000000000003 /* BackgroundSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2000000000000000000003 /* BackgroundSyncManager.swift */; }; - AB1000000000000000000004 /* ScheduleCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2000000000000000000004 /* ScheduleCalendarView.swift */; }; - AC1000000000000000000001 /* HelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC2000000000000000000001 /* HelpView.swift */; }; - AC1000000000000000000002 /* RsyncManagerHelp.md in Resources */ = {isa = PBXBuildFile; fileRef = AC2000000000000000000002 /* RsyncManagerHelp.md */; }; + AB1000000000000000000001 /* Models/SyncSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2000000000000000000001 /* Models/SyncSchedule.swift */; }; + AB1000000000000000000002 /* Services/FileWatchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2000000000000000000002 /* Services/FileWatchService.swift */; }; + AB1000000000000000000003 /* Services/BackgroundSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2000000000000000000003 /* Services/BackgroundSyncManager.swift */; }; + AB1000000000000000000004 /* Views/ScheduleCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2000000000000000000004 /* Views/ScheduleCalendarView.swift */; }; + AC1000000000000000000001 /* Views/HelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC2000000000000000000001 /* Views/HelpView.swift */; }; + AC1000000000000000000002 /* Resources/RsyncManagerHelp.md in Resources */ = {isa = PBXBuildFile; fileRef = AC2000000000000000000002 /* Resources/RsyncManagerHelp.md */; }; + AD1000000000000000000001 /* Resources/PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AD2000000000000000000001 /* Resources/PrivacyInfo.xcprivacy */; }; + C24299D89D285E985C089C0F /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 344231C687A1F4BDC84AE838 /* SettingsView.swift */; }; + CC02106B9AF77855935487F6 /* Services/RsyncRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F173223A0A15A648115C2B6 /* Services/RsyncRunner.swift */; }; + D2009B9542E292B66BB8A0D4 /* Views/Components/StatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5919F7BA545585E4369602 /* Views/Components/StatusBadge.swift */; }; + DF2A7BB149EEC298F6207758 /* Views/Jobs/JobDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 002D9E0AA02D4B37F6BB05F6 /* Views/Jobs/JobDetailView.swift */; }; + F58FB754850EEF340B2E65FE /* Views/Runs/RunLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39F11523D2F6BF2FBCD90FF /* Views/Runs/RunLogView.swift */; }; + FB73BE2A7887F32D512EC848 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D2D059072C1E33E64D7DCDF /* ContentView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 2C1944E5F7109545301358AD /* RsyncManagerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RsyncManagerApp.swift"; sourceTree = ""; }; - 4D2D059072C1E33E64D7DCDF /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView.swift"; sourceTree = ""; }; - 344231C687A1F4BDC84AE838 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView.swift"; sourceTree = ""; }; - 13136DBE7B701EB6D0CE478E /* SyncJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/SyncJob.swift"; sourceTree = ""; }; - C7A5B87A4971CCE37EF1B979 /* SyncRun.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/SyncRun.swift"; sourceTree = ""; }; - F618D81AA8D3052C88FDF93F /* RsyncCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/RsyncCommandBuilder.swift"; sourceTree = ""; }; - 1F173223A0A15A648115C2B6 /* RsyncRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/RsyncRunner.swift"; sourceTree = ""; }; - BE0FFB276373DDA4C5A8E419 /* RsyncLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/RsyncLocator.swift"; sourceTree = ""; }; - 8999F614BA12AF14ACCB5E3D /* FilePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Utilities/FilePicker.swift"; sourceTree = ""; }; - CA5919F7BA545585E4369602 /* StatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Components/StatusBadge.swift"; sourceTree = ""; }; - 58B5F691C6DE4B3B7224BE4B /* PathField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Components/PathField.swift"; sourceTree = ""; }; - 5790F644CCAAF49094D57D68 /* FlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Components/FlowLayout.swift"; sourceTree = ""; }; - BCA4F1E7C15E2FC1DCC85FEE /* JobEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Jobs/JobEditorView.swift"; sourceTree = ""; }; - E2639D73ED1EC5C164BC46AF /* JobListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Jobs/JobListView.swift"; sourceTree = ""; }; - 002D9E0AA02D4B37F6BB05F6 /* JobDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Jobs/JobDetailView.swift"; sourceTree = ""; }; - CD52560701DBE102AB8E8B73 /* RunHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Runs/RunHistoryView.swift"; sourceTree = ""; }; - F39F11523D2F6BF2FBCD90FF /* RunLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Runs/RunLogView.swift"; sourceTree = ""; }; - D91517BFBDECE389497A425B /* RsyncManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RsyncManager.app; sourceTree = BUILT_PRODUCTS_DIR; }; - AA2000000000000000000001 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Utilities/KeychainStore.swift"; sourceTree = ""; }; - AA2000000000000000000002 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Components/SplashView.swift"; sourceTree = ""; }; + 002D9E0AA02D4B37F6BB05F6 /* Views/Jobs/JobDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Jobs/JobDetailView.swift; sourceTree = ""; }; + 13136DBE7B701EB6D0CE478E /* Models/SyncJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/SyncJob.swift; sourceTree = ""; }; + 1F173223A0A15A648115C2B6 /* Services/RsyncRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/RsyncRunner.swift; sourceTree = ""; }; + 2C1944E5F7109545301358AD /* RsyncManagerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RsyncManagerApp.swift; sourceTree = ""; }; + 344231C687A1F4BDC84AE838 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 4D2D059072C1E33E64D7DCDF /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 5790F644CCAAF49094D57D68 /* Views/Components/FlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Components/FlowLayout.swift; sourceTree = ""; }; + 58B5F691C6DE4B3B7224BE4B /* Views/Components/PathField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Components/PathField.swift; sourceTree = ""; }; + 8999F614BA12AF14ACCB5E3D /* Utilities/FilePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities/FilePicker.swift; sourceTree = ""; }; + AA2000000000000000000001 /* Utilities/KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities/KeychainStore.swift; sourceTree = ""; }; + AA2000000000000000000002 /* Views/Components/SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Components/SplashView.swift; sourceTree = ""; }; AA2000000000000000000003 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - AB2000000000000000000001 /* SyncSchedule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/SyncSchedule.swift"; sourceTree = ""; }; - AB2000000000000000000002 /* FileWatchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/FileWatchService.swift"; sourceTree = ""; }; - AB2000000000000000000003 /* BackgroundSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/BackgroundSyncManager.swift"; sourceTree = ""; }; - AB2000000000000000000004 /* ScheduleCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/ScheduleCalendarView.swift"; sourceTree = ""; }; - AC2000000000000000000001 /* HelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/HelpView.swift"; sourceTree = ""; }; - AC2000000000000000000002 /* RsyncManagerHelp.md */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Resources/RsyncManagerHelp.md"; sourceTree = ""; }; + AB2000000000000000000001 /* Models/SyncSchedule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/SyncSchedule.swift; sourceTree = ""; }; + AB2000000000000000000002 /* Services/FileWatchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/FileWatchService.swift; sourceTree = ""; }; + AB2000000000000000000003 /* Services/BackgroundSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/BackgroundSyncManager.swift; sourceTree = ""; }; + AB2000000000000000000004 /* Views/ScheduleCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/ScheduleCalendarView.swift; sourceTree = ""; }; + AC2000000000000000000001 /* Views/HelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/HelpView.swift; sourceTree = ""; }; + AC2000000000000000000002 /* Resources/RsyncManagerHelp.md */ = {isa = PBXFileReference; lastKnownFileType = text; path = Resources/RsyncManagerHelp.md; sourceTree = ""; }; + AD2000000000000000000001 /* Resources/PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Resources/PrivacyInfo.xcprivacy; sourceTree = ""; }; + BCA4F1E7C15E2FC1DCC85FEE /* Views/Jobs/JobEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Jobs/JobEditorView.swift; sourceTree = ""; }; + BE0FFB276373DDA4C5A8E419 /* Services/RsyncLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/RsyncLocator.swift; sourceTree = ""; }; + C7A5B87A4971CCE37EF1B979 /* Models/SyncRun.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/SyncRun.swift; sourceTree = ""; }; + CA5919F7BA545585E4369602 /* Views/Components/StatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Components/StatusBadge.swift; sourceTree = ""; }; + CD52560701DBE102AB8E8B73 /* Views/Runs/RunHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Runs/RunHistoryView.swift; sourceTree = ""; }; + D91517BFBDECE389497A425B /* RsyncManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RsyncManager.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E2639D73ED1EC5C164BC46AF /* Views/Jobs/JobListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Jobs/JobListView.swift; sourceTree = ""; }; + F39F11523D2F6BF2FBCD90FF /* Views/Runs/RunLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Runs/RunLogView.swift; sourceTree = ""; }; + F618D81AA8D3052C88FDF93F /* Services/RsyncCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/RsyncCommandBuilder.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - DAF6AA633395B1B76148E35B /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; + DAF6AA633395B1B76148E35B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - FAD58DE7366495DB4650CFEF = {isa = PBXGroup; children = (B60F08E1B2CF35AEB2F1DE90 /* RsyncManager */, 86024CAD1E83101D97359D73 /* Products */); sourceTree = ""; }; - B60F08E1B2CF35AEB2F1DE90 /* RsyncManager */ = {isa = PBXGroup; children = ( + 86024CAD1E83101D97359D73 /* Products */ = { + isa = PBXGroup; + children = ( + D91517BFBDECE389497A425B /* RsyncManager.app */, + ); + name = Products; + sourceTree = ""; + }; + B60F08E1B2CF35AEB2F1DE90 /* RsyncManager */ = { + isa = PBXGroup; + children = ( 2C1944E5F7109545301358AD /* RsyncManagerApp.swift */, 4D2D059072C1E33E64D7DCDF /* ContentView.swift */, 344231C687A1F4BDC84AE838 /* SettingsView.swift */, - 13136DBE7B701EB6D0CE478E /* SyncJob.swift */, - C7A5B87A4971CCE37EF1B979 /* SyncRun.swift */, - AB2000000000000000000001 /* SyncSchedule.swift */, - F618D81AA8D3052C88FDF93F /* RsyncCommandBuilder.swift */, - 1F173223A0A15A648115C2B6 /* RsyncRunner.swift */, - BE0FFB276373DDA4C5A8E419 /* RsyncLocator.swift */, - AB2000000000000000000002 /* FileWatchService.swift */, - AB2000000000000000000003 /* BackgroundSyncManager.swift */, - 8999F614BA12AF14ACCB5E3D /* FilePicker.swift */, - CA5919F7BA545585E4369602 /* StatusBadge.swift */, - 58B5F691C6DE4B3B7224BE4B /* PathField.swift */, - 5790F644CCAAF49094D57D68 /* FlowLayout.swift */, - BCA4F1E7C15E2FC1DCC85FEE /* JobEditorView.swift */, - E2639D73ED1EC5C164BC46AF /* JobListView.swift */, - 002D9E0AA02D4B37F6BB05F6 /* JobDetailView.swift */, - CD52560701DBE102AB8E8B73 /* RunHistoryView.swift */, - F39F11523D2F6BF2FBCD90FF /* RunLogView.swift */, - AB2000000000000000000004 /* ScheduleCalendarView.swift */, - AC2000000000000000000001 /* HelpView.swift */, - AC2000000000000000000002 /* RsyncManagerHelp.md */, - AA2000000000000000000001 /* KeychainStore.swift */, - AA2000000000000000000002 /* SplashView.swift */, + 13136DBE7B701EB6D0CE478E /* Models/SyncJob.swift */, + C7A5B87A4971CCE37EF1B979 /* Models/SyncRun.swift */, + AB2000000000000000000001 /* Models/SyncSchedule.swift */, + F618D81AA8D3052C88FDF93F /* Services/RsyncCommandBuilder.swift */, + 1F173223A0A15A648115C2B6 /* Services/RsyncRunner.swift */, + BE0FFB276373DDA4C5A8E419 /* Services/RsyncLocator.swift */, + AB2000000000000000000002 /* Services/FileWatchService.swift */, + AB2000000000000000000003 /* Services/BackgroundSyncManager.swift */, + 8999F614BA12AF14ACCB5E3D /* Utilities/FilePicker.swift */, + CA5919F7BA545585E4369602 /* Views/Components/StatusBadge.swift */, + 58B5F691C6DE4B3B7224BE4B /* Views/Components/PathField.swift */, + 5790F644CCAAF49094D57D68 /* Views/Components/FlowLayout.swift */, + BCA4F1E7C15E2FC1DCC85FEE /* Views/Jobs/JobEditorView.swift */, + E2639D73ED1EC5C164BC46AF /* Views/Jobs/JobListView.swift */, + 002D9E0AA02D4B37F6BB05F6 /* Views/Jobs/JobDetailView.swift */, + CD52560701DBE102AB8E8B73 /* Views/Runs/RunHistoryView.swift */, + F39F11523D2F6BF2FBCD90FF /* Views/Runs/RunLogView.swift */, + AB2000000000000000000004 /* Views/ScheduleCalendarView.swift */, + AC2000000000000000000001 /* Views/HelpView.swift */, + AC2000000000000000000002 /* Resources/RsyncManagerHelp.md */, + AD2000000000000000000001 /* Resources/PrivacyInfo.xcprivacy */, + AA2000000000000000000001 /* Utilities/KeychainStore.swift */, + AA2000000000000000000002 /* Views/Components/SplashView.swift */, AA2000000000000000000003 /* Assets.xcassets */, - ); path = RsyncManager; sourceTree = ""; }; - 86024CAD1E83101D97359D73 /* Products */ = {isa = PBXGroup; children = (D91517BFBDECE389497A425B /* RsyncManager.app */); name = Products; sourceTree = ""; }; + ); + path = RsyncManager; + sourceTree = ""; + }; + FAD58DE7366495DB4650CFEF = { + isa = PBXGroup; + children = ( + B60F08E1B2CF35AEB2F1DE90 /* RsyncManager */, + 86024CAD1E83101D97359D73 /* Products */, + ); + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 42AEFBAE01D2DFD981F7DA7D /* RsyncManager */ = {isa = PBXNativeTarget; buildConfigurationList = 1EE5970F9824F2EE5F7679D7; buildPhases = (FCA229C23F166EBB0BA5029E /* Sources */, DAF6AA633395B1B76148E35B /* Frameworks */, B9C6EB6F79FC54535ACB3053 /* Resources */); buildRules = (); dependencies = (); name = RsyncManager; productName = RsyncManager; productReference = D91517BFBDECE389497A425B /* RsyncManager.app */; productType = "com.apple.product-type.application"; }; + 42AEFBAE01D2DFD981F7DA7D /* RsyncManager */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1EE5970F9824F2EE5F7679D7 /* Build configuration list for PBXNativeTarget "RsyncManager" */; + buildPhases = ( + FCA229C23F166EBB0BA5029E /* Sources */, + DAF6AA633395B1B76148E35B /* Frameworks */, + B9C6EB6F79FC54535ACB3053 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = RsyncManager; + productName = RsyncManager; + productReference = D91517BFBDECE389497A425B /* RsyncManager.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ - 46F86FAA6BBF9AC94A7E4595 /* Project object */ = {isa = PBXProject; attributes = {BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1600; TargetAttributes = {42AEFBAE01D2DFD981F7DA7D = {CreatedOnToolsVersion = 16.0;};};}; buildConfigurationList = F0E5A3A488914D1AA34BF23A; compatibilityVersion = "Xcode 15.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = (en, Base); mainGroup = FAD58DE7366495DB4650CFEF; productRefGroup = 86024CAD1E83101D97359D73; projectDirPath = ""; projectRoot = ""; targets = (42AEFBAE01D2DFD981F7DA7D /* RsyncManager */); }; + 46F86FAA6BBF9AC94A7E4595 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 2700; + TargetAttributes = { + 42AEFBAE01D2DFD981F7DA7D = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = F0E5A3A488914D1AA34BF23A /* Build configuration list for PBXProject "RsyncManager" */; + compatibilityVersion = "Xcode 15.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = FAD58DE7366495DB4650CFEF; + productRefGroup = 86024CAD1E83101D97359D73 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 42AEFBAE01D2DFD981F7DA7D /* RsyncManager */, + ); + }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - B9C6EB6F79FC54535ACB3053 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (AA1000000000000000000003 /* Assets.xcassets in Resources */, AC1000000000000000000002 /* RsyncManagerHelp.md in Resources */,); runOnlyForDeploymentPostprocessing = 0; }; + B9C6EB6F79FC54535ACB3053 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA1000000000000000000003 /* Assets.xcassets in Resources */, + AC1000000000000000000002 /* Resources/RsyncManagerHelp.md in Resources */, + AD1000000000000000000001 /* Resources/PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - FCA229C23F166EBB0BA5029E /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FCA229C23F166EBB0BA5029E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( 3E6D35DAB1BD51D9E21E7B3F /* RsyncManagerApp.swift in Sources */, FB73BE2A7887F32D512EC848 /* ContentView.swift in Sources */, C24299D89D285E985C089C0F /* SettingsView.swift in Sources */, - 205FC2ECD561AE0B6EA8BE73 /* SyncJob.swift in Sources */, - 0AB9E3362F1DCE2312221B31 /* SyncRun.swift in Sources */, - AB1000000000000000000001 /* SyncSchedule.swift in Sources */, - 93BD9B27AA1F883F0A05CE3A /* RsyncCommandBuilder.swift in Sources */, - CC02106B9AF77855935487F6 /* RsyncRunner.swift in Sources */, - 87F3C7BF9787C5EF1DFC58CE /* RsyncLocator.swift in Sources */, - AB1000000000000000000002 /* FileWatchService.swift in Sources */, - AB1000000000000000000003 /* BackgroundSyncManager.swift in Sources */, - 0BBFC488F7DFF17E315AEEF2 /* FilePicker.swift in Sources */, - D2009B9542E292B66BB8A0D4 /* StatusBadge.swift in Sources */, - 3D5AB53FDA192FBBA66D2CEF /* PathField.swift in Sources */, - 41CA3E36ADC08AE451912D92 /* FlowLayout.swift in Sources */, - 593DB1791A00690020ADF82D /* JobEditorView.swift in Sources */, - 7C32064BC9CE29F5116ADE89 /* JobListView.swift in Sources */, - DF2A7BB149EEC298F6207758 /* JobDetailView.swift in Sources */, - 1FB249F959D6825750186C76 /* RunHistoryView.swift in Sources */, - F58FB754850EEF340B2E65FE /* RunLogView.swift in Sources */, - AB1000000000000000000004 /* ScheduleCalendarView.swift in Sources */, - AC1000000000000000000001 /* HelpView.swift in Sources */, - AA1000000000000000000001 /* KeychainStore.swift in Sources */, - AA1000000000000000000002 /* SplashView.swift in Sources */, - ); runOnlyForDeploymentPostprocessing = 0; }; + 205FC2ECD561AE0B6EA8BE73 /* Models/SyncJob.swift in Sources */, + 0AB9E3362F1DCE2312221B31 /* Models/SyncRun.swift in Sources */, + AB1000000000000000000001 /* Models/SyncSchedule.swift in Sources */, + 93BD9B27AA1F883F0A05CE3A /* Services/RsyncCommandBuilder.swift in Sources */, + CC02106B9AF77855935487F6 /* Services/RsyncRunner.swift in Sources */, + 87F3C7BF9787C5EF1DFC58CE /* Services/RsyncLocator.swift in Sources */, + AB1000000000000000000002 /* Services/FileWatchService.swift in Sources */, + AB1000000000000000000003 /* Services/BackgroundSyncManager.swift in Sources */, + 0BBFC488F7DFF17E315AEEF2 /* Utilities/FilePicker.swift in Sources */, + D2009B9542E292B66BB8A0D4 /* Views/Components/StatusBadge.swift in Sources */, + 3D5AB53FDA192FBBA66D2CEF /* Views/Components/PathField.swift in Sources */, + 41CA3E36ADC08AE451912D92 /* Views/Components/FlowLayout.swift in Sources */, + 593DB1791A00690020ADF82D /* Views/Jobs/JobEditorView.swift in Sources */, + 7C32064BC9CE29F5116ADE89 /* Views/Jobs/JobListView.swift in Sources */, + DF2A7BB149EEC298F6207758 /* Views/Jobs/JobDetailView.swift in Sources */, + 1FB249F959D6825750186C76 /* Views/Runs/RunHistoryView.swift in Sources */, + F58FB754850EEF340B2E65FE /* Views/Runs/RunLogView.swift in Sources */, + AB1000000000000000000004 /* Views/ScheduleCalendarView.swift in Sources */, + AC1000000000000000000001 /* Views/HelpView.swift in Sources */, + AA1000000000000000000001 /* Utilities/KeychainStore.swift in Sources */, + AA1000000000000000000002 /* Views/Components/SplashView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ - 43E6727D37020658CECE8F0C /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone";}; name = Debug; }; - 643EE4829B317660C2A7A37C /* Release */ = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule;}; name = Release; }; - 0C42D4EB6117C2B9C8008C35 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Rsync Manager"; INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.RsyncManager; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0;}; name = Debug; }; - A969B6690DDF65BACEAA2A89 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Rsync Manager"; INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.RsyncManager; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0;}; name = Release; }; + 0C42D4EB6117C2B9C8008C35 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Rsync Manager"; + INFOPLIST_KEY_CFBundleIconName = AppIcon; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.absdev.RsyncManager; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 43E6727D37020658CECE8F0C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + DEAD_CODE_STRIPPING = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 643EE4829B317660C2A7A37C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + DEAD_CODE_STRIPPING = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + A969B6690DDF65BACEAA2A89 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = YES; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Rsync Manager"; + INFOPLIST_KEY_CFBundleIconName = AppIcon; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.absdev.RsyncManager; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - F0E5A3A488914D1AA34BF23A /* Build configuration list for PBXProject */ = {isa = XCConfigurationList; buildConfigurations = (43E6727D37020658CECE8F0C /* Debug */, 643EE4829B317660C2A7A37C /* Release */); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 1EE5970F9824F2EE5F7679D7 /* Build configuration list for PBXNativeTarget */ = {isa = XCConfigurationList; buildConfigurations = (0C42D4EB6117C2B9C8008C35 /* Debug */, A969B6690DDF65BACEAA2A89 /* Release */); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 1EE5970F9824F2EE5F7679D7 /* Build configuration list for PBXNativeTarget "RsyncManager" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0C42D4EB6117C2B9C8008C35 /* Debug */, + A969B6690DDF65BACEAA2A89 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F0E5A3A488914D1AA34BF23A /* Build configuration list for PBXProject "RsyncManager" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 43E6727D37020658CECE8F0C /* Debug */, + 643EE4829B317660C2A7A37C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 46F86FAA6BBF9AC94A7E4595 /* Project object */; diff --git a/RsyncManager.xcodeproj/project.xcworkspace/xcuserdata/mag.xcuserdatad/UserInterfaceState.xcuserstate b/RsyncManager.xcodeproj/project.xcworkspace/xcuserdata/mag.xcuserdatad/UserInterfaceState.xcuserstate index 26ac728140da4bc2ccebeb4d781c6de404c7fe5d..36be02ecdfac5197aa36655d4ebaf1ad6be25fa8 100644 GIT binary patch delta 8912 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 String { + let stripped = stripANSI(value) + let redacted = stripped + .replacingOccurrences(of: #"(?i)--password-file(?:=|\s+)[^\s]+"#, with: "--password-file=[REDACTED]", options: .regularExpression) + .replacingOccurrences(of: #"(?i)(password|passwd|token|secret)=([^\s]+)"#, with: "$1=[REDACTED]", options: .regularExpression) + + let data = Data(redacted.utf8) + guard data.count > maxBytes else { return redacted } + let prefix = data.prefix(maxBytes) + return String(decoding: prefix, as: UTF8.self) + "\n\n[Log truncated by Rsync Manager for safety and storage limits.]\n" + } + + private static func stripANSI(_ value: String) -> String { + value.replacingOccurrences( + of: #"\u001B\[[0-9;?]*[ -/]*[@-~]"#, + with: "", + options: .regularExpression + ) + } + var exportText: String { var text = "Rsync Manager Run Log\n" text += "Job: \(jobName)\n" diff --git a/RsyncManager/Resources/PrivacyInfo.xcprivacy b/RsyncManager/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..937278f --- /dev/null +++ b/RsyncManager/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/RsyncManager/Resources/RsyncManagerHelp.md b/RsyncManager/Resources/RsyncManagerHelp.md index cc659fe..f79927b 100644 --- a/RsyncManager/Resources/RsyncManagerHelp.md +++ b/RsyncManager/Resources/RsyncManagerHelp.md @@ -511,3 +511,41 @@ For an rsync-specific option not documented here, run `man rsync` in Terminal fo Use **Sync All** in the Jobs sidebar to run every enabled job in sequence. Rsync Manager queues the jobs rather than starting several rsync processes at once. Each job creates its own History entry, and the header shows the current batch progress. Disabled jobs are skipped. + +# Security and unattended operation + +Rsync Manager is designed for direct Developer ID distribution. It uses the macOS Hardened Runtime and intentionally does not use the App Sandbox because the utility must launch `rsync`/`ssh` and work with arbitrary user-selected paths. + +## SSH security + +SSH authentication uses a private key or your normal macOS SSH agent. Rsync Manager does not store SSH passwords and does not place passwords on a process command line. Automated SSH uses `BatchMode=yes`, a connection timeout, keepalive intervals and a finite keepalive failure count. Host-key verification is never disabled; connect to a new host once in Terminal first if SSH needs you to approve its host key. + +Example: + +```text +ssh -p 22 -i ~/.ssh/id_ed25519 mag@pegasus.local +``` + +## Protected advanced arguments + +The Advanced field is for ordinary rsync tuning only. Rsync Manager rejects arguments that can replace the SSH transport, run a different remote command, read options/file lists from other files, write logs/batches to arbitrary paths, load a password file, enter daemon mode, remove source files or bypass the app's controlled delete switch. Examples that are rejected include `-e`, `-M`, `--rsh`, `--rsync-path`, `--password-file`, `--files-from`, `--log-file`, `--write-batch`, `--remove-source-files` and `--delete*`. Unmatched quotes and trailing escape characters are also rejected. + +## Delete protection + +Manual `--delete` runs always show a destructive-operation warning. If a scheduled or file-watch job uses Delete, you must also explicitly enable **Allow unattended delete** in the job editor. This prevents a job from becoming destructively automated merely because Schedule or File Watch was enabled later. + +Run a Dry Run and review the destination before authorising unattended delete. + +## Path overlap protection + +For local jobs, source and destination must be separate directory trees. Rsync Manager rejects configurations where the destination is inside the source or the source is inside the destination. This prevents recursive backup trees and common file-watch loops. + +## History and privacy + +Rsync Manager does not include analytics or tracking. Run output is sanitised before persistence, terminal control sequences are removed, likely credential-shaped values are redacted, and each stdout/stderr log is capped at approximately 2 MB. History retention is configurable in **Settings > Automation > History & Privacy** and defaults to 90 days. + +The bundled privacy manifest declares no tracking and no collected data. + +## Release verification + +The project contains `Docs/RELEASE.md` and `Scripts/security-check.sh` with Developer ID, notarisation, signature and Gatekeeper verification steps for production builds. diff --git a/RsyncManager/RsyncManagerApp.swift b/RsyncManager/RsyncManagerApp.swift index 09b8826..5bba8f9 100644 --- a/RsyncManager/RsyncManagerApp.swift +++ b/RsyncManager/RsyncManagerApp.swift @@ -5,6 +5,7 @@ import SwiftData @MainActor struct RsyncManagerApp: App { let sharedModelContainer: ModelContainer + let startupStorageError: String? @StateObject private var runner: RsyncRunner @StateObject private var automation: BackgroundSyncManager @@ -14,20 +15,33 @@ struct RsyncManagerApp: App { SyncRun.self, ]) let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + KeychainStore.delete("defaultSSHPassword") + do { let container = try ModelContainer(for: schema, configurations: [configuration]) sharedModelContainer = container + startupStorageError = nil let rsyncRunner = RsyncRunner() _runner = StateObject(wrappedValue: rsyncRunner) _automation = StateObject(wrappedValue: BackgroundSyncManager(modelContext: container.mainContext, runner: rsyncRunner)) } catch { - fatalError("Could not create ModelContainer: \(error)") + let fallbackConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + do { + let fallback = try ModelContainer(for: schema, configurations: [fallbackConfiguration]) + sharedModelContainer = fallback + startupStorageError = "Rsync Manager could not open its persistent database and is running with temporary in-memory storage for this session. Jobs or history created now will not persist. Original error: \(error.localizedDescription)" + let rsyncRunner = RsyncRunner() + _runner = StateObject(wrappedValue: rsyncRunner) + _automation = StateObject(wrappedValue: BackgroundSyncManager(modelContext: fallback.mainContext, runner: rsyncRunner)) + } catch { + fatalError("Rsync Manager could not initialise persistent or temporary storage: \(error)") + } } } var body: some Scene { WindowGroup { - ContentView() + ContentView(startupStorageError: startupStorageError) .frame(minWidth: 1040, minHeight: 680) .environmentObject(runner) .environmentObject(automation) diff --git a/RsyncManager/Services/BackgroundSyncManager.swift b/RsyncManager/Services/BackgroundSyncManager.swift index 33229ce..7c4147b 100644 --- a/RsyncManager/Services/BackgroundSyncManager.swift +++ b/RsyncManager/Services/BackgroundSyncManager.swift @@ -23,6 +23,7 @@ final class BackgroundSyncManager: ObservableObject { @Published private(set) var isSyncAllRunning = false @Published private(set) var syncAllCompleted = 0 @Published private(set) var syncAllTotal = 0 + @Published private(set) var lastError: String? var activityDisplayTitle: String { if isSyncAllRunning { @@ -67,6 +68,7 @@ final class BackgroundSyncManager: ObservableObject { guard !started else { return } started = true refreshConfiguration() + pruneHistoryIfNeeded(force: true) checkSchedules() timer = Timer.scheduledTimer(withTimeInterval: 20, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in @@ -119,7 +121,7 @@ final class BackgroundSyncManager: ObservableObject { for job in jobs where job.scheduleEnabled && job.scheduleKind != .manual && job.nextRunAt == nil { job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1)) } - try? modelContext.save() + saveContext("refreshing automation configuration") } private func checkSchedules() { @@ -136,7 +138,7 @@ final class BackgroundSyncManager: ObservableObject { job.nextRunAt = job.calculateNextRun(after: now) } } - try? modelContext.save() + saveContext("updating scheduled jobs") } private func fileChanged(jobID: UUID, path: String) { @@ -150,7 +152,7 @@ final class BackgroundSyncManager: ObservableObject { guard !Task.isCancelled, let self else { return } _ = self.enqueue(jobID: jobID, trigger: .watched) } - try? modelContext.save() + saveContext("recording file-watch activity") } @discardableResult @@ -209,12 +211,12 @@ final class BackgroundSyncManager: ObservableObject { let command = RsyncCommandBuilder.displayCommand(job: job, dryRun: false, executable: executable) let run = SyncRun(job: job, command: command, wasDryRun: false, trigger: trigger) modelContext.insert(run) - try? modelContext.save() + saveContext("creating run history") do { - let result = try await runner.run(job: job, dryRun: false) - run.output = result.stdout - run.errorOutput = result.stderr + let result = try await runner.run(job: job, dryRun: false, trigger: trigger) + run.output = SyncRun.sanitizedLog(result.stdout) + run.errorOutput = SyncRun.sanitizedLog(result.stderr) run.exitCode = Int(result.exitCode) run.wasCancelled = result.wasCancelled run.finishedAt = Date() @@ -222,13 +224,42 @@ final class BackgroundSyncManager: ObservableObject { job.lastExitCode = Int(result.exitCode) job.updatedAt = Date() } catch { - run.errorOutput = error.localizedDescription + run.errorOutput = SyncRun.sanitizedLog(error.localizedDescription) run.exitCode = -1 run.finishedAt = Date() job.lastRunAt = run.finishedAt job.lastExitCode = -1 } - try? modelContext.save() + saveContext("saving run history") + pruneHistoryIfNeeded() + } + + private func pruneHistoryIfNeeded(force: Bool = false) { + let now = Date() + if !force, let last = UserDefaults.standard.object(forKey: "lastHistoryPruneAt") as? Date, now.timeIntervalSince(last) < 3600 { return } + + let configuredDays = UserDefaults.standard.integer(forKey: "historyRetentionDays") + let days = configuredDays == 0 ? 90 : max(7, min(configuredDays, 3650)) + let cutoff = now.addingTimeInterval(-TimeInterval(days) * 86_400) + + do { + let runs = try modelContext.fetch(FetchDescriptor()) + for run in runs where run.finishedAt != nil && run.startedAt < cutoff { + modelContext.delete(run) + } + try modelContext.save() + UserDefaults.standard.set(now, forKey: "lastHistoryPruneAt") + } catch { + lastError = "Could not prune run history: \(error.localizedDescription)" + } + } + + private func saveContext(_ operation: String) { + do { + try modelContext.save() + } catch { + lastError = "Database error while \(operation): \(error.localizedDescription)" + } } private func fetchJobs() -> [SyncJob] { diff --git a/RsyncManager/Services/RsyncCommandBuilder.swift b/RsyncManager/Services/RsyncCommandBuilder.swift index 6c5ff8d..863459e 100644 --- a/RsyncManager/Services/RsyncCommandBuilder.swift +++ b/RsyncManager/Services/RsyncCommandBuilder.swift @@ -12,31 +12,16 @@ struct RsyncCommandBuilder { if job.compress { args.append("-z") } - // Apple's bundled /usr/bin/rsync uses the Apple-specific -E flag for - // extended attributes. That flag is not compatible with a standard - // rsync server over SSH (for example Linux rsync 3.x), because Apple - // rsync expands it to --extended-attributes on the remote side. - // - // Therefore: - // • Apple rsync + local job -> -E - // • Apple rsync + SSH job -> omit xattr flag - // • Modern/Homebrew rsync -> -X (standard rsync xattrs) if job.preserveExtendedAttributes { if isAppleRsync(executable) { - if !job.isRemote { - args.append("-E") - } + if !job.isRemote { args.append("-E") } } else { args.append("-X") } } if job.deleteExtraneous { args.append("--delete") } - - // --info=progress2 is not supported by Apple's bundled rsync 2.6.9. - // --progress works on both Apple's rsync and modern Homebrew rsync. if job.showProgress { args.append("--progress") } - if dryRun { args.append("--dry-run") } args += ["--human-readable", "--stats"] @@ -50,14 +35,24 @@ struct RsyncCommandBuilder { } if job.isRemote { - var sshParts = ["ssh", "-p", String(job.remotePort)] + var sshParts = [ + "ssh", + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=15", + "-o", "ServerAliveInterval=30", + "-o", "ServerAliveCountMax=3", + "-p", String(job.remotePort) + ] let keyPath = normalizedPrivateKeyPath(job.sshKeyPath) if !keyPath.isEmpty { - sshParts += ["-i", keyPath] + sshParts += ["-o", "IdentitiesOnly=yes", "-i", keyPath] } args += ["-e", sshParts.map(shellEscape).joined(separator: " ")] } + // End option parsing before user-controlled filesystem operands. This prevents + // a local path beginning with '-' from being interpreted as another rsync option. + args.append("--") args.append(normalizeSource(job.sourcePath)) args.append(job.destinationDisplay) return args @@ -71,22 +66,21 @@ struct RsyncCommandBuilder { static func normalizedPrivateKeyPath(_ path: String) -> String { let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasSuffix(".pub") else { return trimmed } + guard trimmed.hasSuffix(".pub") else { return (trimmed as NSString).expandingTildeInPath } let privatePath = String(trimmed.dropLast(4)) - if FileManager.default.fileExists(atPath: privatePath) { - return privatePath + let expandedPrivatePath = (privatePath as NSString).expandingTildeInPath + if FileManager.default.fileExists(atPath: expandedPrivatePath) { + return expandedPrivatePath } - return trimmed + return (trimmed as NSString).expandingTildeInPath } private static func isAppleRsync(_ executable: String) -> Bool { executable == "/usr/bin/rsync" } - private static func normalizeSource(_ path: String) -> String { - path - } + private static func normalizeSource(_ path: String) -> String { path } static func shellEscape(_ value: String) -> String { if value.isEmpty { return "''" } @@ -130,3 +124,169 @@ struct RsyncCommandBuilder { return result } } + +enum RsyncSecurityValidator { + static func validationMessage(for job: SyncJob, trigger: SyncRunTrigger = .manual) -> String? { + let source = job.sourcePath.trimmingCharacters(in: .whitespacesAndNewlines) + let destination = job.destinationPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !source.isEmpty, !destination.isEmpty else { + return "Source and destination are required." + } + + if containsControlCharacters(source) || containsControlCharacters(destination) { + return "Paths may not contain control characters." + } + + if job.watchEnabled && containsControlCharacters(job.effectiveWatchPath) { + return "The file-watch path may not contain control characters." + } + + if job.exclusions.contains(where: containsControlCharacters) { + return "Exclusion patterns may not contain control characters." + } + + if let advancedError = validateAdditionalArguments(job.additionalArguments) { + return advancedError + } + + if job.isRemote { + let host = job.remoteHost.trimmingCharacters(in: .whitespacesAndNewlines) + let user = job.remoteUser.trimmingCharacters(in: .whitespacesAndNewlines) + guard isValidHost(host) else { + return "The SSH host is invalid. Use a hostname or IP address without spaces or command-line option prefixes." + } + if !user.isEmpty, !isValidUser(user) { + return "The SSH username is invalid. Use letters, numbers, '.', '_' or '-'." + } + guard (1...65535).contains(job.remotePort) else { + return "The SSH port must be between 1 and 65535." + } + if destination.hasPrefix("-") { + return "A remote destination may not begin with '-'." + } + if !isSafeRemotePath(destination) { + return "The remote destination contains characters that are unsafe for an SSH/rsync remote shell. Use a normal path containing letters, numbers, spaces, '/', '.', '_', '-', '~', '+', '@', '%' or '=' only." + } + if destination == host || destination == "\(user)@\(host)" { + return "Remote destination must be a path on the remote machine, not the host address." + } + + let keyPath = RsyncCommandBuilder.normalizedPrivateKeyPath(job.sshKeyPath) + if !job.sshKeyPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if keyPath.hasSuffix(".pub") { + return "Select the private SSH key, not the .pub public key." + } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: keyPath, isDirectory: &isDirectory), !isDirectory.boolValue else { + return "The selected private SSH key does not exist or is not a regular file." + } + } + } else if pathsOverlap(source: source, destination: destination) { + return "Source and destination overlap. Choose separate directory trees to prevent recursive copies or file-watch loops." + } + + if job.deleteExtraneous, + (trigger == .scheduled || trigger == .watched), + !job.allowUnattendedDelete { + return "This job uses --delete but unattended destructive sync is not authorised. Edit the job and explicitly enable unattended delete." + } + + return nil + } + + private static func validateAdditionalArguments(_ raw: String) -> String? { + if !hasBalancedShellSyntax(raw) { + return "Advanced rsync arguments contain an unmatched quote or trailing escape character." + } + + let arguments = RsyncCommandBuilder.shellSplit(raw) + let blockedExact: Set = [ + "-e", "-M", "--rsh", "--rsync-path", "--remote-option", + "--password-file", "--daemon", "--config", "--port", "--address", + "--sockopts", "--remove-source-files", "--copy-as", "--files-from", + "--exclude-from", "--include-from", "--log-file", "--log-file-format", + "--backup-dir", "--temp-dir", "--partial-dir", "--chown", "--usermap", + "--groupmap", "--rsync-path", "--write-batch", "--only-write-batch", + "--read-batch" + ] + let blockedPrefixes = [ + "--rsh=", "--rsync-path=", "--remote-option=", "--password-file=", + "--config=", "--port=", "--address=", "--sockopts=", "--copy-as=", + "--delete", "--remove-source-files", "--files-from=", "--exclude-from=", + "--include-from=", "--log-file=", "--log-file-format=", "--backup-dir=", + "--temp-dir=", "--partial-dir=", "--chown=", "--usermap=", "--groupmap=", + "--write-batch=", "--only-write-batch=", "--read-batch=" + ] + + for argument in arguments { + if blockedExact.contains(argument) || blockedPrefixes.contains(where: { argument.hasPrefix($0) }) { + return "Advanced argument '\(argument)' is security-sensitive or destructive and is managed by Rsync Manager. Remove it and use the dedicated job controls instead." + } + if containsControlCharacters(argument) { + return "Advanced rsync arguments may not contain control characters." + } + } + return nil + } + + + private static func isSafeRemotePath(_ value: String) -> Bool { + guard !value.isEmpty, !containsControlCharacters(value) else { return false } + // Rsync invokes the remote side through SSH. Keep remote paths out of shell + // metacharacter territory so a configured path can never become a remote command. + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: " /._-~+@%=")) + return value.unicodeScalars.allSatisfy { allowed.contains($0) } + } + + private static func hasBalancedShellSyntax(_ value: String) -> Bool { + var quote: Character? + var escaping = false + for ch in value { + if escaping { + escaping = false + continue + } + if ch == "\\" { + escaping = true + continue + } + if let activeQuote = quote { + if ch == activeQuote { quote = nil } + continue + } + if ch == "\"" || ch == "'" { quote = ch } + } + return quote == nil && !escaping + } + + private static func isValidHost(_ value: String) -> Bool { + guard !value.isEmpty, !value.hasPrefix("-"), !containsControlCharacters(value) else { return false } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_:[]")) + return value.unicodeScalars.allSatisfy { allowed.contains($0) } + } + + private static func isValidUser(_ value: String) -> Bool { + guard !value.hasPrefix("-"), !containsControlCharacters(value) else { return false } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + return value.unicodeScalars.allSatisfy { allowed.contains($0) } + } + + private static func containsControlCharacters(_ value: String) -> Bool { + value.unicodeScalars.contains { CharacterSet.controlCharacters.contains($0) } + } + + private static func pathsOverlap(source: String, destination: String) -> Bool { + let src = canonicalLocalPath(source) + let dst = canonicalLocalPath(destination) + guard !src.isEmpty, !dst.isEmpty else { return false } + if src == dst { return true } + return dst.hasPrefix(src + "/") || src.hasPrefix(dst + "/") + } + + private static func canonicalLocalPath(_ value: String) -> String { + let expanded = (value as NSString).expandingTildeInPath + let standardized = (expanded as NSString).standardizingPath + if standardized == "/" { return standardized } + return standardized.hasSuffix("/") ? String(standardized.dropLast()) : standardized + } +} diff --git a/RsyncManager/Services/RsyncLocator.swift b/RsyncManager/Services/RsyncLocator.swift index 5b9b8f4..3c5d4a9 100644 --- a/RsyncManager/Services/RsyncLocator.swift +++ b/RsyncManager/Services/RsyncLocator.swift @@ -1,12 +1,29 @@ import Foundation enum RsyncLocator { + static let candidates = [ + "/opt/homebrew/bin/rsync", + "/usr/local/bin/rsync", + "/usr/bin/rsync" + ] + static func locate() -> String { - let candidates = [ - "/opt/homebrew/bin/rsync", - "/usr/local/bin/rsync", - "/usr/bin/rsync" - ] - return candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) ?? "/usr/bin/rsync" + candidates.first(where: isTrustedExecutableCandidate) ?? "/usr/bin/rsync" + } + + static func isTrustedExecutableCandidate(_ path: String) -> Bool { + let manager = FileManager.default + guard manager.isExecutableFile(atPath: path) else { return false } + + let resolved = URL(fileURLWithPath: path).resolvingSymlinksInPath().path + var isDirectory: ObjCBool = false + guard manager.fileExists(atPath: resolved, isDirectory: &isDirectory), !isDirectory.boolValue else { return false } + + do { + let attributes = try manager.attributesOfItem(atPath: resolved) + return attributes[.type] as? FileAttributeType == .typeRegular + } catch { + return false + } } } diff --git a/RsyncManager/Services/RsyncRunner.swift b/RsyncManager/Services/RsyncRunner.swift index ba93d8d..664f7ba 100644 --- a/RsyncManager/Services/RsyncRunner.swift +++ b/RsyncManager/Services/RsyncRunner.swift @@ -14,17 +14,16 @@ final class RsyncRunner: ObservableObject { let wasCancelled: Bool } - func run(job: SyncJob, dryRun: Bool) async throws -> Result { + func run(job: SyncJob, dryRun: Bool, trigger: SyncRunTrigger = .manual) async throws -> Result { guard !isRunning else { throw RunnerError.alreadyRunning } - guard !job.sourcePath.isEmpty, !job.destinationPath.isEmpty else { throw RunnerError.invalidPaths } - if job.isRemote && job.remoteHost.isEmpty { throw RunnerError.missingRemoteHost } - if job.isRemote && remoteDestinationLooksLikeHost(job) { throw RunnerError.invalidRemoteDestination } - if job.isRemote && job.sshKeyPath.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix(".pub") { - let privatePath = RsyncCommandBuilder.normalizedPrivateKeyPath(job.sshKeyPath) - if privatePath.hasSuffix(".pub") { throw RunnerError.publicSSHKeySelected } + if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: trigger) { + throw RunnerError.securityValidation(message) } let executable = RsyncLocator.locate() + guard RsyncLocator.isTrustedExecutableCandidate(executable) else { + throw RunnerError.invalidExecutable(executable) + } let arguments = RsyncCommandBuilder.build(job: job, dryRun: dryRun, executable: executable) isRunning = true @@ -106,15 +105,18 @@ final class RsyncRunner: ObservableObject { } enum RunnerError: LocalizedError { - case alreadyRunning, invalidPaths, missingRemoteHost, invalidRemoteDestination, publicSSHKeySelected + case alreadyRunning + case securityValidation(String) + case invalidExecutable(String) var errorDescription: String? { switch self { - case .alreadyRunning: return "Another rsync job is already running." - case .invalidPaths: return "Source and destination are required." - case .missingRemoteHost: return "Remote host is required for an SSH job." - case .invalidRemoteDestination: return "Remote destination must be a path on the remote machine, for example /home/mag/backup or ~/backup — not the host address." - case .publicSSHKeySelected: return "The SSH key must be the private key (for example ~/.ssh/id_rsa or ~/.ssh/id_ed25519), not the .pub file." + case .alreadyRunning: + return "Another rsync job is already running." + case .securityValidation(let message): + return message + case .invalidExecutable(let path): + return "The rsync executable at \(path) is unavailable or is not a trusted regular executable file." } } } diff --git a/RsyncManager/SettingsView.swift b/RsyncManager/SettingsView.swift index b79f1b1..7227623 100644 --- a/RsyncManager/SettingsView.swift +++ b/RsyncManager/SettingsView.swift @@ -10,8 +10,7 @@ struct SettingsView: View { @AppStorage("backgroundSchedulesEnabled") private var backgroundSchedulesEnabled = true @AppStorage("fileWatchingEnabled") private var fileWatchingEnabled = true @AppStorage("defaultWatchDebounceSeconds") private var defaultWatchDebounceSeconds = 3 - @State private var defaultPassword = "" - @State private var passwordSaved = false + @AppStorage("historyRetentionDays") private var historyRetentionDays = 90 @State private var launchAtLogin = false @State private var launchError: String? @@ -30,18 +29,9 @@ struct SettingsView: View { } } - SecureField("Default password", text: $defaultPassword) - HStack { - Text("Stored securely in the macOS Keychain, never in SwiftData or UserDefaults.") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Button(passwordSaved ? "Saved" : "Save Password") { - KeychainStore.set(defaultPassword, for: "defaultSSHPassword") - passwordSaved = true - } - .disabled(defaultPassword.isEmpty) - } + Label("Authentication uses an SSH private key or your macOS SSH agent. Passwords are intentionally not stored or passed on the command line.", systemImage: "lock.shield") + .font(.caption) + .foregroundStyle(.secondary) } Section("New Jobs") { @@ -71,6 +61,13 @@ struct SettingsView: View { .foregroundStyle(.secondary) } + Section("History & Privacy") { + Stepper("Keep run history for \(historyRetentionDays) days", value: $historyRetentionDays, in: 7...3650, step: 7) + Text("Run output is sanitised, ANSI control sequences are removed, and individual stdout/stderr logs are capped at approximately 2 MB before storage.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("File Watching") { Toggle("Watch enabled jobs for changes", isOn: $fileWatchingEnabled) Stepper("Default debounce: \(defaultWatchDebounceSeconds) seconds", value: $defaultWatchDebounceSeconds, in: 1...60) @@ -91,7 +88,16 @@ struct SettingsView: View { Form { Section("Rsync") { LabeledContent("Detected executable", value: RsyncLocator.locate()) - Text("Rsync Manager prefers Homebrew rsync, then /usr/local/bin/rsync, and finally the version supplied with macOS.") + LabeledContent("Executable validation", value: RsyncLocator.isTrustedExecutableCandidate(RsyncLocator.locate()) ? "Passed" : "Failed") + Text("Only executable regular-file rsync candidates are accepted. Symlinks are resolved before validation.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Security") { + LabeledContent("SSH mode", value: "Batch / key or agent") + LabeledContent("Host-key verification", value: "System default") + Text("SSH connections use BatchMode, connection timeouts and keepalive limits. Rsync Manager never disables SSH host-key verification.") .font(.caption) .foregroundStyle(.secondary) } @@ -107,11 +113,9 @@ struct SettingsView: View { } .frame(width: 650, height: 455) .onAppear { - defaultPassword = KeychainStore.string(for: "defaultSSHPassword") - passwordSaved = !defaultPassword.isEmpty + KeychainStore.delete("defaultSSHPassword") launchAtLogin = SMAppService.mainApp.status == .enabled } - .onChange(of: defaultPassword) { _, _ in passwordSaved = false } .onChange(of: backgroundSchedulesEnabled) { _, _ in automation.refreshConfiguration() } .onChange(of: fileWatchingEnabled) { _, _ in automation.refreshConfiguration() } .alert("Launch at Login", isPresented: Binding( diff --git a/RsyncManager/Utilities/KeychainStore.swift b/RsyncManager/Utilities/KeychainStore.swift index e6275b1..46bf3f4 100644 --- a/RsyncManager/Utilities/KeychainStore.swift +++ b/RsyncManager/Utilities/KeychainStore.swift @@ -19,23 +19,37 @@ enum KeychainStore { return value } - static func set(_ value: String, for account: String) { + @discardableResult + static func set(_ value: String, for account: String) -> OSStatus { let base: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account ] - if value.isEmpty { - SecItemDelete(base as CFDictionary) - return - } + if value.isEmpty { return delete(account) } + let data = Data(value.utf8) - let update: [String: Any] = [kSecValueData as String: data] - if SecItemUpdate(base as CFDictionary, update as CFDictionary) == errSecItemNotFound { - var add = base - add[kSecValueData as String] = data - add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock - SecItemAdd(add as CFDictionary, nil) - } + let update: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked + ] + let updateStatus = SecItemUpdate(base as CFDictionary, update as CFDictionary) + if updateStatus != errSecItemNotFound { return updateStatus } + + var add = base + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked + return SecItemAdd(add as CFDictionary, nil) + } + + @discardableResult + static func delete(_ account: String) -> OSStatus { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecItemNotFound ? errSecSuccess : status } } diff --git a/RsyncManager/Views/Jobs/JobDetailView.swift b/RsyncManager/Views/Jobs/JobDetailView.swift index daf71c6..1345cd6 100644 --- a/RsyncManager/Views/Jobs/JobDetailView.swift +++ b/RsyncManager/Views/Jobs/JobDetailView.swift @@ -251,6 +251,10 @@ struct JobDetailView: View { Binding( get: { job.scheduleEnabled }, set: { enabled in + if enabled && job.deleteExtraneous && !job.allowUnattendedDelete { + errorMessage = "This job uses --delete. Edit the job and explicitly enable unattended delete before turning on scheduling." + return + } job.scheduleEnabled = enabled if enabled { if job.scheduleKind == .manual { job.scheduleKind = .daily } @@ -268,6 +272,10 @@ struct JobDetailView: View { Binding( get: { job.watchEnabled }, set: { enabled in + if enabled && job.deleteExtraneous && !job.allowUnattendedDelete { + errorMessage = "This job uses --delete. Edit the job and explicitly enable unattended delete before turning on file watching." + return + } job.watchEnabled = enabled if enabled && job.watchPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { job.watchPath = job.sourcePath @@ -284,8 +292,12 @@ struct JobDetailView: View { private func persistAutomationChange() { job.updatedAt = Date() - try? modelContext.save() - NotificationCenter.default.post(name: .syncJobsChanged, object: nil) + do { + try modelContext.save() + NotificationCenter.default.post(name: .syncJobsChanged, object: nil) + } catch { + errorMessage = "Could not save automation settings: \(error.localizedDescription)" + } } @ViewBuilder @@ -316,7 +328,12 @@ struct JobDetailView: View { let command = RsyncCommandBuilder.displayCommand(job: job, dryRun: dryRun, executable: executable) let run = SyncRun(job: job, command: command, wasDryRun: dryRun, trigger: .manual) modelContext.insert(run) - try? modelContext.save() + do { + try modelContext.save() + } catch { + errorMessage = "Could not create run history: \(error.localizedDescription)" + return + } if presentation.showsModal { presentedRun = run @@ -325,23 +342,25 @@ struct JobDetailView: View { Task { do { - let result = try await runner.run(job: job, dryRun: dryRun) - run.output = result.stdout - run.errorOutput = result.stderr + let result = try await runner.run(job: job, dryRun: dryRun, trigger: .manual) + run.output = SyncRun.sanitizedLog(result.stdout) + run.errorOutput = SyncRun.sanitizedLog(result.stderr) run.exitCode = Int(result.exitCode) run.wasCancelled = result.wasCancelled run.finishedAt = Date() job.lastRunAt = run.finishedAt job.lastExitCode = Int(result.exitCode) job.updatedAt = Date() - try? modelContext.save() + do { try modelContext.save() } + catch { errorMessage = "Could not save run history: \(error.localizedDescription)" } } catch { - run.errorOutput = error.localizedDescription + run.errorOutput = SyncRun.sanitizedLog(error.localizedDescription) run.exitCode = -1 run.finishedAt = Date() job.lastRunAt = run.finishedAt job.lastExitCode = -1 - try? modelContext.save() + do { try modelContext.save() } + catch { errorMessage = "Could not save failed run history: \(error.localizedDescription)" } if !presentation.showsModal { errorMessage = error.localizedDescription diff --git a/RsyncManager/Views/Jobs/JobEditorView.swift b/RsyncManager/Views/Jobs/JobEditorView.swift index 7e7b541..4d95d1a 100644 --- a/RsyncManager/Views/Jobs/JobEditorView.swift +++ b/RsyncManager/Views/Jobs/JobEditorView.swift @@ -38,6 +38,8 @@ struct JobEditorView: View { @State private var watchEnabled: Bool @State private var watchPath: String @State private var watchDebounceSeconds: Int + @State private var allowUnattendedDelete: Bool + @State private var validationMessage: String? init(job: SyncJob? = nil, initialSection: JobEditorSection? = nil) { self.existingJob = job @@ -68,6 +70,8 @@ struct JobEditorView: View { _watchPath = State(initialValue: job?.watchPath ?? "") let defaultDebounce = UserDefaults.standard.integer(forKey: "defaultWatchDebounceSeconds") _watchDebounceSeconds = State(initialValue: job?.watchDebounceSeconds ?? (defaultDebounce == 0 ? 3 : defaultDebounce)) + _allowUnattendedDelete = State(initialValue: job?.allowUnattendedDelete ?? false) + _validationMessage = State(initialValue: nil) } var body: some View { @@ -174,6 +178,12 @@ struct JobEditorView: View { Toggle("Archive mode (-a)", isOn: $archive) Toggle("Compress during transfer (-z)", isOn: $compress) Toggle("Delete files missing from source (--delete)", isOn: $deleteExtraneous) + if deleteExtraneous && (scheduleEnabled || watchEnabled) { + Toggle("Allow unattended delete", isOn: $allowUnattendedDelete) + Text("Required before scheduled or file-watch runs may use --delete. Enable only after validating the job with Dry Run.") + .font(.caption) + .foregroundStyle(.orange) + } Toggle("Preserve permissions", isOn: $preservePermissions) Toggle("Preserve extended attributes", isOn: $preserveExtendedAttributes) Toggle("Show transfer progress", isOn: $showProgress) @@ -191,6 +201,9 @@ struct JobEditorView: View { Section("Advanced") { TextField("Additional rsync arguments", text: $additionalArguments) .font(.system(.body, design: .monospaced)) + Text("Transport, password, remote-command and destructive options are blocked here. Use the dedicated controls above for SSH and --delete.") + .font(.caption) + .foregroundStyle(.secondary) } } .formStyle(.grouped) @@ -213,6 +226,14 @@ struct JobEditorView: View { } } } + .alert("Invalid Sync Job", isPresented: Binding( + get: { validationMessage != nil }, + set: { if !$0 { validationMessage = nil } } + )) { + Button("OK", role: .cancel) { validationMessage = nil } + } message: { + Text(validationMessage ?? "The job configuration is invalid.") + } .frame(width: 690, height: 820) } @@ -262,10 +283,22 @@ struct JobEditorView: View { job.watchEnabled = watchEnabled job.watchPath = watchPath job.watchDebounceSeconds = watchDebounceSeconds + job.allowUnattendedDelete = deleteExtraneous ? allowUnattendedDelete : false job.updatedAt = Date() - if existingJob == nil { modelContext.insert(job) } - try? modelContext.save() - NotificationCenter.default.post(name: .syncJobsChanged, object: nil) - dismiss() + + let validationTrigger: SyncRunTrigger = (job.scheduleEnabled || job.watchEnabled) ? .scheduled : .manual + if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: validationTrigger) { + validationMessage = message + return + } + + do { + if existingJob == nil { modelContext.insert(job) } + try modelContext.save() + NotificationCenter.default.post(name: .syncJobsChanged, object: nil) + dismiss() + } catch { + validationMessage = "Could not save the job: \(error.localizedDescription)" + } } } diff --git a/RsyncManager/Views/Jobs/JobListView.swift b/RsyncManager/Views/Jobs/JobListView.swift index d08525f..0431d89 100644 --- a/RsyncManager/Views/Jobs/JobListView.swift +++ b/RsyncManager/Views/Jobs/JobListView.swift @@ -9,6 +9,7 @@ struct JobListView: View { @Binding var showingNewJob: Bool @State private var searchText = "" @State private var filter: JobFilter = .all + @State private var persistenceError: String? var body: some View { VStack(spacing: 0) { @@ -50,6 +51,14 @@ struct JobListView: View { .listStyle(.sidebar) } } + .alert("Database Error", isPresented: Binding( + get: { persistenceError != nil }, + set: { if !$0 { persistenceError = nil } } + )) { + Button("OK", role: .cancel) { persistenceError = nil } + } message: { + Text(persistenceError ?? "Unable to save job changes.") + } } private var dashboardHeader: some View { @@ -198,6 +207,7 @@ struct JobListView: View { copy.watchEnabled = job.watchEnabled copy.watchPath = job.watchPath copy.watchDebounceSeconds = job.watchDebounceSeconds + copy.allowUnattendedDelete = job.allowUnattendedDelete modelContext.insert(copy) saveAndRefresh() selection = copy @@ -210,8 +220,12 @@ struct JobListView: View { } private func saveAndRefresh() { - try? modelContext.save() - NotificationCenter.default.post(name: .syncJobsChanged, object: nil) + do { + try modelContext.save() + NotificationCenter.default.post(name: .syncJobsChanged, object: nil) + } catch { + persistenceError = error.localizedDescription + } } } diff --git a/RsyncManager/Views/Runs/RunHistoryView.swift b/RsyncManager/Views/Runs/RunHistoryView.swift index a865c80..64db7a2 100644 --- a/RsyncManager/Views/Runs/RunHistoryView.swift +++ b/RsyncManager/Views/Runs/RunHistoryView.swift @@ -5,6 +5,7 @@ struct RunHistoryView: View { @Environment(\.modelContext) private var modelContext @Query(sort: \SyncRun.startedAt, order: .reverse) private var runs: [SyncRun] @State private var selection: SyncRun? + @State private var persistenceError: String? let onBack: () -> Void var body: some View { @@ -24,7 +25,7 @@ struct RunHistoryView: View { Button("Clear", role: .destructive) { runs.forEach(modelContext.delete) selection = nil - try? modelContext.save() + saveHistoryChanges() } .buttonStyle(.bordered) .controlSize(.small) @@ -79,7 +80,7 @@ struct RunHistoryView: View { Button("Delete Log", role: .destructive) { if selection?.id == run.id { selection = nil } modelContext.delete(run) - try? modelContext.save() + saveHistoryChanges() } } } @@ -103,6 +104,19 @@ struct RunHistoryView: View { .frame(minWidth: 620, maxWidth: .infinity, maxHeight: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .alert("Database Error", isPresented: Binding( + get: { persistenceError != nil }, + set: { if !$0 { persistenceError = nil } } + )) { + Button("OK", role: .cancel) { persistenceError = nil } + } message: { + Text(persistenceError ?? "Unable to update run history.") + } + } + + private func saveHistoryChanges() { + do { try modelContext.save() } + catch { persistenceError = error.localizedDescription } } private func statusIcon(_ run: SyncRun) -> String { diff --git a/Scripts/release-preflight.sh b/Scripts/release-preflight.sh new file mode 100755 index 0000000..7c644c7 --- /dev/null +++ b/Scripts/release-preflight.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT="$ROOT/RsyncManager.xcodeproj" +SCHEME="RsyncManager" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "release-preflight.sh must run on macOS with Xcode installed." >&2 + exit 2 +fi + +for tool in xcodebuild plutil codesign spctl; do + command -v "$tool" >/dev/null || { echo "Missing required tool: $tool" >&2; exit 2; } +done + +"$SCRIPT_DIR/security-check.sh" +plutil -lint "$ROOT/RsyncManager/Resources/PrivacyInfo.xcprivacy" + +BUILD_DIR="$ROOT/build/preflight" +rm -rf "$BUILD_DIR" + +xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration Release \ + -destination 'platform=macOS' \ + -derivedDataPath "$BUILD_DIR" \ + CODE_SIGNING_ALLOWED=NO \ + clean build + +APP="$BUILD_DIR/Build/Products/Release/RsyncManager.app" +test -d "$APP" || { echo "Release app was not produced at $APP" >&2; exit 1; } + +echo "Unsigned Release build passed: $APP" +echo "Next: Archive with Developer ID, notarise, staple, then run the signed-app checks in Docs/RELEASE.md." diff --git a/Scripts/security-check.sh b/Scripts/security-check.sh new file mode 100755 index 0000000..4302ec3 --- /dev/null +++ b/Scripts/security-check.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT="$ROOT/RsyncManager.xcodeproj" + +echo "== Rsync Manager project security checks ==" +grep -q 'ENABLE_HARDENED_RUNTIME = YES' "$PROJECT/project.pbxproj" +grep -q 'ENABLE_APP_SANDBOX = NO' "$PROJECT/project.pbxproj" +grep -q 'INFOPLIST_KEY_CFBundleIconName = AppIcon' "$PROJECT/project.pbxproj" +test -f "$ROOT/RsyncManager/Resources/PrivacyInfo.xcprivacy" + +if grep -R --line-number -E 'StrictHostKeyChecking[ =]+no|UserKnownHostsFile[ =]+/dev/null|sshpass' "$ROOT/RsyncManager" --include='*.swift'; then + echo "Unsafe SSH configuration detected." >&2 + exit 1 +fi + +grep -q 'BatchMode=yes' "$ROOT/RsyncManager/Services/RsyncCommandBuilder.swift" +grep -q 'allowUnattendedDelete' "$ROOT/RsyncManager/Models/SyncJob.swift" +grep -q 'RsyncSecurityValidator' "$ROOT/RsyncManager/Services/RsyncCommandBuilder.swift" +grep -q 'isSafeRemotePath' "$ROOT/RsyncManager/Services/RsyncCommandBuilder.swift" +grep -q 'hasBalancedShellSyntax' "$ROOT/RsyncManager/Services/RsyncCommandBuilder.swift" +grep -q -- '--files-from' "$ROOT/RsyncManager/Services/RsyncCommandBuilder.swift" +grep -q -- '--log-file' "$ROOT/RsyncManager/Services/RsyncCommandBuilder.swift" + +echo "Static policy checks passed." +echo "" +echo "After creating a signed Release app, also run:" +echo " codesign --verify --deep --strict --verbose=2 /path/to/RsyncManager.app" +echo " spctl --assess --type execute --verbose=4 /path/to/RsyncManager.app" +echo " xcrun stapler validate /path/to/RsyncManager.app"