9fd04b0ce4
Add the owner-only AF_UNIX Reyna CLI privacy host, strict signed-app installation, and typed native routing for Calendar, Contacts, and Reminders.\n\nAdd bounded system-status paths and config-only direct local-service wrappers. Preserve MacMiniMCP pending explicit cutover approval.\n\nApple Notes is intentionally deferred: no native Notes operations, Apple Events declaration, or Automation helper are included; legacy Notes handling remains untouched.
127 lines
5.8 KiB
Swift
127 lines
5.8 KiB
Swift
import XCTest
|
|
import Foundation
|
|
import Darwin
|
|
@testable import ReynaCLIHostCore
|
|
|
|
final class LStatFailClosedTests: XCTestCase {
|
|
private func dir(uid: uid_t, mode: mode_t, symlink: Bool = false) -> LStatInfo {
|
|
return LStatInfo(uid: uid, mode: mode_t(mode), isSymlink: symlink, isDir: !symlink, exists: true)
|
|
}
|
|
private func file(uid: uid_t, mode: mode_t) -> LStatInfo {
|
|
// regular file: not symlink, not dir
|
|
return LStatInfo(uid: uid, mode: mode_t(mode), isSymlink: false, isDir: false, exists: true)
|
|
}
|
|
private func currentUID() -> uid_t { getuid() }
|
|
|
|
// RED: EACCES and ELOOP must not be treated as missing (fail-closed)
|
|
func testNonENOENTProviderFailureRejectsWithCode22() {
|
|
let uid = currentUID()
|
|
let socketPath = "/tmp/rhfail/reyna.sock"
|
|
// Map only root trusted; but intermediate component will fail with EACCES
|
|
var mapPresent: [String: LStatInfo] = [
|
|
"/": dir(uid: 0, mode: 0o40755),
|
|
"/private": dir(uid: 0, mode: 0o40755),
|
|
"/private/tmp": dir(uid: 0, mode: 0o41777),
|
|
"/tmp": dir(uid: 0, mode: 0o120777, symlink: true)
|
|
]
|
|
let provider: LStatResultProvider = { p in
|
|
if p == "/tmp/rhfail" {
|
|
return .failed(errnoCode: EACCES)
|
|
}
|
|
if let v = mapPresent[p] { return .present(v) }
|
|
return .absent
|
|
}
|
|
XCTAssertThrowsError(try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: uid, provider: provider)) { err in
|
|
XCTAssertEqual((err as NSError).code, 22, "EACCES must surface as lstat failure code 22, not absent")
|
|
}
|
|
|
|
let providerLoop: LStatResultProvider = { p in
|
|
if p == "/tmp/rhfail" { return .failed(errnoCode: ELOOP) }
|
|
if let v = mapPresent[p] { return .present(v) }
|
|
return .absent
|
|
}
|
|
XCTAssertThrowsError(try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: uid, provider: providerLoop)) { err in
|
|
XCTAssertEqual((err as NSError).code, 22, "ELOOP must surface as code 22, not treated as ENOENT")
|
|
}
|
|
}
|
|
|
|
func testEAccesAnywhereInChainRejects() {
|
|
let uid = currentUID()
|
|
let socketPath = "/Users/\(NSUserName())/Library/reyna.sock"
|
|
let provider: LStatResultProvider = { p in
|
|
if p == "/Users" { return .failed(errnoCode: EACCES) }
|
|
return .absent
|
|
}
|
|
XCTAssertThrowsError(try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: uid, provider: provider))
|
|
}
|
|
|
|
// RED: regular file at /tmp or /var must be rejected (old ensureParentDirectories had bug where it skipped directory check for those)
|
|
func testRegularFileAtTmpMustReject() throws {
|
|
let uid = currentUID()
|
|
let socketPath = "/tmp/reyna.sock"
|
|
// /tmp exists as regular file (not dir, not symlink)
|
|
var map: [String: LStatInfo] = [
|
|
"/": dir(uid: 0, mode: 0o40755),
|
|
"/tmp": file(uid: 0, mode: 0o100644) // regular file
|
|
]
|
|
let provider: LStatResultProvider = { p in
|
|
if let v = map[p] { return .present(v) }
|
|
return .absent
|
|
}
|
|
XCTAssertThrowsError(try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: uid, provider: provider), "Regular file at /tmp must reject (not directory)")
|
|
// Also test direct single-component validator
|
|
XCTAssertThrowsError(try validateSingleLStatInfoOrThrow(path: "/tmp", info: map["/tmp"]!, currentUID: uid))
|
|
XCTAssertThrowsError(try validateSingleLStatInfoOrThrow(path: "/var", info: file(uid: 0, mode: 0o100644), currentUID: uid))
|
|
}
|
|
|
|
func testRegularFileAtIntermediateTrustedAliasMustReject() {
|
|
let uid = currentUID()
|
|
// /private/var exists as file
|
|
var map: [String: LStatInfo] = [
|
|
"/": dir(uid: 0, mode: 0o40755),
|
|
"/private": dir(uid: 0, mode: 0o40755),
|
|
"/private/var": file(uid: 0, mode: 0o100644)
|
|
]
|
|
let provider: LStatResultProvider = { p in
|
|
if let v = map[p] { return .present(v) }
|
|
return .absent
|
|
}
|
|
XCTAssertThrowsError(try validateParentChainPureResultProvider(socketPath: "/private/var/tmp/x/reyna.sock", currentUID: uid, provider: provider))
|
|
}
|
|
|
|
func testLiveProviderDoesNotSwallowNonENOENT() {
|
|
// liveLStatProvider legacy should now fail-closed sentinel, not nil
|
|
// Simulate by calling wrapper directly: we can't easily force EACCES without real FS,
|
|
// but we can assert that failed case in result provider is distinct from absent
|
|
let absent = LStatResult.absent
|
|
let failed = LStatResult.failed(errnoCode: EACCES)
|
|
switch absent {
|
|
case .absent: break
|
|
default: XCTFail()
|
|
}
|
|
switch failed {
|
|
case .failed(let c): XCTAssertEqual(c, EACCES)
|
|
default: XCTFail()
|
|
}
|
|
// legacy provider should return non-nil sentinel for failed case (so caller doesn't treat as missing)
|
|
// We test sentinel is non-nil and will be rejected by validator
|
|
// The new liveLStatResultProvider is tested via chain above
|
|
}
|
|
|
|
func testTmpSymlinkStillAllowed() throws {
|
|
let uid = currentUID()
|
|
let map: [String: LStatInfo] = [
|
|
"/": dir(uid: 0, mode: 0o40755),
|
|
"/private": dir(uid: 0, mode: 0o40755),
|
|
"/private/tmp": dir(uid: 0, mode: 0o41777),
|
|
"/tmp": dir(uid: 0, mode: 0o120777, symlink: true),
|
|
"/tmp/rh-test": dir(uid: uid, mode: 0o40700)
|
|
]
|
|
let provider: LStatResultProvider = { p in
|
|
if let v = map[p] { return .present(v) }
|
|
return .absent
|
|
}
|
|
XCTAssertNoThrow(try validateParentChainPureResultProvider(socketPath: "/tmp/rh-test/reyna.sock", currentUID: uid, provider: provider))
|
|
}
|
|
}
|