Files

270 lines
12 KiB
Swift

import Foundation
import Testing
import XCTest
@testable import ShadowsocksX_NG2
/// 自定义规则持久化:往返、缺失文件、损坏与诊断摘要。
final class CustomRuleStoreTests: XCTestCase {
private var directory: URL!
private var store: CustomRuleStore!
override func setUpWithError() throws {
try super.setUpWithError()
directory = FileManager.default.temporaryDirectory
.appendingPathComponent("ssxng-custom-rules-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
store = CustomRuleStore(fileURL: directory.appendingPathComponent("custom-rules.json"))
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: directory)
try super.tearDownWithError()
}
func testSchemaOneMigratesAndDisablementRoundTripsInSameDocument() throws {
let rule = CustomRule(action: .direct, match: try RuleMatch(domainExact: "private.example"))
let legacy = Data(
"""
{"schemaVersion":1,"rules":[{"id":"\(rule.id.uuidString)","action":"direct",
"match":{"kind":"domainExact","value":"private.example"},
"source":{"kind":"custom","upstreamVersion":"user","label":"自定义"}}]}
""".utf8)
try legacy.write(to: store.fileURL)
let migrated = try store.loadDocument()
XCTAssertEqual(migrated.rules, [rule])
XCTAssertTrue(migrated.disabledIdentities.isEmpty)
try store.saveDocument(
CustomRuleDocument(rules: migrated.rules, disabledIdentities: [rule.identity]))
let saved = try store.loadDocument()
XCTAssertEqual(saved.disabledIdentities, [rule.identity])
XCTAssertNotEqual(
try store.summary().contentVersion, CustomRuleSummary.summarizing([rule]).contentVersion)
}
func testMissingFileLoadsAsEmpty() throws {
XCTAssertEqual(try store.load(), [])
}
func testRoundTripPersistsActionMatchAndUUID() throws {
let match = try RuleMatch(domainSuffix: "example.com")
let rule = CustomRule(action: .direct, match: match)
try store.save([rule])
let loaded = try store.load()
XCTAssertEqual(loaded.count, 1)
XCTAssertEqual(loaded[0].id, rule.id)
XCTAssertEqual(loaded[0].action, .direct)
XCTAssertEqual(loaded[0].match, match)
}
func testRoundTripKeepsMultipleRuleKinds() throws {
let rules = [
CustomRule(action: .proxy, match: try RuleMatch(domainExact: "a.example.com")),
CustomRule(action: .direct, match: try RuleMatch(ipv4CIDR: "203.0.113.0/24")),
CustomRule(action: .proxy, match: try RuleMatch(ipv6CIDR: "2001:db8::/32")),
]
try store.save(rules)
XCTAssertEqual(try store.load(), rules)
}
func testStoredMatchIsNormalizedWithoutChangingCustomIdentity() throws {
let rule = CustomRule(action: .direct, match: try RuleMatch(ipv4CIDR: "203.0.113.15/24"))
try store.save([rule])
let loaded = try XCTUnwrap(store.load().first)
XCTAssertEqual(loaded.id, rule.id)
XCTAssertEqual(loaded.match, try RuleMatch(ipv4CIDR: "203.0.113.0/24"))
XCTAssertEqual(loaded.identity, rule.identity)
}
func testMalformedStoredCIDRReportsCorruptionWithoutRewritingDocument() throws {
for kind in ["ipv4CIDR", "ipv6CIDR"] {
try Data(
"""
{"schemaVersion":3,"rules":[{"id":"A22CD040-4788-41CC-BA73-AF296C7FE410",
"action":"direct","match":{"kind":"\(kind)","value":"/"}}],"disabledIdentities":[]}
""".utf8
).write(to: store.fileURL)
let before = try Data(contentsOf: store.fileURL)
XCTAssertThrowsError(try store.load()) { error in
guard case .corrupt = error as? CustomRuleStoreError else {
return XCTFail("Expected corrupt document, got \(error)")
}
}
XCTAssertEqual(try Data(contentsOf: store.fileURL), before)
}
}
func testCorruptFileThrowsCorrupt() throws {
try Data("not-json".utf8).write(to: store.fileURL)
XCTAssertThrowsError(try store.load()) { error in
guard case .corrupt = error as? CustomRuleStoreError else {
return XCTFail("应点名 corrupt,实际 \(error)")
}
}
}
func testSchemaVersionMismatchIsRejected() throws {
let payload = """
{"schemaVersion": 99, "rules": []}
"""
try Data(payload.utf8).write(to: store.fileURL)
XCTAssertThrowsError(try store.load()) { error in
guard case .schemaVersionMismatch(let found, let expected)? = error as? CustomRuleStoreError
else {
return XCTFail("应点名 schema 版本不匹配,实际 \(error)")
}
XCTAssertEqual(found, 99)
XCTAssertEqual(expected, CustomRuleStore.currentSchemaVersion)
}
}
func testSummaryExposesCountAndStableContentVersionWithoutRawDomains() throws {
let rules = [
CustomRule(action: .proxy, match: try RuleMatch(domainSuffix: "secret-internal.example")),
CustomRule(action: .direct, match: try RuleMatch(ipv4CIDR: "203.0.113.0/24")),
]
try store.save(rules)
let summary = try store.summary()
XCTAssertEqual(summary.count, 2)
XCTAssertEqual(summary.contentVersion.count, 12)
XCTAssertFalse(summary.contentVersion.contains("secret"))
// 重排不改内容版本。
let reordered = CustomRuleSummary.summarizing(Array(rules.reversed()))
XCTAssertEqual(reordered.contentVersion, summary.contentVersion)
// 内容变化改版本。
let changed = CustomRuleSummary.summarizing([
CustomRule(action: .proxy, match: try RuleMatch(domainSuffix: "other.example"))
])
XCTAssertNotEqual(changed.contentVersion, summary.contentVersion)
}
}
struct CustomRuleStorageMigrationTests {
@Test(arguments: RulePreset.allCases)
func migrationRemovesDefaultActionRulesAndPreservesRemainingIntent(_ preset: RulePreset) throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let store = CustomRuleStore(
preset: preset, baseURL: directory.appendingPathComponent("rules.json"))
try FileManager.default.createDirectory(
at: store.fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
let action: RuleAction = preset == .chinaDirect ? .direct : .proxy
let first = CustomRule(action: action, match: try RuleMatch(domainExact: "first.example"))
let removed = CustomRule(
action: preset == .chinaDirect ? .proxy : .direct,
match: try RuleMatch(ipv4CIDR: "203.0.113.8/32"))
let last = CustomRule(action: action, match: try RuleMatch(domainSuffix: "last.example"))
let disabled: Set<ProxyRule> = [first.identity, removed.identity]
let document = CustomRuleDocument(rules: [first, removed, last], disabledIdentities: disabled)
var json = try #require(
JSONSerialization.jsonObject(with: CustomRuleStore.encode(document)) as? [String: Any])
json["schemaVersion"] = 3
let legacy = try JSONSerialization.data(withJSONObject: json)
try legacy.write(to: store.fileURL)
let loaded = try store.loadDocument()
#expect(loaded.rules == [first, last])
#expect(loaded.disabledIdentities == disabled)
#expect(try store.loadDocument() == loaded)
#expect(try Data(contentsOf: store.fileURL) == legacy)
try store.saveDocument(loaded)
#expect(try store.loadDocument() == loaded)
let saved = try #require(
JSONSerialization.jsonObject(with: Data(contentsOf: store.fileURL)) as? [String: Any])
#expect(saved["schemaVersion"] as? Int == 4)
}
@Test func legacySourceIsDiscardedOnNextSaveWithoutLosingSavedIntent() throws {
let data = Data(
"""
{"schemaVersion":2,"rules":[
{"id":"A22CD040-4788-41CC-BA73-AF296C7FE410","action":"direct",
"match":{"kind":"ipv4CIDR","value":"203.0.113.15/24"},
"source":{"kind":"custom","upstreamVersion":"imported","label":"Old import"}}
],"disabledIdentities":[
{"action":"proxy","match":{"kind":"domainExact","value":"orphan.example"}}
]}
""".utf8)
let document = try CustomRuleStore.decode(data)
#expect(document.rules.first?.id.uuidString == "A22CD040-4788-41CC-BA73-AF296C7FE410")
#expect(document.disabledIdentities.count == 1)
let encoded = try CustomRuleStore.encode(document)
let json = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
#expect(json["schemaVersion"] as? Int == 4)
let rule = try #require((json["rules"] as? [[String: Any]])?.first)
#expect(rule["source"] == nil)
#expect((rule["match"] as? [String: String])?["value"] == "203.0.113.0/24")
#expect(try CustomRuleStore.decode(encoded).disabledIdentities == document.disabledIdentities)
}
}
extension CustomRuleStorageMigrationTests {
@Test(arguments: [3, 4])
func scopedLoadRejectsCorruptionWithoutRewriting(schema: Int) throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let store = CustomRuleStore(
preset: .chinaProxy, baseURL: directory.appendingPathComponent("rules.json"))
try FileManager.default.createDirectory(
at: store.fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
let reverse = CustomRule(action: .direct, match: try RuleMatch(domainExact: "reverse.example"))
// A duplicate must remain corrupt even when migration would remove both entries.
let rules = schema == 3 ? [reverse, reverse] : [reverse]
var json = try #require(
JSONSerialization.jsonObject(with: CustomRuleStore.encode(CustomRuleDocument(rules: rules)))
as? [String: Any])
json["schemaVersion"] = schema
let bytes = try JSONSerialization.data(withJSONObject: json)
try bytes.write(to: store.fileURL)
#expect(throws: CustomRuleStoreError.self) { try store.loadDocument() }
#expect(try Data(contentsOf: store.fileURL) == bytes)
}
@Test func loadDoesNotRewriteLegacyFileAndSaveRetainsOrphanDisablement() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let store = CustomRuleStore(fileURL: directory.appendingPathComponent("rules.json"))
let legacy = Data(
"""
{"schemaVersion":2,"rules":[],"disabledIdentities":[
{"action":"proxy","match":{"kind":"domainExact","value":"orphan.example"}}]}
""".utf8)
try legacy.write(to: store.fileURL)
let document = try store.loadDocument()
#expect(try Data(contentsOf: store.fileURL) == legacy)
let rule = CustomRule(action: .direct, match: try RuleMatch(domainExact: "new.example"))
try store.save([rule])
let saved = try store.loadDocument()
#expect(saved.rules == [rule])
#expect(saved.disabledIdentities == document.disabledIdentities)
}
@Test(arguments: ["uuid", "identity", "disabled"])
func duplicateRecordsRemainRejected(kind: String) throws {
let record: [String: Any] = [
"id": "A22CD040-4788-41CC-BA73-AF296C7FE410", "action": "direct",
"match": ["kind": "domainExact", "value": "example.com"],
]
var second = record
if kind == "identity" { second["id"] = "B22CD040-4788-41CC-BA73-AF296C7FE410" }
if kind == "uuid" { second["action"] = "proxy" }
let disabled: [String: Any] = [
"action": "proxy", "match": ["kind": "domainExact", "value": "orphan.example"],
]
let json: [String: Any] = [
"schemaVersion": 3, "rules": kind == "disabled" ? [] : [record, second],
"disabledIdentities": kind == "disabled" ? [disabled, disabled] : [],
]
let data = try JSONSerialization.data(withJSONObject: json)
#expect(throws: CustomRuleStoreError.self) { try CustomRuleStore.decode(data) }
}
}