diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69ec530..bfd54bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: path: | .build ~/Library/Caches/org.swift.swiftpm - key: spm-${{ runner.os }}-${{ hashFiles('Package.resolved') }} + key: spm-${{ runner.os }}-${{ hashFiles('Package.swift', 'Package.resolved') }} restore-keys: spm-${{ runner.os }}- - run: swift test --enable-code-coverage - name: Convert coverage diff --git a/Makefile b/Makefile index 45a13c2..42b1b22 100644 --- a/Makefile +++ b/Makefile @@ -19,13 +19,13 @@ build-helper: @swiftc -O \ -target arm64-apple-macos13.0 \ -o $(HELPER_BUILD_DIR)/$(HELPER_ID)-arm64 \ - Sources/HelperProtocol.swift \ + Sources/VPNBypassCore/HelperProtocol.swift \ Helper/HelperTool.swift \ Helper/main.swift @swiftc -O \ -target x86_64-apple-macos13.0 \ -o $(HELPER_BUILD_DIR)/$(HELPER_ID)-x86_64 \ - Sources/HelperProtocol.swift \ + Sources/VPNBypassCore/HelperProtocol.swift \ Helper/HelperTool.swift \ Helper/main.swift @lipo -create \ @@ -61,7 +61,7 @@ bundle: build build-helper @cp assets/menubar-icon-error.png "$(APP_BUNDLE)/Contents/Resources/" @cp assets/menubar-icon-error@2x.png "$(APP_BUNDLE)/Contents/Resources/" @# Copy localizations - @cp -R Resources/*.lproj "$(APP_BUNDLE)/Contents/Resources/" + @cp -R Sources/VPNBypassCore/Resources/*.lproj "$(APP_BUNDLE)/Contents/Resources/" @echo "App bundle created: $(APP_BUNDLE)" clean: diff --git a/Package.swift b/Package.swift index e50dc6f..5a51869 100644 --- a/Package.swift +++ b/Package.swift @@ -12,26 +12,23 @@ let package = Package( ], dependencies: [], targets: [ - .executableTarget( - name: "VPNBypass", + .target( + name: "VPNBypassCore", dependencies: [], - path: ".", - exclude: [ - "AGENTS.md", "Casks", "Helper", "Info.plist", "LICENSE", - "Makefile", "README.md", "ROADMAP.md", "SECURITY.md", - "VPN Bypass.app", "VPNBypass.entitlements", "assets", - "dist", "docs", "scripts", "Tests" - ], - sources: ["Sources"], + path: "Sources/VPNBypassCore", resources: [ .process("Resources") ] ), + .executableTarget( + name: "VPNBypass", + dependencies: ["VPNBypassCore"], + path: "Sources/VPNBypass" + ), .testTarget( name: "VPNBypassTests", - dependencies: [], - path: "Tests/VPNBypassTests", - sources: ["VPNBypassTests.swift"] + dependencies: ["VPNBypassCore"], + path: "Tests/VPNBypassTests" ) ] ) diff --git a/Sources/VPNBypass/main.swift b/Sources/VPNBypass/main.swift new file mode 100644 index 0000000..3665ce0 --- /dev/null +++ b/Sources/VPNBypass/main.swift @@ -0,0 +1,3 @@ +import VPNBypassCore + +VPNBypassApp.main() diff --git a/Sources/ColorExtension.swift b/Sources/VPNBypassCore/ColorExtension.swift similarity index 100% rename from Sources/ColorExtension.swift rename to Sources/VPNBypassCore/ColorExtension.swift diff --git a/Sources/HelperManager.swift b/Sources/VPNBypassCore/HelperManager.swift similarity index 100% rename from Sources/HelperManager.swift rename to Sources/VPNBypassCore/HelperManager.swift diff --git a/Sources/HelperProtocol.swift b/Sources/VPNBypassCore/HelperProtocol.swift similarity index 100% rename from Sources/HelperProtocol.swift rename to Sources/VPNBypassCore/HelperProtocol.swift diff --git a/Sources/LaunchAtLoginManager.swift b/Sources/VPNBypassCore/LaunchAtLoginManager.swift similarity index 100% rename from Sources/LaunchAtLoginManager.swift rename to Sources/VPNBypassCore/LaunchAtLoginManager.swift diff --git a/Sources/MenuBarViews.swift b/Sources/VPNBypassCore/MenuBarViews.swift similarity index 100% rename from Sources/MenuBarViews.swift rename to Sources/VPNBypassCore/MenuBarViews.swift diff --git a/Sources/NotificationManager.swift b/Sources/VPNBypassCore/NotificationManager.swift similarity index 100% rename from Sources/NotificationManager.swift rename to Sources/VPNBypassCore/NotificationManager.swift diff --git a/Resources/en.lproj/Localizable.strings b/Sources/VPNBypassCore/Resources/en.lproj/Localizable.strings similarity index 100% rename from Resources/en.lproj/Localizable.strings rename to Sources/VPNBypassCore/Resources/en.lproj/Localizable.strings diff --git a/Resources/es.lproj/Localizable.strings b/Sources/VPNBypassCore/Resources/es.lproj/Localizable.strings similarity index 100% rename from Resources/es.lproj/Localizable.strings rename to Sources/VPNBypassCore/Resources/es.lproj/Localizable.strings diff --git a/Resources/fr.lproj/Localizable.strings b/Sources/VPNBypassCore/Resources/fr.lproj/Localizable.strings similarity index 100% rename from Resources/fr.lproj/Localizable.strings rename to Sources/VPNBypassCore/Resources/fr.lproj/Localizable.strings diff --git a/Sources/RouteManager.swift b/Sources/VPNBypassCore/RouteManager.swift similarity index 99% rename from Sources/RouteManager.swift rename to Sources/VPNBypassCore/RouteManager.swift index 7abaabd..4d083a5 100644 --- a/Sources/RouteManager.swift +++ b/Sources/VPNBypassCore/RouteManager.swift @@ -3656,12 +3656,7 @@ final class RouteManager: ObservableObject { } } - /// Public domain normalization for custom service editor - func cleanDomainForService(_ input: String) -> String { - return cleanDomain(input) - } - - private func cleanDomain(_ input: String) -> String { + nonisolated func cleanDomain(_ input: String) -> String { var domain = input.trimmingCharacters(in: .whitespacesAndNewlines) // Remove any protocol scheme (http, https, ssh, ftp, etc.) using regex @@ -3694,7 +3689,7 @@ final class RouteManager: ObservableObject { return domain.lowercased() } - private func isValidIP(_ string: String) -> Bool { + nonisolated func isValidIP(_ string: String) -> Bool { let parts = string.components(separatedBy: ".") guard parts.count == 4 else { return false } return parts.allSatisfy { @@ -3706,7 +3701,7 @@ final class RouteManager: ObservableObject { /// Validate CIDR notation (e.g., "192.168.1.0/24") /// Rejects /0 which would conflict with VPN Only catch-all routes. - private func isValidCIDR(_ string: String) -> Bool { + nonisolated func isValidCIDR(_ string: String) -> Bool { let parts = string.components(separatedBy: "/") guard parts.count == 2, isValidIP(parts[0]), diff --git a/Sources/SettingsView.swift b/Sources/VPNBypassCore/SettingsView.swift similarity index 99% rename from Sources/SettingsView.swift rename to Sources/VPNBypassCore/SettingsView.swift index 79e4b58..c3da425 100644 --- a/Sources/SettingsView.swift +++ b/Sources/VPNBypassCore/SettingsView.swift @@ -974,7 +974,7 @@ struct CustomServiceEditor: View { private func save() { // Normalize domains the same way the main domain input does (strips protocols, ports, paths, invalid chars) let cleanDomains = domains - .map { routeManager.cleanDomainForService($0) } + .map { routeManager.cleanDomain($0) } .filter { !$0.isEmpty } let cleanIPs = ipRanges.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } let name = serviceName.trimmingCharacters(in: .whitespaces) diff --git a/Sources/Theme.swift b/Sources/VPNBypassCore/Theme.swift similarity index 100% rename from Sources/Theme.swift rename to Sources/VPNBypassCore/Theme.swift diff --git a/Sources/VPNBypassApp.swift b/Sources/VPNBypassCore/VPNBypassApp.swift similarity index 99% rename from Sources/VPNBypassApp.swift rename to Sources/VPNBypassCore/VPNBypassApp.swift index cf6426f..ca60fea 100644 --- a/Sources/VPNBypassApp.swift +++ b/Sources/VPNBypassCore/VPNBypassApp.swift @@ -6,14 +6,15 @@ import SwiftUI import Network import UserNotifications -@main -struct VPNBypassApp: App { +public struct VPNBypassApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var routeManager = RouteManager.shared @StateObject private var notificationManager = NotificationManager.shared @StateObject private var launchAtLoginManager = LaunchAtLoginManager.shared - - var body: some Scene { + + public init() {} + + public var body: some Scene { MenuBarExtra { MenuContent() .environmentObject(routeManager) diff --git a/Tests/VPNBypassTests/CleanDomainTests.swift b/Tests/VPNBypassTests/CleanDomainTests.swift new file mode 100644 index 0000000..48bb592 --- /dev/null +++ b/Tests/VPNBypassTests/CleanDomainTests.swift @@ -0,0 +1,358 @@ +import XCTest +@testable import VPNBypassCore + +final class CleanDomainTests: XCTestCase { + private let rm = RouteManager.shared + + // MARK: - Basic Passthrough + + func testBasicDomainPassthrough() { + XCTAssertEqual(rm.cleanDomain("example.com"), "example.com") + } + + func testAlreadyCleanDomain() { + XCTAssertEqual(rm.cleanDomain("telegram.org"), "telegram.org") + } + + func testSubdomainPreservation() { + XCTAssertEqual(rm.cleanDomain("sub.domain.example.com"), "sub.domain.example.com") + } + + func testHyphenatedDomain() { + XCTAssertEqual(rm.cleanDomain("my-domain.com"), "my-domain.com") + } + + func testNumericDomain() { + XCTAssertEqual(rm.cleanDomain("123.com"), "123.com") + } + + func testSingleLabelDomain() { + XCTAssertEqual(rm.cleanDomain("localhost"), "localhost") + } + + // MARK: - Case Normalization + + func testCaseNormalization() { + XCTAssertEqual(rm.cleanDomain("Example.COM"), "example.com") + } + + func testMixedCaseSubdomain() { + XCTAssertEqual(rm.cleanDomain("WWW.Example.Org"), "www.example.org") + } + + // MARK: - Protocol Stripping + + func testHTTPSStripping() { + XCTAssertEqual(rm.cleanDomain("https://example.com"), "example.com") + } + + func testHTTPStripping() { + XCTAssertEqual(rm.cleanDomain("http://example.com"), "example.com") + } + + func testFTPStripping() { + XCTAssertEqual(rm.cleanDomain("ftp://files.example.com"), "files.example.com") + } + + func testSSHStripping() { + XCTAssertEqual(rm.cleanDomain("ssh://server.example.com"), "server.example.com") + } + + func testCustomSchemeStripping() { + XCTAssertEqual(rm.cleanDomain("custom+scheme://example.com"), "example.com") + } + + func testProtocolWithNumbers() { + XCTAssertEqual(rm.cleanDomain("h2c://example.com"), "example.com") + } + + func testProtocolWithDotsAndHyphens() { + XCTAssertEqual(rm.cleanDomain("coap+tcp://example.com"), "example.com") + } + + // MARK: - Userinfo Removal + + func testUserinfoRemoval() { + XCTAssertEqual(rm.cleanDomain("user:pass@example.com"), "example.com") + } + + func testUserinfoWithScheme() { + XCTAssertEqual(rm.cleanDomain("https://user:pass@example.com"), "example.com") + } + + func testUsernameOnlyRemoval() { + XCTAssertEqual(rm.cleanDomain("admin@example.com"), "example.com") + } + + // MARK: - Port Removal + + func testPort443Removal() { + XCTAssertEqual(rm.cleanDomain("example.com:443"), "example.com") + } + + func testPort8080Removal() { + XCTAssertEqual(rm.cleanDomain("example.com:8080"), "example.com") + } + + func testPortWithScheme() { + XCTAssertEqual(rm.cleanDomain("https://example.com:443"), "example.com") + } + + // MARK: - Path Removal + + func testPathRemoval() { + XCTAssertEqual(rm.cleanDomain("example.com/path/to/page"), "example.com") + } + + func testPathWithScheme() { + XCTAssertEqual(rm.cleanDomain("https://example.com/path"), "example.com") + } + + func testTrailingSlash() { + XCTAssertEqual(rm.cleanDomain("example.com/"), "example.com") + } + + // MARK: - Query String Removal + + func testQueryStringRemoval() { + XCTAssertEqual(rm.cleanDomain("example.com?q=search"), "example.com") + } + + func testQueryStringWithPath() { + XCTAssertEqual(rm.cleanDomain("example.com/page?q=1&lang=en"), "example.com") + } + + // MARK: - Fragment Removal (via allowed char filter) + + func testFragmentRemoval() { + // '#' is not in the allowed character set, so it and everything after gets filtered + // but the fragment chars that are alphanumeric will remain joined to the domain + XCTAssertEqual(rm.cleanDomain("example.com#section"), "example.comsection") + } + + // MARK: - Combined URL Components + + func testFullURLCombined() { + // https://user:pass@Example.COM:8080/path?q=1#frag + // 1. trim: no change + // 2. scheme strip: user:pass@Example.COM:8080/path?q=1#frag + // 3. userinfo (@): Example.COM:8080/path?q=1#frag + // 4. port (:): Example.COM + // 5. path (/): no slash left + // 6. query (?): no ? left + // 7. filter: Example.COM (all valid) + // 8. lowercase: example.com + XCTAssertEqual(rm.cleanDomain("https://user:pass@Example.COM:8080/path?q=1#frag"), "example.com") + } + + func testSchemeUserinfoPortPath() { + XCTAssertEqual(rm.cleanDomain("ftp://anonymous:@files.example.com:21/pub"), "files.example.com") + } + + // MARK: - Whitespace Handling + + func testLeadingTrailingWhitespace() { + XCTAssertEqual(rm.cleanDomain(" example.com "), "example.com") + } + + func testTabsAndNewlines() { + XCTAssertEqual(rm.cleanDomain("\t example.com \n"), "example.com") + } + + func testWhitespaceOnly() { + XCTAssertEqual(rm.cleanDomain(" "), "") + } + + // MARK: - Empty and Minimal Inputs + + func testEmptyString() { + XCTAssertEqual(rm.cleanDomain(""), "") + } + + func testOnlyProtocol() { + // "https://" -> scheme strip leaves "" -> result "" + XCTAssertEqual(rm.cleanDomain("https://"), "") + } + + func testOnlyProtocolWithTrailingSlash() { + XCTAssertEqual(rm.cleanDomain("http:///"), "") + } + + // MARK: - Invalid / Special Character Filtering + + func testInvalidCharactersStripped() { + // semicolon and space are not in allowed set [alphanumerics + .-] + XCTAssertEqual(rm.cleanDomain("example.com;rm -rf"), "example.comrm-rf") + } + + func testShellInjectionAttempt() { + // $, (, ) are stripped; alphanumerics remain + XCTAssertEqual(rm.cleanDomain("example.com$(whoami)"), "example.comwhoami") + } + + func testBacktickInjection() { + // backticks are stripped + XCTAssertEqual(rm.cleanDomain("example.com`ls`"), "example.comls") + } + + func testPipeInjection() { + // Path removal truncates at first '/', leaving "example.com|cat " + // Filter strips '|' and space + XCTAssertEqual(rm.cleanDomain("example.com|cat /etc/passwd"), "example.comcat") + } + + func testAngleBracketsStripped() { + XCTAssertEqual(rm.cleanDomain("example.com