Files
ShadowsocksX-NG/ShadowsocksX-NG2/Tests/Runtime/RuntimeFileStoreTests.swift
T

320 lines
13 KiB
Swift
Raw 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 XCTest
@testable import ShadowsocksX_NG2
/// 运行时文件存取:0700/0600 权限基线、原子替换、
/// 读取侧判定与显式停止清理。
final class RuntimeFileStoreTests: XCTestCase {
private var runtime: ProxyRuntimeFixture.TemporaryRuntime!
private var store: RuntimeFileStore!
override func setUpWithError() throws {
try super.setUpWithError()
runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
store = RuntimeFileStore(fileURL: runtime.contract)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: runtime.directory)
try super.tearDownWithError()
}
private func permissions(of url: URL) throws -> Int {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
return try XCTUnwrap(attributes[.posixPermissions] as? Int)
}
// MARK: 权限基线
func testWriteCreatesDirectoryWith0700AndFileWith0600() throws {
try store.write(ProxyRuntimeFixture.makeDocument())
XCTAssertEqual(try permissions(of: runtime.directory), 0o700, "runtime/ 目录 0700")
XCTAssertEqual(try permissions(of: runtime.contract), 0o600, "契约文件 0600")
}
func testWriteHealsDirectoryPermissionBaseline() throws {
try FileManager.default.createDirectory(
at: runtime.directory, withIntermediateDirectories: true)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: runtime.directory.path)
try store.write(ProxyRuntimeFixture.makeDocument())
XCTAssertEqual(try permissions(of: runtime.directory), 0o700, "目录权限自愈回 0700")
}
func testAtomicReplaceKeepsFileAt0600() throws {
try store.write(ProxyRuntimeFixture.makeDocument(localPort: 1086))
try store.write(ProxyRuntimeFixture.makeDocument(localPort: 2086))
XCTAssertEqual(try permissions(of: runtime.contract), 0o600, "替换后仍 0600")
let document = try XCTUnwrap(store.loadDocument())
XCTAssertEqual(document.socksPort, 2086, "内容已被原子替换")
}
func testWriteLeavesNoTemporaryFilesBehind() throws {
try store.write(ProxyRuntimeFixture.makeDocument())
try store.write(ProxyRuntimeFixture.makeDocument(localPort: 2087))
let residue = try FileManager.default.contentsOfDirectory(atPath: runtime.directory.path)
.filter { $0.hasPrefix(".sslocal-active.json.tmp-") }
XCTAssertTrue(residue.isEmpty, "临时文件不应残留,实际 \(residue)")
}
func testACLAndContractAreWrittenWithProtectedPermissions() throws {
let document = SslocalRuntimeDocument(
servers: [],
listen: SslocalListenSettings(),
acl: .direct(at: store.aclFileURL))
try store.write(document)
XCTAssertEqual(try permissions(of: runtime.directory), 0o700)
XCTAssertEqual(try permissions(of: runtime.contract), 0o600)
XCTAssertEqual(
try permissions(of: store.aclVariantFileURL(summary: "direct")), 0o600,
"变体文件 0600;链接权限由文件系统决定,不作断言")
XCTAssertEqual(store.loadDocument(), document)
}
/// ADR-0011:变体落在 `acl-<summary>.ini`,契约路径是稳定的 `acl-active.ini`
/// 链接;换模式只换链接指向,不重写变体内容。
func testWriteMaterializesVariantFileAndActiveLink() throws {
let direct = SslocalRuntimeDocument(
servers: [], listen: SslocalListenSettings(),
acl: .direct(at: store.aclFileURL))
try store.write(direct)
let variantURL = store.aclVariantFileURL(summary: "direct")
XCTAssertTrue(FileManager.default.fileExists(atPath: variantURL.path))
let linkTarget = try FileManager.default.destinationOfSymbolicLink(
atPath: store.aclFileURL.path)
XCTAssertEqual(linkTarget, variantURL.lastPathComponent, "链接指向同目录变体文件")
let global = SslocalRuntimeDocument(
servers: [], listen: SslocalListenSettings(),
acl: .global(at: store.aclFileURL))
try store.write(global)
XCTAssertEqual(
try FileManager.default.destinationOfSymbolicLink(atPath: store.aclFileURL.path),
store.aclVariantFileURL(summary: "global").lastPathComponent)
XCTAssertTrue(
FileManager.default.fileExists(atPath: variantURL.path),
"旧变体文件保留,切换只改链接指向")
}
/// digest 清单让未变内容免于重写(ADR-0011):同内容二次落盘时变体文件
/// 修改时间不变。
func testUnchangedVariantContentSkipsRewrite() throws {
let document = SslocalRuntimeDocument(
servers: [], listen: SslocalListenSettings(),
acl: .direct(at: store.aclFileURL))
try store.write(document)
let variantURL = store.aclVariantFileURL(summary: "direct")
let firstAttributes = try FileManager.default.attributesOfItem(atPath: variantURL.path)
let firstModification = try XCTUnwrap(firstAttributes[.modificationDate] as? Date)
try store.write(document)
let secondAttributes = try FileManager.default.attributesOfItem(atPath: variantURL.path)
let secondModification = try XCTUnwrap(secondAttributes[.modificationDate] as? Date)
XCTAssertEqual(firstModification, secondModification, "digest 相同跳过变体写入")
}
/// Q12-A:读取侧不读 ACL 内容、不核摘要;链接逃逸出运行目录才拒绝。
func testLoadDocumentRejectsLinkEscapingRuntimeDirectory() throws {
let document = SslocalRuntimeDocument(
servers: [], listen: SslocalListenSettings(),
acl: .direct(at: store.aclFileURL))
try store.write(document)
let outside = runtime.directory.deletingLastPathComponent()
.appendingPathComponent("outside-\(UUID().uuidString).ini")
try Data("[bypass_all]\n".utf8).write(to: outside)
defer { try? FileManager.default.removeItem(at: outside) }
try FileManager.default.removeItem(at: store.aclFileURL)
try FileManager.default.createSymbolicLink(
atPath: store.aclFileURL.path, withDestinationPath: outside.path)
XCTAssertNil(store.loadDocument(), "链接解析后落在运行目录之外即拒绝")
}
func testContractWriteFailureRestoresPreviousVariantAndLink() throws {
let contractURL = runtime.contract
let previous = SslocalRuntimeDocument(
servers: [], listen: SslocalListenSettings(),
acl: .direct(at: store.aclFileURL))
try store.write(previous)
let failingStore = RuntimeFileStore(fileURL: contractURL) { data, url in
guard url != contractURL else { throw NSError(domain: "test", code: 1) }
try AtomicFileWriter.write(data, to: url)
}
let next = SslocalRuntimeDocument(
servers: [], listen: SslocalListenSettings(),
acl: .global(at: failingStore.aclFileURL))
XCTAssertThrowsError(try failingStore.write(next))
XCTAssertEqual(
try FileManager.default.destinationOfSymbolicLink(atPath: failingStore.aclFileURL.path),
failingStore.aclVariantFileURL(summary: "direct").lastPathComponent,
"契约写失败后链接回到此前指向")
XCTAssertEqual(
failingStore.loadDocument(), previous,
"契约未写成功,读取侧仍看到此前文档")
}
/// digest 清单不得先于契约写落盘:否则回滚后清单说新内容、盘上是旧内容,
/// 下次写同一新内容会被错误跳过。
func testContractWriteFailureDoesNotLeakNewDigestIntoManifest() throws {
let previous = SslocalRuntimeDocument(
servers: [], listen: SslocalListenSettings(),
acl: .direct(at: store.aclFileURL))
try store.write(previous)
let contractURL = runtime.contract
let failingStore = RuntimeFileStore(fileURL: contractURL) { data, url in
guard url != contractURL else { throw NSError(domain: "test", code: 1) }
try AtomicFileWriter.write(data, to: url)
}
let next = SslocalRuntimeDocument(
servers: [], listen: SslocalListenSettings(),
acl: .global(at: failingStore.aclFileURL))
XCTAssertThrowsError(try failingStore.write(next))
// 回滚成功后重试同一「global」写入:必须真写出 global 变体,不得被清单跳过。
let retryStore = RuntimeFileStore(fileURL: contractURL)
try retryStore.write(next)
XCTAssertEqual(
try Data(contentsOf: retryStore.aclFileURL),
Data(ProxyACLDocument.global(at: retryStore.aclFileURL).content.utf8),
"失败回滚后重试同一变体必须真正落盘")
XCTAssertEqual(retryStore.loadDocument(), next)
}
// MARK: 读取侧判定
func testLoadDocumentRoundTripsWrittenDocument() throws {
let document = ProxyRuntimeFixture.makeDocument()
try store.write(document)
XCTAssertEqual(store.loadDocument(), document)
XCTAssertEqual(store.readData(), try document.jsonData())
}
func testLoadDocumentReturnsNilForMissingFile() throws {
XCTAssertNil(store.loadDocument())
XCTAssertNil(store.readData())
}
func testLoadDocumentReturnsNilForCorruptJSON() throws {
try Data("not json {".utf8).write(to: runtime.contract)
XCTAssertNil(store.loadDocument())
}
func testLoadDocumentReturnsNilForStructurallyInvalidDocument() throws {
try store.write(
SslocalRuntimeDocument(
servers: [], listen: SslocalListenSettings(socksPort: 0)))
XCTAssertNil(store.loadDocument(), "本地端口无效属结构性无效(读取侧防御)")
}
// MARK: 显式停止清理
func testDeleteRuntimeFilesRemovesContractPidAndStaleTemporaries() throws {
try store.write(
SslocalRuntimeDocument(
servers: [], listen: SslocalListenSettings(), acl: .direct(at: store.aclFileURL)))
try store.write(
SslocalRuntimeDocument(
servers: [], listen: SslocalListenSettings(), acl: .global(at: store.aclFileURL)))
try Data("1086".utf8).write(to: runtime.pidFile)
try Data("status".utf8).write(to: store.runtimeStatusFileURL)
let staleTemporary = runtime.directory.appendingPathComponent(".sslocal-active.json.tmp-stale")
try Data("partial".utf8).write(to: staleTemporary)
store.deleteRuntimeFiles()
XCTAssertFalse(FileManager.default.fileExists(atPath: runtime.contract.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: runtime.pidFile.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: store.aclFileURL.path))
XCTAssertFalse(
FileManager.default.fileExists(atPath: store.aclVariantFileURL(summary: "direct").path))
XCTAssertFalse(
FileManager.default.fileExists(atPath: store.aclVariantFileURL(summary: "global").path))
XCTAssertFalse(
FileManager.default.fileExists(atPath: store.aclDigestManifestURL.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: store.runtimeStatusFileURL.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: staleTemporary.path))
}
func testDeleteRuntimeFilesIsHarmlessWhenNothingExists() throws {
store.deleteRuntimeFiles()
store.deleteRuntimeFiles()
XCTAssertTrue(true, "重复清理不抛错")
}
// MARK: 文档校验与监听指纹
func testWellFormedValidationRejectsStructuralGarbage() {
func document(
localPort: Int = 1086,
servers: [SslocalServerDocument]
) -> SslocalRuntimeDocument {
SslocalRuntimeDocument(
servers: servers,
listen: SslocalListenSettings(
socksPort: localPort))
}
func server(
id: String = "s", address: String = "203.0.113.7", port: Int = 8388,
method: String = "aes-256-gcm"
) -> SslocalServerDocument {
SslocalServerDocument(
id: id, server: address, serverPort: port, password: "pw",
method: method, plugin: nil, pluginOpts: nil)
}
XCTAssertTrue(document(servers: [server()]).isWellFormed)
XCTAssertTrue(
document(servers: []).isWellFormed,
"空 servers 合法:无活动目标的本地监听(issue #60)")
XCTAssertFalse(document(localPort: 0, servers: [server()]).isWellFormed)
XCTAssertFalse(document(localPort: 65_536, servers: [server()]).isWellFormed)
XCTAssertFalse(document(servers: [server(port: 0)]).isWellFormed)
XCTAssertFalse(document(servers: [server(address: "")]).isWellFormed)
XCTAssertFalse(document(servers: [server(method: "")]).isWellFormed)
XCTAssertFalse(document(servers: [server(id: "")]).isWellFormed)
}
func testListenFingerprintIgnoresServerChangesAndTracksListenFields() {
let base = ProxyRuntimeFixture.makeDocument()
let serverChanged = ProxyRuntimeFixture.makeDocument(serverAddress: "198.51.100.9")
let listenChanged = ProxyRuntimeFixture.makeDocument(localPort: 2088)
XCTAssertEqual(
base.listenFingerprint, serverChanged.listenFingerprint,
"仅服务器列表变化不影响监听指纹(走 SIGUSR1 热重载)")
XCTAssertNotEqual(
base.listenFingerprint, listenChanged.listenFingerprint,
"监听端口变化改变指纹(走优雅重启)")
}
func testValidatedRuntimeRequiresFixedTCPAndUDPModeForSOCKS() throws {
let base = ProxyRuntimeFixture.makeDocument()
var object = try XCTUnwrap(
try JSONSerialization.jsonObject(with: base.jsonData()) as? [String: Any])
var locals = try XCTUnwrap(object["locals"] as? [[String: Any]])
locals[0]["mode"] = "tcp_only"
object["locals"] = locals
let data = try JSONSerialization.data(withJSONObject: object)
XCTAssertNil(SslocalRuntimeDocument.decodeValidated(data))
}
}