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.
333 lines
17 KiB
Swift
333 lines
17 KiB
Swift
import XCTest
|
||
import Foundation
|
||
import Darwin
|
||
@testable import ReynaCLIHostCore
|
||
|
||
final class SocketPathValidationTests: XCTestCase {
|
||
|
||
// Helpers to build fake LStatInfo
|
||
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 currentUID() -> uid_t { getuid() }
|
||
|
||
// Real default socket path: $HOME/Library/Application Support/reyna-cli/privacy/reyna-cli.sock
|
||
// Actual Mac modes from bug report:
|
||
// home /Users/adolforeyna = 0750, ~/Library = 0700, ~/Library/Application Support = 0700, privacy = 0700
|
||
// Tier 2 allows 0750 for intermediates, tier 3 requires 0700 for dedicated runtime parent.
|
||
func testRealDefaultHomeSocketPathValidationAccepts() throws {
|
||
let uid = currentUID()
|
||
let home = NSHomeDirectory() // /Users/adolforeyna
|
||
let socketPath = (home as NSString).appendingPathComponent("Library/Application Support/reyna-cli/privacy/reyna-cli.sock")
|
||
var map: [String: LStatInfo] = [:]
|
||
map["/"] = dir(uid: 0, mode: 0o40755)
|
||
map["/Users"] = dir(uid: 0, mode: 0o40755)
|
||
|
||
// helper to insert chain
|
||
func insertChain(upTo target: String, defaultMode: mode_t, overrides: [String: mode_t] = [:]) {
|
||
let url = URL(fileURLWithPath: target)
|
||
var cur = ""
|
||
for comp in url.pathComponents {
|
||
if comp == "/" { cur = "/"; continue }
|
||
if cur == "/" { cur = "/" + comp } else if cur.isEmpty { cur = comp } else { cur = cur + "/" + comp }
|
||
if map[cur] != nil { continue }
|
||
if cur == "/" || cur == "/Users" { continue }
|
||
let m = overrides[cur] ?? defaultMode
|
||
map[cur] = dir(uid: uid, mode: m)
|
||
}
|
||
}
|
||
|
||
// Home itself 0750 per actual system
|
||
map[home] = dir(uid: uid, mode: 0o40750)
|
||
// Library and subdirs 0700 except home already set
|
||
// Build full parent chain to privacy
|
||
let parentOfSocket = URL(fileURLWithPath: socketPath).deletingLastPathComponent().path
|
||
// For parent chain: home is 0750 override, others 0700
|
||
var chainCur = ""
|
||
for comp in URL(fileURLWithPath: parentOfSocket).pathComponents {
|
||
if comp == "/" { chainCur = "/"; continue }
|
||
if chainCur == "/" { chainCur = "/" + comp } else if chainCur.isEmpty { chainCur = comp } else { chainCur = chainCur + "/" + comp }
|
||
if map[chainCur] != nil { continue }
|
||
if chainCur == "/" || chainCur == "/Users" { continue }
|
||
if chainCur == home { continue } // already 0750
|
||
map[chainCur] = dir(uid: uid, mode: 0o40700)
|
||
}
|
||
|
||
let provider: LStatProvider = { path in map[path] }
|
||
|
||
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
|
||
"Real default home with 0750 home and 0700 Library/.../privacy must validate")
|
||
|
||
XCTAssertTrue(platformTrustedRootPaths.contains("/Users"))
|
||
XCTAssertTrue(platformTrustedRootPaths.contains("/"))
|
||
}
|
||
|
||
func testRealDefaultHomeSocketPathValidationAcceptsWithHome0755() throws {
|
||
// Also allow 0755 for home (some configs) – tier 2 should still allow as no write
|
||
let uid = currentUID()
|
||
let home = NSHomeDirectory()
|
||
let socketPath = (home as NSString).appendingPathComponent("Library/Application Support/reyna-cli/privacy/reyna-cli.sock")
|
||
var map: [String: LStatInfo] = [
|
||
"/": dir(uid: 0, mode: 0o40755),
|
||
"/Users": dir(uid: 0, mode: 0o40755)
|
||
]
|
||
map[home] = dir(uid: uid, mode: 0o40755)
|
||
let parentPath = URL(fileURLWithPath: socketPath).deletingLastPathComponent().path
|
||
var cur = ""
|
||
for comp in URL(fileURLWithPath: parentPath).pathComponents {
|
||
if comp == "/" { cur = "/"; continue }
|
||
if cur == "/" { cur = "/" + comp } else if cur.isEmpty { cur = comp } else { cur = cur + "/" + comp }
|
||
if map[cur] != nil { continue }
|
||
if cur == "/" || cur == "/Users" { continue }
|
||
map[cur] = dir(uid: uid, mode: 0o40700)
|
||
}
|
||
let provider: LStatProvider = { map[$0] }
|
||
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
|
||
"Home 0755 should also be allowed (no write)")
|
||
}
|
||
|
||
// Safe vs unsafe ancestor decision with fake stats
|
||
func testTrustedRootMustBeRootOwned() {
|
||
let uid = currentUID()
|
||
let socketPath = "/Users/\(NSUserName())/Library/reyna.sock"
|
||
var map: [String: LStatInfo] = [
|
||
"/": dir(uid: 0, mode: 0o40755),
|
||
"/Users": dir(uid: uid, mode: 0o40755), // wrong: owned by current user, should fail - /Users must be uid 0
|
||
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40700)
|
||
]
|
||
let provider: LStatProvider = { map[$0] }
|
||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider)) { err in
|
||
let msg = (err as NSError).localizedDescription
|
||
XCTAssertTrue(msg.contains("/Users") || msg.contains("current uid") || msg.contains("not owned"))
|
||
}
|
||
}
|
||
|
||
func testTrustedRootMustNotBeWorldWritable() {
|
||
let uid = currentUID()
|
||
let socketPath = "/Users/\(NSUserName())/Library/reyna.sock"
|
||
var map: [String: LStatInfo] = [
|
||
"/": dir(uid: 0, mode: 0o40755),
|
||
"/Users": dir(uid: 0, mode: 0o40777), // world writable unsafe
|
||
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40700)
|
||
]
|
||
let provider: LStatProvider = { map[$0] }
|
||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider)) { err in
|
||
XCTAssertTrue((err as NSError).code == 25 || (err as NSError).localizedDescription.contains("permissions"))
|
||
}
|
||
}
|
||
|
||
func testRejectsArbitraryRootOwnedIntermediatePath() {
|
||
// E.g. /tmp/root_owned_dir owned by root should be REJECTED because not in allowlist
|
||
let uid = currentUID()
|
||
let socketPath = "/tmp/root_owned_dir/reyna.sock"
|
||
var map: [String: LStatInfo] = [
|
||
"/": dir(uid: 0, mode: 0o40755),
|
||
// /tmp is symlink-allowed platform path
|
||
"/tmp": dir(uid: 0, mode: 0o120777, symlink: true), // symlink allowed
|
||
"/tmp/root_owned_dir": dir(uid: 0, mode: 0o40700) // root owned but not allowlisted
|
||
]
|
||
let provider: LStatProvider = { map[$0] }
|
||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
|
||
"Arbitrary root-owned path /tmp/root_owned_dir must be rejected – only explicit allowlist trusted")
|
||
}
|
||
|
||
func testRejectsHomeNotOwnedByCurrentUID() {
|
||
let uid = currentUID()
|
||
let socketPath = "/Users/\(NSUserName())/Library/reyna.sock"
|
||
var map: [String: LStatInfo] = [
|
||
"/": dir(uid: 0, mode: 0o40755),
|
||
"/Users": dir(uid: 0, mode: 0o40755),
|
||
"/Users/\(NSUserName())": dir(uid: 0, mode: 0o40700) // wrong owner
|
||
]
|
||
let provider: LStatProvider = { map[$0] }
|
||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider))
|
||
}
|
||
|
||
func testAllowsHome0750ForIntermediateButRejectsWritable() {
|
||
// Tier 2: intermediate ancestors allow 0750/0755, reject writable bits
|
||
let uid = currentUID()
|
||
let socketPath = "/Users/\(NSUserName())/Library/Application Support/reyna-cli/privacy/reyna.sock"
|
||
// 0750 for home should be accepted (intermediate)
|
||
var mapAllow: [String: LStatInfo] = [
|
||
"/": dir(uid: 0, mode: 0o40755),
|
||
"/Users": dir(uid: 0, mode: 0o40755),
|
||
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40750),
|
||
"/Users/\(NSUserName())/Library": dir(uid: uid, mode: 0o40700),
|
||
"/Users/\(NSUserName())/Library/Application Support": dir(uid: uid, mode: 0o40700),
|
||
"/Users/\(NSUserName())/Library/Application Support/reyna-cli": dir(uid: uid, mode: 0o40700),
|
||
"/Users/\(NSUserName())/Library/Application Support/reyna-cli/privacy": dir(uid: uid, mode: 0o40700)
|
||
]
|
||
let providerAllow: LStatProvider = { mapAllow[$0] }
|
||
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: providerAllow),
|
||
"HOME 0750 as intermediate must be allowed (no write bits)")
|
||
|
||
// 0770 (group writable) for home must be rejected
|
||
var mapReject: [String: LStatInfo] = [
|
||
"/": dir(uid: 0, mode: 0o40755),
|
||
"/Users": dir(uid: 0, mode: 0o40755),
|
||
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40770)
|
||
]
|
||
let socketPath2 = "/Users/\(NSUserName())/Library/reyna.sock"
|
||
let providerReject: LStatProvider = { mapReject[$0] }
|
||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath2, currentUID: uid, provider: providerReject),
|
||
"HOME 0770 (group writable) must be rejected even for intermediate")
|
||
|
||
// 0777 world writable must be rejected
|
||
var mapReject2: [String: LStatInfo] = [
|
||
"/": dir(uid: 0, mode: 0o40755),
|
||
"/Users": dir(uid: 0, mode: 0o40755),
|
||
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40777)
|
||
]
|
||
let providerReject2: LStatProvider = { mapReject2[$0] }
|
||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath2, currentUID: uid, provider: providerReject2),
|
||
"HOME 0777 must be rejected")
|
||
}
|
||
|
||
func testRejectsDedicatedRuntimeParentWith0750or0755() {
|
||
let uid = currentUID()
|
||
let home = NSHomeDirectory()
|
||
let socketPath = (home as NSString).appendingPathComponent("Library/Application Support/reyna-cli/privacy/reyna-cli.sock")
|
||
// privacy dir 0750 must be rejected (tier 3 requires 0700)
|
||
var map0750: [String: LStatInfo] = [
|
||
"/": dir(uid: 0, mode: 0o40755),
|
||
"/Users": dir(uid: 0, mode: 0o40755),
|
||
]
|
||
// build chain but make privacy 0750
|
||
var cur = ""
|
||
for comp in URL(fileURLWithPath: URL(fileURLWithPath: socketPath).deletingLastPathComponent().path).pathComponents {
|
||
if comp == "/" { cur = "/"; continue }
|
||
if cur == "/" { cur = "/" + comp } else if cur.isEmpty { cur = comp } else { cur = cur + "/" + comp }
|
||
if map0750[cur] != nil { continue }
|
||
if cur == "/" || cur == "/Users" { continue }
|
||
if cur.hasSuffix("/privacy") {
|
||
map0750[cur] = dir(uid: uid, mode: 0o40750)
|
||
} else if cur == home {
|
||
map0750[cur] = dir(uid: uid, mode: 0o40750) // home 0750 allowed
|
||
} else {
|
||
map0750[cur] = dir(uid: uid, mode: 0o40700)
|
||
}
|
||
}
|
||
let provider0750: LStatProvider = { map0750[$0] }
|
||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider0750),
|
||
"Dedicated runtime parent privacy with 0750 must be rejected – requires 0700")
|
||
|
||
// 0755 also rejected
|
||
var map0755 = map0750
|
||
let privacyPath = URL(fileURLWithPath: socketPath).deletingLastPathComponent().path
|
||
map0755[privacyPath] = dir(uid: uid, mode: 0o40755)
|
||
let provider0755: LStatProvider = { map0755[$0] }
|
||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider0755),
|
||
"Dedicated runtime parent privacy with 0755 must be rejected")
|
||
}
|
||
|
||
func testAllowsIntermediate0755ButRequiresPrivacy0700() {
|
||
let uid = currentUID()
|
||
let home = NSHomeDirectory()
|
||
let socketPath = (home as NSString).appendingPathComponent("Library/Application Support/reyna-cli/privacy/reyna-cli.sock")
|
||
var map: [String: LStatInfo] = [
|
||
"/": dir(uid: 0, mode: 0o40755),
|
||
"/Users": dir(uid: 0, mode: 0o40755),
|
||
]
|
||
var cur = ""
|
||
for comp in URL(fileURLWithPath: URL(fileURLWithPath: socketPath).deletingLastPathComponent().path).pathComponents {
|
||
if comp == "/" { cur = "/"; continue }
|
||
if cur == "/" { cur = "/" + comp } else if cur.isEmpty { cur = comp } else { cur = cur + "/" + comp }
|
||
if map[cur] != nil { continue }
|
||
if cur == "/" || cur == "/Users" { continue }
|
||
if cur == home {
|
||
map[cur] = dir(uid: uid, mode: 0o40755) // home 0755 allowed as intermediate
|
||
} else if cur.hasSuffix("/privacy") == false {
|
||
// intermediate Library etc can be 0750/0755
|
||
map[cur] = dir(uid: uid, mode: 0o40750)
|
||
} else {
|
||
map[cur] = dir(uid: uid, mode: 0o40700) // privacy must be 0700
|
||
}
|
||
}
|
||
let provider: LStatProvider = { map[$0] }
|
||
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
|
||
"Intermediate 0750/0755 allowed, privacy 0700 must validate")
|
||
}
|
||
|
||
func testRejectsSymlinkInUserChain() {
|
||
let uid = currentUID()
|
||
let socketPath = "/Users/\(NSUserName())/Library/reyna.sock"
|
||
var map: [String: LStatInfo] = [
|
||
"/": dir(uid: 0, mode: 0o40755),
|
||
"/Users": dir(uid: 0, mode: 0o40755),
|
||
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40700),
|
||
"/Users/\(NSUserName())/Library": dir(uid: uid, mode: 0o120777, symlink: true)
|
||
]
|
||
let provider: LStatProvider = { map[$0] }
|
||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
|
||
"Symlink in user chain must be rejected")
|
||
}
|
||
|
||
func testAllowsPlatformSymlinksTmpVar() {
|
||
let uid = currentUID()
|
||
let socketPath = "/tmp/rh-test/reyna.sock"
|
||
var map: [String: LStatInfo] = [
|
||
"/": dir(uid: 0, mode: 0o40755),
|
||
"/private": dir(uid: 0, mode: 0o40755),
|
||
"/private/tmp": dir(uid: 0, mode: 0o41777), // /private/tmp typically 1777
|
||
"/tmp": dir(uid: 0, mode: 0o120777, symlink: true), // allowed symlink
|
||
"/tmp/rh-test": dir(uid: uid, mode: 0o40700)
|
||
]
|
||
let provider: LStatProvider = { map[$0] }
|
||
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider))
|
||
}
|
||
|
||
func testRejectsDotDot() {
|
||
let uid = currentUID()
|
||
let socketPath = "/tmp/rh/../evil.sock"
|
||
var map: [String: LStatInfo] = [:]
|
||
let provider: LStatProvider = { map[$0] }
|
||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider))
|
||
}
|
||
|
||
func testNoBroadGlobalShortcutOnlyAllowlist() {
|
||
// Ensure implementation does not accept every root-owned path.
|
||
// For path /opt/rootdir where /opt is root-owned (simulating arbitrary root path), it must be rejected unless explicitly allowlisted.
|
||
let uid = currentUID()
|
||
let socketPath = "/opt/rootdir/reyna.sock"
|
||
var map: [String: LStatInfo] = [
|
||
"/": dir(uid: 0, mode: 0o40755),
|
||
"/opt": dir(uid: 0, mode: 0o40755), // root owned, not allowlisted
|
||
"/opt/rootdir": dir(uid: uid, mode: 0o40700)
|
||
]
|
||
let provider: LStatProvider = { map[$0] }
|
||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
|
||
"Must not accept arbitrary root-owned /opt")
|
||
}
|
||
|
||
func testAllowsPrivateVarChain() {
|
||
// macOS: /var -> /private/var, /private/var/tmp etc are platform trusted
|
||
let uid = currentUID()
|
||
let socketPath = "/private/var/tmp/rh-test/reyna.sock"
|
||
var map: [String: LStatInfo] = [
|
||
"/": dir(uid: 0, mode: 0o40755),
|
||
"/private": dir(uid: 0, mode: 0o40755),
|
||
"/private/var": dir(uid: 0, mode: 0o40755),
|
||
"/private/var/tmp": dir(uid: 0, mode: 0o41777),
|
||
"/private/var/tmp/rh-test": dir(uid: uid, mode: 0o40700)
|
||
]
|
||
let provider: LStatProvider = { map[$0] }
|
||
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider))
|
||
}
|
||
|
||
func testRejectsGroupWritableIntermediate() {
|
||
let uid = currentUID()
|
||
let socketPath = "/Users/\(NSUserName())/Library/Application Support/reyna.sock"
|
||
var map: [String: LStatInfo] = [
|
||
"/": dir(uid: 0, mode: 0o40755),
|
||
"/Users": dir(uid: 0, mode: 0o40755),
|
||
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40755),
|
||
"/Users/\(NSUserName())/Library": dir(uid: uid, mode: 0o40770),
|
||
"/Users/\(NSUserName())/Library/Application Support": dir(uid: uid, mode: 0o40700)
|
||
]
|
||
let provider: LStatProvider = { map[$0] }
|
||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
|
||
"Intermediate Library 0770 group writable must be rejected")
|
||
}
|
||
}
|