Files

211 lines
7.7 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Darwin
import XCTest
@testable import ShadowsocksX_NG2
/// 端口语义主缝测试:出厂默认、占用校验、建议端口
/// 算法与点名错误。bind 探测覆盖真实回环 socket;lsof 输出解析为纯函数夹具。
final class ProxyPortSemanticsTests: XCTestCase {
// MARK: 出厂默认(与 Legacy 隔离,任何路径不静默改端口)
func testFactoryDefaultsAvoidLegacyPortsAndValidateClean() {
let settings = ProxyListeningConfiguration()
XCTAssertEqual(settings.socksPort, 11086)
XCTAssertEqual(settings.httpPort, 11087)
XCTAssertTrue(settings.portValidationErrors().isEmpty)
}
// MARK: 配置校验与点名错误
func testOutOfRangePortsAreRejectedNamingEndpointAndPort() {
var settings = ProxyListeningConfiguration()
settings.socksPort = 0
settings.httpPort = 65536
XCTAssertEqual(
settings.portValidationErrors(),
[
.portOutOfRange(endpoint: .socks, port: 0),
.portOutOfRange(endpoint: .http, port: 65536),
])
}
func testDuplicateConfiguredPortsAreRejectedNamingBothEndpoints() {
let settings = ProxyListeningConfiguration(
socksPort: 1086,
httpPort: 1086)
XCTAssertEqual(
settings.portValidationErrors(),
[.duplicatePort(endpoint: .socks, otherEndpoint: .http, port: 1086)],
"两个端点的配置值都必须互异")
}
func testPresentationNamesEndpointAndPort() {
XCTAssertEqual(
AppPresentation.message(
for: PortSettingError.portOutOfRange(endpoint: .http, port: 70000)),
"本地 HTTP 代理 端口 70000 无效,必须是 1025–65535 之间的整数")
XCTAssertEqual(
AppPresentation.message(
for: PortSettingError.duplicatePort(
endpoint: .socks, otherEndpoint: .http, port: 1086)),
"本地 SOCKS5 代理 端口与 本地 HTTP 代理 端口冲突(都是 1086),请为每个端点配置不同的端口")
}
// MARK: 建议端口算法(32768–65535 高位段,空闲且与另两端点配置值互异)
func testSuggestionScansHighRangeAscendingSkippingExcludedAndOccupied() {
var probed: [Int] = []
let suggested = PortSuggestion.firstFree(
excluding: [32768],
isFree: { port in
probed.append(port)
return port != 32769
})
XCTAssertEqual(suggested, 32770)
XCTAssertEqual(probed, [32769, 32770], "被排除的 32768 不探测;占用端口跳过")
}
func testSuggestionReturnsNilWhenWholeRangeExhausted() {
XCTAssertNil(PortSuggestion.firstFree(excluding: [], isFree: { _ in false }))
}
func testSuggestedPortForEndpointExcludesOtherConfiguredValues() {
let settings = ProxyListeningConfiguration(
socksPort: 40000, httpPort: 40001)
var probed: [Int] = []
let suggested = settings.suggestedPort(for: .http) { port in
probed.append(port)
return port == 40002
}
XCTAssertEqual(suggested, 40002)
XCTAssertFalse(probed.contains(40000), "另个端点的配置值无论启用与否都排除")
}
// MARK: 占用探测(设置区即时校验的 I/O 缝;激活仍以 runtime 实际绑定的
// 健康门禁为准,本探测只是编辑期尽力而为的提示)
func testBindProbeReportsOccupiedThenFreeOnRealLoopbackSocket() throws {
let held = try ListenerFixture()
let port = held.port
let probe = SystemPortOccupancyProbe()
guard case .occupied = probe.occupancy(port: port, listenerMode: .disabled) else {
return XCTFail("已被本测试进程监听的端口必须判为占用")
}
held.close()
guard case .free = probe.occupancy(port: port, listenerMode: .disabled) else {
return XCTFail("关闭监听后同一端口必须判为空闲")
}
}
func testBindProbeReportsUnknownWhenBindIsNotPermitted() async throws {
try XCTSkipIf(getuid() == 0, "root 可绑定特权端口,无法用 EACCES 构造 .unknown")
// 特权端口:非 root 进程 bind 失败(EACCES)≠ 占用,也不得冒充空闲。
let request = PortOccupancyProbeRequest(
endpoint: .socks,
listen: RuntimeListenFacts(listen: ProxyListeningConfiguration()),
port: 80)
let outcome = await SystemPortOccupancyProbe().occupancy(for: request)
guard case .unknown = outcome else {
return XCTFail("绑定失败时必须报 .unknown,不得冒充空闲或占用,实际 \(outcome)")
}
}
func testIPv6OnlyBindCanShareAnIPv4PortWhileDualStackCannot() throws {
let held = try ListenerFixture(listenerMode: .allIPv4Interfaces)
defer { held.close() }
let probe = SystemPortOccupancyProbe()
guard case .free = probe.occupancy(port: held.port, listenerMode: .allIPv6Interfaces) else {
return XCTFail("IPv6-only 探测不应与 IPv4 通配监听冲突")
}
guard
case .occupied = probe.occupancy(
port: held.port, listenerMode: .allIPv4AndIPv6Interfaces)
else {
return XCTFail("双栈探测必须覆盖 IPv4,因而与 IPv4 通配监听冲突")
}
}
func testOccupierNameParsedFromLsofOutputFixture() {
let output = """
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
sslocal 12345 qiu 7u IPv4 0xabcd 0t0 TCP 127.0.0.1:1086 (LISTEN)
TestApp 12346 qiu 8u IPv4 0xabce 0t0 TCP 127.0.0.1:1086 (LISTEN)
"""
XCTAssertEqual(SystemPortOccupancyProbe.occupierProcessName(fromLsofOutput: output), "sslocal")
XCTAssertEqual(
SystemPortOccupancyProbe.occupierProcessIDs(fromLsofOutput: output), [12345, 12346])
XCTAssertEqual(
SystemPortOccupancyProbe.lsofArguments(port: 1086, addressFamily: .ipv4),
["-nP", "-i4TCP:1086", "-sTCP:LISTEN"])
XCTAssertEqual(
SystemPortOccupancyProbe.lsofArguments(port: 1086, addressFamily: .ipv6),
["-nP", "-i6TCP:1086", "-sTCP:LISTEN"])
XCTAssertNil(SystemPortOccupancyProbe.occupierProcessName(fromLsofOutput: " \n"))
XCTAssertNil(
SystemPortOccupancyProbe.occupierProcessName(fromLsofOutput: "COMMAND PID USER FD"),
"只有表头(无占用进程)时解析为 nil")
}
}
/// 测试专用回环监听器:占用一个内核分配端口,供占用探测断言真实占用。
private final class ListenerFixture {
let descriptor: Int32
let port: Int
init(listenerMode: ListenerMode = .disabled) throws {
let listenerFD = socket(AF_INET, SOCK_STREAM, 0)
guard listenerFD >= 0 else { throw POSIXError(.ENOTSOCK) }
var address = sockaddr_in()
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
address.sin_family = sa_family_t(AF_INET)
address.sin_port = 0
guard inet_pton(AF_INET, listenerMode.bindAddress, &address.sin_addr) == 1 else {
Darwin.close(listenerFD)
throw POSIXError(.EADDRNOTAVAIL)
}
let bindResult = withUnsafePointer(to: &address) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
bind(listenerFD, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard bindResult == 0 else {
let error = errno
Darwin.close(listenerFD)
throw POSIXError(POSIXErrorCode(rawValue: error)!)
}
guard listen(listenerFD, 1) == 0 else {
let error = errno
Darwin.close(listenerFD)
throw POSIXError(POSIXErrorCode(rawValue: error)!)
}
var bound = sockaddr_in()
var length = socklen_t(MemoryLayout<sockaddr_in>.size)
let nameResult = withUnsafeMutablePointer(to: &bound) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
getsockname(listenerFD, $0, &length)
}
}
guard nameResult == 0 else {
let error = errno
Darwin.close(listenerFD)
throw POSIXError(POSIXErrorCode(rawValue: error)!)
}
descriptor = listenerFD
port = Int(CFSwapInt16BigToHost(bound.sin_port))
}
func close() {
Darwin.close(descriptor)
}
}