Files
ShadowsocksX-NG/ShadowsocksX-NG2/Tests/RuntimeFileStoreTests.swift
T
Qiu Yuzhou b8e5919bcd fix(runtime): converge proxy runtime only when contract bytes change
Every catalog commit re-expanded the active target and ran the full
deploy cycle unconditionally: the status face flashed back to starting,
the launch health gate re-probed both local inbounds, and system proxy
converged again — even when the derived contract was byte-identical to
what was already deployed. Remark edits went further: remarks were part
of SslocalServerDocument, so renaming the active server rewrote the
contract and signalled sslocal for a change with no runtime effect.

The reload predicate is now contract byte equality, shared by both
layers (CONTEXT.md: a committed edit that leaves the runtime
configuration file's bytes unchanged triggers no runtime convergence
work). remarks leave the contract — upstream treats them as optional
metadata and the GUI resolves display names from the catalog — so
remark-only edits and no-op re-saves derive byte-identical documents.
The catalogDidCommit deploy path skips write, signal, health gate and
system proxy converge when ProxyRuntimePlan.actions returns empty
(registered agent, live wrapper, on-disk contract equals the derived
document); disk drift or a dead wrapper keeps the self-healing deploy.
Catalog revalidation (candidate skips, target invalidation) still runs
on every commit, and explicit intents (activation, switches, settings,
launch resync) are untouched.

The remark-edit runtime test — which encoded the old behavior as the
example of a value change — is replaced by four catalog-convergence
cases: remark-only edit and outside-subtree edit stay untouched, group
member and active server value edits rewrite and signal.

Also repairs the smoke suite, which failed on HEAD before this change:
its ACL documents used a sslocal-active.acl path while
RuntimeFileStore.write validates against acl-active.ini, so every
ACL-bearing case threw before spawning the wrapper. Smoke ACL paths now
bind to the store-validated location, and the suite passes 11/11 against
real sslocal, confirming upstream accepts the contract without remarks.
2026-09-29 11:57:37 +08:00

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
/// 运行时文件存取(spec #21 D5,issue #27):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: 权限基线(D5)
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))
}
}