feat(privacy-host): add signed native calendar contacts and reminders host
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.
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user