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,41 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ReynaCLIHost",
|
||||
platforms: [.macOS(.v13)],
|
||||
products: [
|
||||
.executable(name: "ReynaCLIHost", targets: ["ReynaCLIHost"]),
|
||||
.library(name: "ReynaCLIHostCore", targets: ["ReynaCLIHostCore"]),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "CSignalSupport",
|
||||
path: "Sources/CSignalSupport",
|
||||
publicHeadersPath: "include"
|
||||
),
|
||||
.target(
|
||||
name: "ReynaCLIHostCore",
|
||||
dependencies: ["CSignalSupport"],
|
||||
path: "Sources/ReynaCLIHostCore",
|
||||
linkerSettings: [
|
||||
.linkedFramework("EventKit"),
|
||||
.linkedFramework("Contacts")
|
||||
]
|
||||
),
|
||||
.executableTarget(
|
||||
name: "ReynaCLIHost",
|
||||
dependencies: ["ReynaCLIHostCore", "CSignalSupport"],
|
||||
path: "Sources/ReynaCLIHost",
|
||||
linkerSettings: [
|
||||
.linkedFramework("EventKit"),
|
||||
.linkedFramework("Contacts")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "ReynaCLIHostTests",
|
||||
dependencies: ["ReynaCLIHostCore"],
|
||||
path: "Tests/ReynaCLIHostTests"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,420 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
C00000000000000000000001 /* AppMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000001 /* AppMain.swift */; };
|
||||
C00000000000000000000002 /* AppEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000004 /* AppEntry.swift */; };
|
||||
C00000000000000000000003 /* Protocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000005 /* Protocol.swift */; };
|
||||
C00000000000000000000004 /* CalendarProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000006 /* CalendarProvider.swift */; };
|
||||
C00000000000000000000005 /* CalendarAuthorizationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000007 /* CalendarAuthorizationProvider.swift */; };
|
||||
C00000000000000000000006 /* SocketPathValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000008 /* SocketPathValidation.swift */; };
|
||||
C00000000000000000000007 /* SocketServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000009 /* SocketServer.swift */; };
|
||||
C00000000000000000000008 /* CSignalSupport.c in Sources */ = {isa = PBXBuildFile; fileRef = B0000000000000000000000A /* CSignalSupport.c */; };
|
||||
C00000000000000000000009 /* EventKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B0000000000000000000000C /* EventKit.framework */; };
|
||||
C0000000000000000000000A /* ContactsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0000000000000000000000E /* ContactsProvider.swift */; };
|
||||
C0000000000000000000000B /* ContactsAuthorizationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0000000000000000000000F /* ContactsAuthorizationProvider.swift */; };
|
||||
C0000000000000000000000C /* Contacts.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B00000000000000000000010 /* Contacts.framework */; };
|
||||
C0000000000000000000000D /* RemindersProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000011 /* RemindersProvider.swift */; };
|
||||
C0000000000000000000000E /* RemindersAuthorizationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000012 /* RemindersAuthorizationProvider.swift */; };
|
||||
C00000000000000000000011 /* SystemInfoProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000015 /* SystemInfoProvider.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
B00000000000000000000001 /* AppMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMain.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000002 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
B00000000000000000000003 /* ReynaCLIHost-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ReynaCLIHost-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
B00000000000000000000004 /* AppEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEntry.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000005 /* Protocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Protocol.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000006 /* CalendarProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarProvider.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000007 /* CalendarAuthorizationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarAuthorizationProvider.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000008 /* SocketPathValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketPathValidation.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000009 /* SocketServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketServer.swift; sourceTree = "<group>"; };
|
||||
B0000000000000000000000A /* CSignalSupport.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = CSignalSupport.c; sourceTree = "<group>"; };
|
||||
B0000000000000000000000B /* CSignalSupport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CSignalSupport.h; sourceTree = "<group>"; };
|
||||
B0000000000000000000000C /* EventKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = EventKit.framework; path = System/Library/Frameworks/EventKit.framework; sourceTree = SDKROOT; };
|
||||
B0000000000000000000000E /* ContactsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsProvider.swift; sourceTree = "<group>"; };
|
||||
B0000000000000000000000F /* ContactsAuthorizationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsAuthorizationProvider.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000010 /* Contacts.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Contacts.framework; path = System/Library/Frameworks/Contacts.framework; sourceTree = SDKROOT; };
|
||||
B00000000000000000000011 /* RemindersProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersProvider.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000012 /* RemindersAuthorizationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersAuthorizationProvider.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000015 /* SystemInfoProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemInfoProvider.swift; sourceTree = "<group>"; };
|
||||
B0000000000000000000000D /* Reyna CLI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Reyna CLI.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
D00000000000000000000002 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C00000000000000000000009 /* EventKit.framework in Frameworks */,
|
||||
C0000000000000000000000C /* Contacts.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
4CE47EC8302137F000015A48 /* Recovered References */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B0000000000000000000000C /* EventKit.framework */,
|
||||
B00000000000000000000010 /* Contacts.framework */,
|
||||
);
|
||||
name = "Recovered References";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000000000000000000002 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A00000000000000000000004 /* ReynaCLIHost */,
|
||||
A00000000000000000000005 /* Sources */,
|
||||
A00000000000000000000003 /* Products */,
|
||||
4CE47EC8302137F000015A48 /* Recovered References */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000000000000000000003 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B0000000000000000000000D /* Reyna CLI.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000000000000000000004 /* ReynaCLIHost */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B00000000000000000000001 /* AppMain.swift */,
|
||||
B00000000000000000000002 /* Info.plist */,
|
||||
B00000000000000000000003 /* ReynaCLIHost-Bridging-Header.h */,
|
||||
);
|
||||
path = ReynaCLIHost;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000000000000000000005 /* Sources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A00000000000000000000006 /* ReynaCLIHostCore */,
|
||||
A00000000000000000000007 /* CSignalSupport */,
|
||||
);
|
||||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000000000000000000006 /* ReynaCLIHostCore */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B00000000000000000000004 /* AppEntry.swift */,
|
||||
B00000000000000000000005 /* Protocol.swift */,
|
||||
B00000000000000000000006 /* CalendarProvider.swift */,
|
||||
B00000000000000000000007 /* CalendarAuthorizationProvider.swift */,
|
||||
B0000000000000000000000E /* ContactsProvider.swift */,
|
||||
B0000000000000000000000F /* ContactsAuthorizationProvider.swift */,
|
||||
B00000000000000000000011 /* RemindersProvider.swift */,
|
||||
B00000000000000000000012 /* RemindersAuthorizationProvider.swift */,
|
||||
B00000000000000000000015 /* SystemInfoProvider.swift */,
|
||||
B00000000000000000000008 /* SocketPathValidation.swift */,
|
||||
B00000000000000000000009 /* SocketServer.swift */,
|
||||
);
|
||||
path = ReynaCLIHostCore;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000000000000000000007 /* CSignalSupport */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B0000000000000000000000A /* CSignalSupport.c */,
|
||||
B0000000000000000000000B /* CSignalSupport.h */,
|
||||
);
|
||||
path = CSignalSupport;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
E00000000000000000000001 /* Reyna CLI */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = F00000000000000000000002 /* Build configuration list for PBXNativeTarget "Reyna CLI" */;
|
||||
buildPhases = (
|
||||
D00000000000000000000001 /* Sources */,
|
||||
D00000000000000000000002 /* Frameworks */,
|
||||
D00000000000000000000003 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = "Reyna CLI";
|
||||
productName = "Reyna CLI";
|
||||
productReference = B0000000000000000000000D /* Reyna CLI.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
E00000000000000000000002 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastUpgradeCheck = 1500;
|
||||
TargetAttributes = {
|
||||
E00000000000000000000001 = {
|
||||
CreatedOnToolsVersion = 15.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = F00000000000000000000001 /* Build configuration list for PBXProject "ReynaCLIHost" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = A00000000000000000000002;
|
||||
productRefGroup = A00000000000000000000003 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
E00000000000000000000001 /* Reyna CLI */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
D00000000000000000000003 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
D00000000000000000000001 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C00000000000000000000001 /* AppMain.swift in Sources */,
|
||||
C00000000000000000000002 /* AppEntry.swift in Sources */,
|
||||
C00000000000000000000003 /* Protocol.swift in Sources */,
|
||||
C00000000000000000000004 /* CalendarProvider.swift in Sources */,
|
||||
C00000000000000000000005 /* CalendarAuthorizationProvider.swift in Sources */,
|
||||
C0000000000000000000000A /* ContactsProvider.swift in Sources */,
|
||||
C0000000000000000000000B /* ContactsAuthorizationProvider.swift in Sources */,
|
||||
C0000000000000000000000D /* RemindersProvider.swift in Sources */,
|
||||
C0000000000000000000000E /* RemindersAuthorizationProvider.swift in Sources */,
|
||||
C00000000000000000000011 /* SystemInfoProvider.swift in Sources */,
|
||||
C00000000000000000000006 /* SocketPathValidation.swift in Sources */,
|
||||
C00000000000000000000007 /* SocketServer.swift in Sources */,
|
||||
C00000000000000000000008 /* CSignalSupport.c in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
F00000000000000000000003 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
F00000000000000000000004 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
F00000000000000000000005 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = RHUM5U925W;
|
||||
EXECUTABLE_NAME = ReynaCLIHost;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(SRCROOT)/Sources/CSignalSupport/include",
|
||||
);
|
||||
INFOPLIST_FILE = ReynaCLIHost/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Reyna CLI";
|
||||
INFOPLIST_KEY_LSUIElement = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.reyna.cli.privacy-host";
|
||||
PRODUCT_NAME = "Reyna CLI";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "ReynaCLIHost/ReynaCLIHost-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
F00000000000000000000006 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = RHUM5U925W;
|
||||
EXECUTABLE_NAME = ReynaCLIHost;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(SRCROOT)/Sources/CSignalSupport/include",
|
||||
);
|
||||
INFOPLIST_FILE = ReynaCLIHost/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Reyna CLI";
|
||||
INFOPLIST_KEY_LSUIElement = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.reyna.cli.privacy-host";
|
||||
PRODUCT_NAME = "Reyna CLI";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "ReynaCLIHost/ReynaCLIHost-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
F00000000000000000000001 /* Build configuration list for PBXProject "ReynaCLIHost" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
F00000000000000000000003 /* Debug */,
|
||||
F00000000000000000000004 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
F00000000000000000000002 /* Build configuration list for PBXNativeTarget "Reyna CLI" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
F00000000000000000000005 /* Debug */,
|
||||
F00000000000000000000006 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = E00000000000000000000002 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "E00000000000000000000001"
|
||||
BuildableName = "Reyna CLI.app"
|
||||
BlueprintName = "Reyna CLI"
|
||||
ReferencedContainer = "container:ReynaCLIHost.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "E00000000000000000000001"
|
||||
BuildableName = "Reyna CLI.app"
|
||||
BlueprintName = "Reyna CLI"
|
||||
ReferencedContainer = "container:ReynaCLIHost.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "E00000000000000000000001"
|
||||
BuildableName = "Reyna CLI.app"
|
||||
BlueprintName = "Reyna CLI"
|
||||
ReferencedContainer = "container:ReynaCLIHost.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
// Xcode-owned macOS app entry — app-bundle-associated executable so TCC sees signed bundle identity.
|
||||
// Headless entry point preserves --socket and stdin JSON-lines modes.
|
||||
|
||||
@main
|
||||
struct ReynaCLIApp {
|
||||
static func main() {
|
||||
runReynaCLIHost(arguments: CommandLine.arguments)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTD/PLIST-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Reyna CLI</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ReynaCLIHost</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.reyna.cli.privacy-host</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Reyna CLI</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||
<string>Reyna CLI needs calendar access to list and manage your events locally.</string>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>Reyna CLI needs contacts access to search and manage your contacts locally.</string>
|
||||
<key>NSRemindersFullAccessUsageDescription</key>
|
||||
<string>Reyna CLI needs reminders access to list and manage your reminders locally.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#import "CSignalSupport.h"
|
||||
@@ -0,0 +1,43 @@
|
||||
#include "CSignalSupport.h"
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <signal.h>
|
||||
#include <stddef.h>
|
||||
|
||||
static char g_socket_path[104 * 4];
|
||||
static volatile sig_atomic_t g_has_path = 0;
|
||||
|
||||
void reyna_store_socket_path(const char *path) {
|
||||
if (!path) {
|
||||
g_socket_path[0] = '\0';
|
||||
g_has_path = 0;
|
||||
return;
|
||||
}
|
||||
strncpy(g_socket_path, path, sizeof(g_socket_path)-1);
|
||||
g_socket_path[sizeof(g_socket_path)-1] = '\0';
|
||||
g_has_path = 1;
|
||||
}
|
||||
|
||||
void reyna_cleanup_socket_sync(void) {
|
||||
if (!g_has_path) return;
|
||||
if (g_socket_path[0] == '\0') return;
|
||||
unlink(g_socket_path);
|
||||
}
|
||||
|
||||
static void reyna_signal_handler(int sig) {
|
||||
(void)sig;
|
||||
reyna_cleanup_socket_sync();
|
||||
_exit(0);
|
||||
}
|
||||
|
||||
void reyna_install_signal_handlers(void) {
|
||||
struct sigaction sa;
|
||||
memset(&sa, 0, sizeof(sa));
|
||||
sa.sa_handler = reyna_signal_handler;
|
||||
sigemptyset(&sa.sa_mask);
|
||||
sa.sa_flags = 0;
|
||||
sigaction(SIGTERM, &sa, NULL);
|
||||
sigaction(SIGINT, &sa, NULL);
|
||||
sigaction(SIGHUP, &sa, NULL);
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#ifndef CSignalSupport_h
|
||||
#define CSignalSupport_h
|
||||
#include <sys/types.h>
|
||||
|
||||
void reyna_store_socket_path(const char *path);
|
||||
void reyna_install_signal_handlers(void);
|
||||
void reyna_cleanup_socket_sync(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,4 @@
|
||||
module CSignalSupport {
|
||||
header "CSignalSupport.h"
|
||||
export *
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import ReynaCLIHostCore
|
||||
import Foundation
|
||||
|
||||
// Thin executable wrapper preserving AF_UNIX protocol — all logic lives in ReynaCLIHostCore shared library.
|
||||
// This file is the source of truth for the SwiftPM binary AND referenced by the Xcode app target's main.
|
||||
|
||||
runReynaCLIHost(arguments: CommandLine.arguments)
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
// Public entry point used by both SwiftPM executable and Xcode app target.
|
||||
// Preserves AF_UNIX privacy-host protocol exactly as before.
|
||||
// Headless design: socket-server or stdin JSON-lines mode only.
|
||||
|
||||
public func runReynaCLIHost(arguments: [String] = CommandLine.arguments) -> Never {
|
||||
if let idx = arguments.firstIndex(of: "--socket") {
|
||||
let nextIdx = idx + 1
|
||||
guard nextIdx < arguments.count else {
|
||||
fputs("error: --socket requires a path argument\n", stderr)
|
||||
Darwin.exit(2)
|
||||
}
|
||||
let socketPath = arguments[nextIdx]
|
||||
runSocketServer(socketPath: socketPath)
|
||||
} else {
|
||||
runStdinLoop()
|
||||
}
|
||||
}
|
||||
|
||||
func extractRecoverableId(from data: Data) -> String? {
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
||||
let id = obj["id"] as? String {
|
||||
return id
|
||||
}
|
||||
guard let str = String(data: data, encoding: .utf8) else { return nil }
|
||||
let pattern = "\"id\"\\s*:\\s*\"([^\"]*)\""
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []),
|
||||
let match = regex.firstMatch(in: str, options: [], range: NSRange(str.startIndex..., in: str)),
|
||||
match.numberOfRanges >= 2,
|
||||
let r = Range(match.range(at: 1), in: str) else {
|
||||
return nil
|
||||
}
|
||||
return String(str[r])
|
||||
}
|
||||
|
||||
func writeResponse(_ response: Response) {
|
||||
guard let jsonData = try? JSONEncoder().encode(response),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8),
|
||||
let outData = (jsonString + "\n").data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
FileHandle.standardOutput.write(outData)
|
||||
}
|
||||
|
||||
func handleLine(_ lineData: Data) {
|
||||
if lineData.isEmpty { return }
|
||||
if let s = String(data: lineData, encoding: .utf8),
|
||||
s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return
|
||||
}
|
||||
if let request = try? JSONDecoder().decode(Request.self, from: lineData) {
|
||||
let response = dispatch(request: request)
|
||||
writeResponse(response)
|
||||
} else {
|
||||
let recoveredId = extractRecoverableId(from: lineData)
|
||||
let err = ErrorPayload(code: "invalid_request", message: "Invalid request JSON")
|
||||
let resp = Response(id: recoveredId ?? "", ok: false, result: nil, error: err)
|
||||
writeResponse(resp)
|
||||
}
|
||||
}
|
||||
|
||||
func runStdinLoop() -> Never {
|
||||
while let line = readLine() {
|
||||
if line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { continue }
|
||||
if let data = line.data(using: .utf8) {
|
||||
handleLine(data)
|
||||
} else {
|
||||
let err = ErrorPayload(code: "invalid_request", message: "Invalid request encoding")
|
||||
let resp = Response(id: "", ok: false, result: nil, error: err)
|
||||
writeResponse(resp)
|
||||
}
|
||||
}
|
||||
Darwin.exit(0)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import Foundation
|
||||
import EventKit
|
||||
|
||||
// Explicit calendar authorization provider – ONLY location allowed to call requestFullAccessToEvents.
|
||||
// Production list path (EventKitCalendarProvider) must remain read-only and never prompt.
|
||||
|
||||
// Public auth status mirrored from EKAuthorizationStatus without importing EventKit into protocol file
|
||||
enum CalendarAuthorizationStatus: String, Equatable, Sendable {
|
||||
case authorized
|
||||
case notDetermined
|
||||
case denied
|
||||
case restricted
|
||||
case writeOnly
|
||||
case unknown
|
||||
}
|
||||
|
||||
protocol CalendarAuthorizationProviding: Sendable {
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus
|
||||
func requestFullAccess() throws -> Bool
|
||||
}
|
||||
|
||||
// Internal main-run-loop pumping bridge – bounded wait that pumps run loop instead of blocking it.
|
||||
// Provides deterministic seam via injected starter closure.
|
||||
struct EventKitMainRunLoopBridge: Sendable {
|
||||
typealias Starter = @Sendable (@escaping @Sendable (Bool, Error?) -> Void) -> Void
|
||||
|
||||
/// Waits up to `timeout` seconds for `starter` to invoke completion.
|
||||
/// The starter is expected to eventually call completion, potentially from main run loop.
|
||||
/// This method pumps the current run loop (which is the main run loop when called on main thread)
|
||||
/// so that EventKit's main-run-loop delivered completion can run instead of deadlocking.
|
||||
func requestAccess(timeout: TimeInterval = 30, starter: @escaping Starter) throws -> Bool {
|
||||
final class Box: @unchecked Sendable {
|
||||
var granted: Bool = false
|
||||
var error: Error? = nil
|
||||
var done: Bool = false
|
||||
let lock = NSLock()
|
||||
func setOnce(granted: Bool, error: Error?) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !done else { return false }
|
||||
self.granted = granted
|
||||
self.error = error
|
||||
self.done = true
|
||||
return true
|
||||
}
|
||||
func snapshot() -> (done: Bool, granted: Bool, error: Error?) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (done, granted, error)
|
||||
}
|
||||
}
|
||||
|
||||
let box = Box()
|
||||
let completion: @Sendable (Bool, Error?) -> Void = { granted, error in
|
||||
_ = box.setOnce(granted: granted, error: error)
|
||||
}
|
||||
|
||||
// Trigger the underlying async request
|
||||
starter(completion)
|
||||
|
||||
let deadline = Date(timeIntervalSinceNow: timeout)
|
||||
// Pump run loop until done or timeout. Uses RunLoop.current.run(mode:before:) to avoid busy spin.
|
||||
while true {
|
||||
let snap = box.snapshot()
|
||||
if snap.done { break }
|
||||
if Date() >= deadline { break }
|
||||
// 20ms slice – small enough to be responsive, large enough to avoid spin
|
||||
let next = Date(timeIntervalSinceNow: 0.02)
|
||||
_ = RunLoop.current.run(mode: .default, before: next)
|
||||
}
|
||||
|
||||
let final = box.snapshot()
|
||||
if !final.done {
|
||||
throw CalendarProviderError.unavailable("calendar authorization timed out")
|
||||
}
|
||||
if let err = final.error {
|
||||
throw CalendarProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
return final.granted
|
||||
}
|
||||
}
|
||||
|
||||
struct EventKitCalendarAuthorizationProvider: CalendarAuthorizationProviding {
|
||||
// Allow injection of bridge for tests while keeping default production behavior
|
||||
var bridge: EventKitMainRunLoopBridge = EventKitMainRunLoopBridge()
|
||||
|
||||
// Convert EK status to our enum
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus {
|
||||
let s = EKEventStore.authorizationStatus(for: .event)
|
||||
switch s {
|
||||
case .fullAccess, .authorized:
|
||||
return .authorized
|
||||
case .notDetermined:
|
||||
return .notDetermined
|
||||
case .denied:
|
||||
return .denied
|
||||
case .restricted:
|
||||
return .restricted
|
||||
case .writeOnly:
|
||||
return .writeOnly
|
||||
@unknown default:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
// Bounded async-to-sync bridge, max 30s, pumping main run loop. ONLY place calling requestFullAccessToEvents.
|
||||
func requestFullAccess() throws -> Bool {
|
||||
if #available(macOS 14.0, *) {
|
||||
return try bridge.requestAccess(timeout: 30) { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestFullAccessToEvents { granted, error in
|
||||
completion(granted, error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw CalendarProviderError.unavailable("requestFullAccessToEvents requires macOS 14+")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import Foundation
|
||||
import EventKit
|
||||
|
||||
// Production EventKit provider – read-only for list, mutating only for create.
|
||||
// Requirements:
|
||||
// - Only checks authorization status (never triggers prompt) for list/create.
|
||||
// - Lists calendars when status permits.
|
||||
// - Never calls prompting APIs except in auth provider.
|
||||
// - If permission absent/denied/restricted, throw permissionRequired.
|
||||
|
||||
struct EventKitCalendarProvider: CalendarListProviding, CalendarEventsListProviding, CalendarEventCreateProviding {
|
||||
|
||||
// MARK: - Auth check shared
|
||||
private func requireAuthorizedOrThrow() throws {
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
switch status {
|
||||
case .fullAccess, .authorized:
|
||||
break
|
||||
case .writeOnly:
|
||||
// writeOnly does not allow reading calendars/events; but for create we could allow? Task says already-authorized access but no permission request; map denied/not-determined to permission_required for all.
|
||||
// Simpler: for events list, writeOnly -> permissionRequired; for create, writeOnly should also require check but writeOnly actually allows writing. However to keep deterministic, attempt to respect writeOnly for create?
|
||||
// Spec: map denied/not-determined to permission_required, provider failures to calendar_unavailable.
|
||||
// To be safe: for list -> permissionRequired, for create we will check below differently? But shared throw would block create with writeOnly unnecessarily.
|
||||
// We differentiate inside methods. For this helper, allow writeOnly as authorized for mutating path? Caller should call specific check.
|
||||
throw CalendarProviderError.permissionRequired
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
private func requireAuthorizedForReadOrWrite(allowsWriteOnlyRead: Bool = false) throws {
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
switch status {
|
||||
case .fullAccess, .authorized:
|
||||
return
|
||||
case .writeOnly:
|
||||
if allowsWriteOnlyRead {
|
||||
return
|
||||
}
|
||||
// For read paths (list calendars, list events), writeOnly does NOT permit read -> permission_required
|
||||
throw CalendarProviderError.permissionRequired
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Calendar list (existing)
|
||||
func listCalendars() throws -> [CalendarListItem] {
|
||||
try requireAuthorizedForReadOrWrite(allowsWriteOnlyRead: false)
|
||||
|
||||
let store = EKEventStore()
|
||||
let ekCalendars = store.calendars(for: .event)
|
||||
|
||||
let items: [CalendarListItem] = ekCalendars.map { cal in
|
||||
let sourceTitle = cal.source.title
|
||||
let typeString: String
|
||||
switch cal.type {
|
||||
case .birthday:
|
||||
typeString = "birthday"
|
||||
case .calDAV:
|
||||
typeString = "caldav"
|
||||
case .exchange:
|
||||
typeString = "exchange"
|
||||
case .local:
|
||||
typeString = "local"
|
||||
case .subscription:
|
||||
typeString = "subscription"
|
||||
@unknown default:
|
||||
typeString = "unknown"
|
||||
}
|
||||
return CalendarListItem(
|
||||
id: cal.calendarIdentifier,
|
||||
title: cal.title,
|
||||
source: sourceTitle,
|
||||
type: typeString
|
||||
)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// MARK: - Calendar helpers for events
|
||||
private func allCalendars(from store: EKEventStore) -> [EKCalendar] {
|
||||
store.calendars(for: .event)
|
||||
}
|
||||
|
||||
private func resolveCalendarForList(store: EKEventStore, calendarId: String?, calendarTitle: String?) throws -> [EKCalendar] {
|
||||
// Return array of matching calendars (filtered)
|
||||
// Rules:
|
||||
// - if calendarId provided (stable ID), exact match only one; if unknown -> invalidRequest
|
||||
// - else if calendarTitle provided, exact title match; must be unique else invalidRequest (ambiguous) or unknown => invalidRequest
|
||||
// - else all calendars
|
||||
let calendars = allCalendars(from: store)
|
||||
|
||||
if let cid = calendarId, !cid.isEmpty {
|
||||
// ID wins
|
||||
if let found = calendars.first(where: { $0.calendarIdentifier == cid }) {
|
||||
return [found]
|
||||
} else {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(cid)")
|
||||
}
|
||||
}
|
||||
|
||||
if let title = calendarTitle, !title.isEmpty {
|
||||
let matched = calendars.filter { $0.title == title }
|
||||
if matched.isEmpty {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(title)")
|
||||
}
|
||||
if matched.count > 1 {
|
||||
throw CalendarProviderError.invalidRequest("Ambiguous calendar title: \(title) matches \(matched.count) calendars")
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
// no filter -> all
|
||||
return calendars
|
||||
}
|
||||
|
||||
private func resolveCalendarForCreate(store: EKEventStore, calendarId: String?, calendarTitle: String?) throws -> EKCalendar {
|
||||
// Create must not default to arbitrary.
|
||||
// If neither id nor title -> invalidRequest
|
||||
let calendars = allCalendars(from: store)
|
||||
|
||||
if let cid = calendarId, !cid.isEmpty {
|
||||
guard let found = calendars.first(where: { $0.calendarIdentifier == cid }) else {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(cid)")
|
||||
}
|
||||
if !found.allowsContentModifications {
|
||||
throw CalendarProviderError.invalidRequest("Calendar is read-only: \(found.title)")
|
||||
}
|
||||
// also check writable via allowsContentModifications; isImmutable also relevant but we use allowsContentModifications
|
||||
return found
|
||||
}
|
||||
|
||||
if let title = calendarTitle, !title.isEmpty {
|
||||
let matched = calendars.filter { $0.title == title }
|
||||
if matched.isEmpty {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(title)")
|
||||
}
|
||||
if matched.count > 1 {
|
||||
throw CalendarProviderError.invalidRequest("Ambiguous calendar title: \(title) matches \(matched.count) calendars")
|
||||
}
|
||||
let found = matched[0]
|
||||
if !found.allowsContentModifications {
|
||||
throw CalendarProviderError.invalidRequest("Calendar is read-only: \(found.title)")
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// No calendar specified – per spec "no default first arbitrary calendar"
|
||||
throw CalendarProviderError.invalidRequest("Calendar must be specified by id or exact unique title")
|
||||
}
|
||||
|
||||
// MARK: - Events list
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] {
|
||||
// Permission: read requires full access
|
||||
try requireAuthorizedForReadOrWrite(allowsWriteOnlyRead: false)
|
||||
|
||||
let store = EKEventStore()
|
||||
let targetCalendars: [EKCalendar]
|
||||
do {
|
||||
targetCalendars = try resolveCalendarForList(store: store, calendarId: calendarId, calendarTitle: calendarTitle)
|
||||
} catch let err as CalendarProviderError {
|
||||
throw err
|
||||
} catch {
|
||||
throw CalendarProviderError.unavailable("Calendar lookup failed")
|
||||
}
|
||||
|
||||
if targetCalendars.isEmpty {
|
||||
return []
|
||||
}
|
||||
|
||||
// EK predicate
|
||||
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: targetCalendars)
|
||||
let ekEvents: [EKEvent] = store.events(matching: predicate)
|
||||
|
||||
// Map and sort deterministically, enforce overlap check (predicate already does but ensure)
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
|
||||
var items: [CalendarEventItem] = []
|
||||
items.reserveCapacity(min(ekEvents.count, limit))
|
||||
|
||||
for ev in ekEvents {
|
||||
guard let evStart = ev.startDate, let evEnd = ev.endDate else { continue }
|
||||
// Enforce overlap (EventKit predicate should already overlap but safe)
|
||||
if evEnd < start || evStart > end { continue }
|
||||
|
||||
guard let cal = ev.calendar else { continue }
|
||||
let item = CalendarEventItem(
|
||||
id: ev.eventIdentifier ?? ev.calendarItemIdentifier,
|
||||
title: ev.title ?? "",
|
||||
start: iso.string(from: evStart),
|
||||
end: iso.string(from: evEnd),
|
||||
all_day: ev.isAllDay,
|
||||
calendar_id: cal.calendarIdentifier,
|
||||
calendar_title: cal.title,
|
||||
notes: ev.notes,
|
||||
location: ev.location
|
||||
)
|
||||
items.append(item)
|
||||
}
|
||||
|
||||
// Deterministic sort by start, then title, then id, then truncate to limit
|
||||
items.sort {
|
||||
if $0.start != $1.start { return $0.start < $1.start }
|
||||
if $0.title != $1.title { return $0.title < $1.title }
|
||||
return $0.id < $1.id
|
||||
}
|
||||
|
||||
if items.count > limit {
|
||||
return Array(items.prefix(limit))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// MARK: - Event create
|
||||
func createEvent(
|
||||
title: String,
|
||||
start: Date,
|
||||
end: Date,
|
||||
allDay: Bool,
|
||||
notes: String?,
|
||||
location: String?,
|
||||
calendarId: String?,
|
||||
calendarTitle: String?
|
||||
) throws -> CalendarEventItem {
|
||||
// For create, we allow fullAccess, authorized, and writeOnly (since writeOnly permits creation)
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
switch status {
|
||||
case .fullAccess, .authorized, .writeOnly:
|
||||
break
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
}
|
||||
|
||||
let store = EKEventStore()
|
||||
|
||||
let destination: EKCalendar
|
||||
do {
|
||||
destination = try resolveCalendarForCreate(store: store, calendarId: calendarId, calendarTitle: calendarTitle)
|
||||
} catch let err as CalendarProviderError {
|
||||
throw err
|
||||
} catch {
|
||||
throw CalendarProviderError.unavailable("Calendar lookup failed")
|
||||
}
|
||||
|
||||
// Validate start < end already done in dispatch, but double check
|
||||
if start >= end {
|
||||
throw CalendarProviderError.invalidRequest("start must occur before end")
|
||||
}
|
||||
|
||||
let ekEvent = EKEvent(eventStore: store)
|
||||
ekEvent.title = title
|
||||
ekEvent.startDate = start
|
||||
ekEvent.endDate = end
|
||||
ekEvent.isAllDay = allDay
|
||||
ekEvent.notes = notes
|
||||
ekEvent.location = location
|
||||
ekEvent.calendar = destination
|
||||
|
||||
do {
|
||||
try store.save(ekEvent, span: .thisEvent, commit: true)
|
||||
} catch {
|
||||
throw CalendarProviderError.unavailable("Failed to save event: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
|
||||
return CalendarEventItem(
|
||||
id: ekEvent.eventIdentifier ?? ekEvent.calendarItemIdentifier,
|
||||
title: ekEvent.title ?? title,
|
||||
start: iso.string(from: ekEvent.startDate ?? start),
|
||||
end: iso.string(from: ekEvent.endDate ?? end),
|
||||
all_day: ekEvent.isAllDay,
|
||||
calendar_id: destination.calendarIdentifier,
|
||||
calendar_title: destination.title,
|
||||
notes: ekEvent.notes,
|
||||
location: ekEvent.location
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import Contacts
|
||||
|
||||
// Explicit Contacts authorization provider – ONLY location allowed to call requestAccess(for:)
|
||||
enum ContactsBridgingError: Error, Equatable, Sendable {
|
||||
case timeout
|
||||
case unavailable(String)
|
||||
}
|
||||
|
||||
struct ContactsMainRunLoopBridge: Sendable {
|
||||
typealias Starter = @Sendable (@escaping @Sendable (Bool, Error?) -> Void) -> Void
|
||||
|
||||
func requestAccess(timeout: TimeInterval = 30, starter: @escaping Starter) throws -> Bool {
|
||||
final class Box: @unchecked Sendable {
|
||||
var granted: Bool = false
|
||||
var error: Error? = nil
|
||||
var done: Bool = false
|
||||
let lock = NSLock()
|
||||
func setOnce(granted: Bool, error: Error?) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !done else { return false }
|
||||
self.granted = granted
|
||||
self.error = error
|
||||
self.done = true
|
||||
return true
|
||||
}
|
||||
func snapshot() -> (done: Bool, granted: Bool, error: Error?) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (done, granted, error)
|
||||
}
|
||||
}
|
||||
|
||||
let box = Box()
|
||||
let completion: @Sendable (Bool, Error?) -> Void = { granted, error in
|
||||
_ = box.setOnce(granted: granted, error: error)
|
||||
}
|
||||
|
||||
starter(completion)
|
||||
|
||||
let deadline = Date(timeIntervalSinceNow: timeout)
|
||||
while true {
|
||||
let snap = box.snapshot()
|
||||
if snap.done { break }
|
||||
if Date() >= deadline { break }
|
||||
let next = Date(timeIntervalSinceNow: 0.02)
|
||||
_ = RunLoop.current.run(mode: .default, before: next)
|
||||
}
|
||||
|
||||
let final = box.snapshot()
|
||||
if !final.done {
|
||||
throw ContactsProviderError.unavailable("contacts authorization timed out")
|
||||
}
|
||||
if let err = final.error {
|
||||
throw ContactsProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
return final.granted
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactsAuthorizationProvider: ContactsAuthorizationProviding {
|
||||
var bridge: ContactsMainRunLoopBridge = ContactsMainRunLoopBridge()
|
||||
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus {
|
||||
let s = CNContactStore.authorizationStatus(for: .contacts)
|
||||
switch s {
|
||||
case .authorized:
|
||||
return .authorized
|
||||
case .notDetermined:
|
||||
return .notDetermined
|
||||
case .denied:
|
||||
return .denied
|
||||
case .restricted:
|
||||
return .restricted
|
||||
@unknown default:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
func requestAccess() throws -> Bool {
|
||||
return try bridge.requestAccess(timeout: 30) { completion in
|
||||
let store = CNContactStore()
|
||||
store.requestAccess(for: .contacts) { granted, error in
|
||||
completion(granted, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import Foundation
|
||||
import Contacts
|
||||
|
||||
// MARK: - Contacts data models
|
||||
struct ContactListItem: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let organization: String
|
||||
let modifiedAt: String
|
||||
}
|
||||
|
||||
struct ContactEmailLabelValue: Codable, Equatable, Sendable {
|
||||
let label: String
|
||||
let value: String
|
||||
}
|
||||
|
||||
struct ContactPhoneLabelValue: Codable, Equatable, Sendable {
|
||||
let label: String
|
||||
let value: String
|
||||
}
|
||||
|
||||
struct ContactDetailItem: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let firstName: String
|
||||
let lastName: String
|
||||
let organization: String
|
||||
let jobTitle: String
|
||||
let emails: [ContactEmailLabelValue]
|
||||
let phones: [ContactPhoneLabelValue]
|
||||
let modifiedAt: String
|
||||
}
|
||||
|
||||
struct ContactCreateResult: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let organization: String
|
||||
}
|
||||
|
||||
// MARK: - Contacts provider errors
|
||||
enum ContactsProviderError: Error, Equatable, Sendable {
|
||||
case permissionRequired
|
||||
case permissionDenied
|
||||
case unavailable(String)
|
||||
case invalidRequest(String)
|
||||
case notFound(String)
|
||||
}
|
||||
|
||||
// MARK: - Contact provider protocols
|
||||
protocol ContactsAuthorizationProviding: Sendable {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus
|
||||
func requestAccess() throws -> Bool
|
||||
}
|
||||
|
||||
enum ContactsAuthorizationStatus: String, Equatable, Sendable {
|
||||
case authorized
|
||||
case notDetermined
|
||||
case denied
|
||||
case restricted
|
||||
case unknown
|
||||
}
|
||||
|
||||
protocol ContactsSearchProviding: Sendable {
|
||||
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem]
|
||||
}
|
||||
|
||||
protocol ContactsReadProviding: Sendable {
|
||||
func readContact(id: String) throws -> ContactDetailItem
|
||||
}
|
||||
|
||||
protocol ContactsCreateProviding: Sendable {
|
||||
func createContact(
|
||||
firstName: String?,
|
||||
lastName: String?,
|
||||
organization: String?,
|
||||
jobTitle: String?,
|
||||
note: String?,
|
||||
email: ContactEmailLabelValue?,
|
||||
phone: ContactPhoneLabelValue?
|
||||
) throws -> ContactCreateResult
|
||||
}
|
||||
|
||||
protocol FullContactsProviding: ContactsSearchProviding, ContactsReadProviding, ContactsCreateProviding {}
|
||||
|
||||
// MARK: - Production Contacts providers
|
||||
|
||||
private func displayNameFromFetchedParts(givenName: String, familyName: String) -> String {
|
||||
let combined = "\(givenName) \(familyName)".trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let squashed = combined.components(separatedBy: .whitespaces).filter { !$0.isEmpty }.joined(separator: " ")
|
||||
return squashed
|
||||
}
|
||||
|
||||
struct ContactsSearchProvider: ContactsSearchProviding {
|
||||
private func requireAuthorized() throws {
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] {
|
||||
try requireAuthorized()
|
||||
let store = CNContactStore()
|
||||
let keys: [CNKeyDescriptor] = [
|
||||
CNContactIdentifierKey as CNKeyDescriptor,
|
||||
CNContactGivenNameKey as CNKeyDescriptor,
|
||||
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||
CNContactOrganizationNameKey as CNKeyDescriptor
|
||||
]
|
||||
let fetchRequest = CNContactFetchRequest(keysToFetch: keys)
|
||||
var items: [ContactListItem] = []
|
||||
let q = query?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let shouldFilter = !(q?.isEmpty ?? true)
|
||||
|
||||
do {
|
||||
try store.enumerateContacts(with: fetchRequest) { contact, stop in
|
||||
// Production crash fix: the formatter accesses unfetched
|
||||
// properties like middleName (CNPropertyNotFetchedException is ObjC exception
|
||||
// and is uncatchable in Swift). Construct deterministically from only fetched keys.
|
||||
let fullName = displayNameFromFetchedParts(givenName: contact.givenName, familyName: contact.familyName)
|
||||
let org = contact.organizationName
|
||||
if shouldFilter, let lowerQ = q {
|
||||
let haystack = "\(fullName)\n\(org)".lowercased()
|
||||
if !haystack.contains(lowerQ) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Deterministic: do not leak current time; nil modification date yields stable empty string
|
||||
let modifiedStr: String = ""
|
||||
items.append(ContactListItem(
|
||||
id: contact.identifier,
|
||||
name: fullName,
|
||||
organization: org,
|
||||
modifiedAt: modifiedStr
|
||||
))
|
||||
if items.count >= limit {
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
} catch let err as NSError {
|
||||
// Permission or other failure
|
||||
if err.domain == CNErrorDomain {
|
||||
throw ContactsProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
throw ContactsProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
|
||||
// Deterministic: sorted by name then org then id
|
||||
items.sort {
|
||||
if $0.name != $1.name { return $0.name < $1.name }
|
||||
if $0.organization != $1.organization { return $0.organization < $1.organization }
|
||||
return $0.id < $1.id
|
||||
}
|
||||
if items.count > limit {
|
||||
return Array(items.prefix(limit))
|
||||
}
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactsReadProvider: ContactsReadProviding {
|
||||
private func requireAuthorized() throws {
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
func readContact(id: String) throws -> ContactDetailItem {
|
||||
guard !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw ContactsProviderError.invalidRequest("Missing required argument: id")
|
||||
}
|
||||
try requireAuthorized()
|
||||
let store = CNContactStore()
|
||||
let keys: [CNKeyDescriptor] = [
|
||||
CNContactIdentifierKey as CNKeyDescriptor,
|
||||
CNContactGivenNameKey as CNKeyDescriptor,
|
||||
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||
CNContactOrganizationNameKey as CNKeyDescriptor,
|
||||
CNContactJobTitleKey as CNKeyDescriptor,
|
||||
CNContactEmailAddressesKey as CNKeyDescriptor,
|
||||
CNContactPhoneNumbersKey as CNKeyDescriptor
|
||||
]
|
||||
do {
|
||||
let contact = try store.unifiedContact(withIdentifier: id, keysToFetch: keys)
|
||||
// Same crash root cause as search: the system formatter can touch unfetched keys.
|
||||
// Use only the keys we fetched to avoid ObjC CNPropertyNotFetchedException.
|
||||
let fullName = displayNameFromFetchedParts(givenName: contact.givenName, familyName: contact.familyName)
|
||||
// Deterministic: nil modification date yields stable empty string, not current time
|
||||
let modifiedStr = ""
|
||||
|
||||
let emails = contact.emailAddresses.map { labeled in
|
||||
ContactEmailLabelValue(
|
||||
label: CNLabeledValue<NSString>.localizedString(forLabel: labeled.label ?? ""),
|
||||
value: labeled.value as String
|
||||
)
|
||||
}
|
||||
let phones = contact.phoneNumbers.map { labeled in
|
||||
ContactPhoneLabelValue(
|
||||
label: CNLabeledValue<CNPhoneNumber>.localizedString(forLabel: labeled.label ?? ""),
|
||||
value: labeled.value.stringValue
|
||||
)
|
||||
}
|
||||
return ContactDetailItem(
|
||||
id: contact.identifier,
|
||||
name: fullName,
|
||||
firstName: contact.givenName,
|
||||
lastName: contact.familyName,
|
||||
organization: contact.organizationName,
|
||||
jobTitle: contact.jobTitle,
|
||||
emails: emails,
|
||||
phones: phones,
|
||||
modifiedAt: modifiedStr
|
||||
)
|
||||
} catch let err as CNError {
|
||||
if err.code == .recordDoesNotExist {
|
||||
throw ContactsProviderError.notFound("Contact not found: \(id)")
|
||||
}
|
||||
throw ContactsProviderError.unavailable(err.localizedDescription)
|
||||
} catch let err as ContactsProviderError {
|
||||
throw err
|
||||
} catch {
|
||||
throw ContactsProviderError.unavailable(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactsCreateProvider: ContactsCreateProviding {
|
||||
private func requireAuthorized() throws {
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
func createContact(
|
||||
firstName: String?,
|
||||
lastName: String?,
|
||||
organization: String?,
|
||||
jobTitle: String?,
|
||||
note: String?,
|
||||
email: ContactEmailLabelValue?,
|
||||
phone: ContactPhoneLabelValue?
|
||||
) throws -> ContactCreateResult {
|
||||
try requireAuthorized()
|
||||
let mutable = CNMutableContact()
|
||||
mutable.givenName = firstName ?? ""
|
||||
mutable.familyName = lastName ?? ""
|
||||
mutable.organizationName = organization ?? ""
|
||||
mutable.jobTitle = jobTitle ?? ""
|
||||
if let n = note {
|
||||
mutable.note = n
|
||||
}
|
||||
if let em = email {
|
||||
mutable.emailAddresses = [CNLabeledValue(label: em.label.isEmpty ? CNLabelWork : em.label, value: em.value as NSString)]
|
||||
}
|
||||
if let ph = phone {
|
||||
mutable.phoneNumbers = [CNLabeledValue(label: ph.label.isEmpty ? CNLabelPhoneNumberMobile : ph.label, value: CNPhoneNumber(stringValue: ph.value))]
|
||||
}
|
||||
let store = CNContactStore()
|
||||
let saveRequest = CNSaveRequest()
|
||||
saveRequest.add(mutable, toContainerWithIdentifier: nil)
|
||||
do {
|
||||
try store.execute(saveRequest)
|
||||
} catch let err as NSError {
|
||||
throw ContactsProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
// CNMutableContact doesn't trigger key-fetch checks; safe deterministic construction still.
|
||||
// Prefer only locally available strings – avoids future formatter regressions.
|
||||
let fullName = displayNameFromFetchedParts(givenName: mutable.givenName, familyName: mutable.familyName)
|
||||
return ContactCreateResult(
|
||||
id: mutable.identifier,
|
||||
name: fullName,
|
||||
organization: organization ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
import EventKit
|
||||
|
||||
// ONLY place allowed to call requestFullAccessToReminders
|
||||
enum RemindersAuthorizationStatus: String, Equatable, Sendable {
|
||||
case authorized
|
||||
case notDetermined
|
||||
case denied
|
||||
case restricted
|
||||
case writeOnly
|
||||
case unknown
|
||||
}
|
||||
|
||||
protocol RemindersAuthorizationProviding: Sendable {
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus
|
||||
func requestFullAccess() throws -> Bool
|
||||
}
|
||||
|
||||
struct RemindersMainRunLoopBridge: Sendable {
|
||||
typealias Starter = @Sendable (@escaping @Sendable (Bool, Error?) -> Void) -> Void
|
||||
|
||||
func requestAccess(timeout: TimeInterval = 30, starter: @escaping Starter) throws -> Bool {
|
||||
final class Box: @unchecked Sendable {
|
||||
var granted: Bool = false
|
||||
var error: Error? = nil
|
||||
var done: Bool = false
|
||||
let lock = NSLock()
|
||||
func setOnce(granted: Bool, error: Error?) -> Bool {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
guard !done else { return false }
|
||||
self.granted = granted
|
||||
self.error = error
|
||||
self.done = true
|
||||
return true
|
||||
}
|
||||
func snapshot() -> (done: Bool, granted: Bool, error: Error?) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return (done, granted, error)
|
||||
}
|
||||
}
|
||||
|
||||
let box = Box()
|
||||
let completion: @Sendable (Bool, Error?) -> Void = { granted, error in
|
||||
_ = box.setOnce(granted: granted, error: error)
|
||||
}
|
||||
|
||||
starter(completion)
|
||||
|
||||
let deadline = Date(timeIntervalSinceNow: timeout)
|
||||
while true {
|
||||
let snap = box.snapshot()
|
||||
if snap.done { break }
|
||||
if Date() >= deadline { break }
|
||||
let next = Date(timeIntervalSinceNow: 0.02)
|
||||
_ = RunLoop.current.run(mode: .default, before: next)
|
||||
}
|
||||
|
||||
let final = box.snapshot()
|
||||
if !final.done {
|
||||
throw RemindersProviderError.unavailable("reminders authorization timed out")
|
||||
}
|
||||
if let err = final.error {
|
||||
throw RemindersProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
return final.granted
|
||||
}
|
||||
}
|
||||
|
||||
struct RemindersAuthorizationProvider: RemindersAuthorizationProviding {
|
||||
var bridge: RemindersMainRunLoopBridge = RemindersMainRunLoopBridge()
|
||||
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus {
|
||||
let s = EKEventStore.authorizationStatus(for: .reminder)
|
||||
switch s {
|
||||
case .fullAccess, .authorized:
|
||||
return .authorized
|
||||
case .notDetermined:
|
||||
return .notDetermined
|
||||
case .denied:
|
||||
return .denied
|
||||
case .restricted:
|
||||
return .restricted
|
||||
case .writeOnly:
|
||||
return .writeOnly
|
||||
@unknown default:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
func requestFullAccess() throws -> Bool {
|
||||
if #available(macOS 14.0, *) {
|
||||
return try bridge.requestAccess(timeout: 30) { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestFullAccessToReminders { granted, error in
|
||||
completion(granted, error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback for macOS 13: requestAccess(to: .reminder)
|
||||
return try bridge.requestAccess(timeout: 30) { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestAccess(to: .reminder) { granted, error in
|
||||
completion(granted, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import Foundation
|
||||
import EventKit
|
||||
|
||||
// MARK: - Reminders data models
|
||||
struct ReminderListItem: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let title: String
|
||||
let source: String
|
||||
let type: String // local/caldav/exchange/etc
|
||||
}
|
||||
|
||||
struct ReminderItem: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let list_id: String
|
||||
let list_title: String
|
||||
let title: String
|
||||
let notes: String?
|
||||
let completed: Bool
|
||||
let due: String? // ISO8601 or nil
|
||||
let priority: Int // 0-9 (EKReminderPriority mapped)
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case list_id
|
||||
case list_title
|
||||
case title
|
||||
case notes
|
||||
case completed
|
||||
case due
|
||||
case priority
|
||||
}
|
||||
}
|
||||
|
||||
struct ReminderCreateResult: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let list_id: String
|
||||
let list_title: String
|
||||
let title: String
|
||||
}
|
||||
|
||||
enum RemindersProviderError: Error, Equatable, Sendable {
|
||||
case permissionRequired
|
||||
case permissionDenied
|
||||
case unavailable(String)
|
||||
case invalidRequest(String)
|
||||
|
||||
var isPermission: Bool {
|
||||
if case .permissionRequired = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider protocols
|
||||
protocol RemindersListsProviding: Sendable {
|
||||
func listReminderLists() throws -> [ReminderListItem]
|
||||
}
|
||||
|
||||
protocol RemindersListProviding: Sendable {
|
||||
func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem]
|
||||
}
|
||||
|
||||
protocol RemindersCreateProviding: Sendable {
|
||||
func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult
|
||||
}
|
||||
|
||||
protocol FullRemindersProviding: RemindersListsProviding, RemindersListProviding, RemindersCreateProviding {}
|
||||
|
||||
// MARK: - Production EventKit provider (scoped read non-prompt; create requires explicit list)
|
||||
struct EventKitRemindersProvider: RemindersListsProviding, RemindersListProviding, RemindersCreateProviding {
|
||||
|
||||
// Shared auth check – only checks status, never prompts
|
||||
private func requireAuthorizedForRead() throws {
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
switch status {
|
||||
case .fullAccess, .authorized:
|
||||
return
|
||||
case .writeOnly:
|
||||
// writeOnly does not permit listing reminders per EventKit; treat as permission_required for list operations
|
||||
throw RemindersProviderError.permissionRequired
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw RemindersProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw RemindersProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
private func requireAuthorizedForCreate() throws {
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
switch status {
|
||||
case .fullAccess, .authorized, .writeOnly:
|
||||
return
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw RemindersProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw RemindersProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
private func allReminderCalendars(store: EKEventStore) -> [EKCalendar] {
|
||||
store.calendars(for: .reminder)
|
||||
}
|
||||
|
||||
private func resolveForList(store: EKEventStore, listId: String?, listTitle: String?) throws -> [EKCalendar] {
|
||||
let calendars = allReminderCalendars(store: store)
|
||||
if let lid = listId, !lid.isEmpty {
|
||||
if let found = calendars.first(where: { $0.calendarIdentifier == lid }) {
|
||||
return [found]
|
||||
} else {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list not found: \(lid)")
|
||||
}
|
||||
}
|
||||
if let title = listTitle, !title.isEmpty {
|
||||
let matched = calendars.filter { $0.title == title }
|
||||
if matched.isEmpty {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list not found: \(title)")
|
||||
}
|
||||
if matched.count > 1 {
|
||||
throw RemindersProviderError.invalidRequest("Ambiguous reminders list title: \(title) matches \(matched.count) lists")
|
||||
}
|
||||
return matched
|
||||
}
|
||||
return calendars
|
||||
}
|
||||
|
||||
private func resolveForCreate(store: EKEventStore, listId: String?, listTitle: String?) throws -> EKCalendar {
|
||||
let calendars = allReminderCalendars(store: store)
|
||||
if let lid = listId, !lid.isEmpty {
|
||||
guard let found = calendars.first(where: { $0.calendarIdentifier == lid }) else {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list not found: \(lid)")
|
||||
}
|
||||
if !found.allowsContentModifications {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list is read-only: \(found.title)")
|
||||
}
|
||||
return found
|
||||
}
|
||||
if let title = listTitle, !title.isEmpty {
|
||||
let matched = calendars.filter { $0.title == title }
|
||||
if matched.isEmpty {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list not found: \(title)")
|
||||
}
|
||||
if matched.count > 1 {
|
||||
throw RemindersProviderError.invalidRequest("Ambiguous reminders list title: \(title) matches \(matched.count) lists")
|
||||
}
|
||||
let found = matched[0]
|
||||
if !found.allowsContentModifications {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list is read-only: \(found.title)")
|
||||
}
|
||||
return found
|
||||
}
|
||||
throw RemindersProviderError.invalidRequest("Reminders list must be specified by id or exact unique title")
|
||||
}
|
||||
|
||||
// MARK: - lists
|
||||
func listReminderLists() throws -> [ReminderListItem] {
|
||||
try requireAuthorizedForRead()
|
||||
let store = EKEventStore()
|
||||
let calendars = allReminderCalendars(store: store)
|
||||
return calendars.map { cal in
|
||||
let sourceTitle = cal.source.title
|
||||
let typeString: String
|
||||
switch cal.type {
|
||||
case .local: typeString = "local"
|
||||
case .calDAV: typeString = "caldav"
|
||||
case .exchange: typeString = "exchange"
|
||||
case .subscription: typeString = "subscription"
|
||||
case .birthday: typeString = "birthday"
|
||||
@unknown default: typeString = "unknown"
|
||||
}
|
||||
return ReminderListItem(id: cal.calendarIdentifier, title: cal.title, source: sourceTitle, type: typeString)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - list reminders
|
||||
func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem] {
|
||||
try requireAuthorizedForRead()
|
||||
let store = EKEventStore()
|
||||
let targetCalendars: [EKCalendar]
|
||||
do {
|
||||
targetCalendars = try resolveForList(store: store, listId: listId, listTitle: listTitle)
|
||||
} catch let e as RemindersProviderError {
|
||||
throw e
|
||||
} catch {
|
||||
throw RemindersProviderError.unavailable("Reminders lookup failed")
|
||||
}
|
||||
if targetCalendars.isEmpty { return [] }
|
||||
|
||||
let predicate = store.predicateForReminders(in: targetCalendars)
|
||||
var fetched: [EKReminder] = []
|
||||
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
var fetchError: Error? = nil
|
||||
store.fetchReminders(matching: predicate) { rems in
|
||||
fetched = rems ?? []
|
||||
sem.signal()
|
||||
}
|
||||
// fetchReminders is async on newer APIs? In EventKit even on macOS 13 fetchReminders matching is async via completion.
|
||||
// Wait bounded 10s
|
||||
let waitRes = sem.wait(timeout: .now() + 10)
|
||||
if waitRes == .timedOut {
|
||||
throw RemindersProviderError.unavailable("Reminders fetch timed out")
|
||||
}
|
||||
if let err = fetchError {
|
||||
throw RemindersProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
|
||||
var items: [ReminderItem] = []
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
|
||||
for rem in fetched {
|
||||
let isCompleted = rem.isCompleted
|
||||
if let filterCompleted = completed, filterCompleted != isCompleted { continue }
|
||||
guard let cal = rem.calendar else { continue }
|
||||
let dueStr: String?
|
||||
if let comps = rem.dueDateComponents, let d = Calendar.current.date(from: comps) {
|
||||
dueStr = iso.string(from: d)
|
||||
} else {
|
||||
dueStr = nil
|
||||
}
|
||||
let item = ReminderItem(
|
||||
id: rem.calendarItemIdentifier,
|
||||
list_id: cal.calendarIdentifier,
|
||||
list_title: cal.title,
|
||||
title: rem.title ?? "",
|
||||
notes: rem.notes,
|
||||
completed: isCompleted,
|
||||
due: dueStr,
|
||||
priority: rem.priority
|
||||
)
|
||||
items.append(item)
|
||||
}
|
||||
|
||||
// Deterministic sort: due, title, id
|
||||
items.sort {
|
||||
let due0 = $0.due ?? ""
|
||||
let due1 = $1.due ?? ""
|
||||
if due0 != due1 { return due0 < due1 }
|
||||
if $0.title != $1.title { return $0.title < $1.title }
|
||||
return $0.id < $1.id
|
||||
}
|
||||
|
||||
if items.count > limit {
|
||||
return Array(items.prefix(limit))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// MARK: - create
|
||||
func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult {
|
||||
try requireAuthorizedForCreate()
|
||||
guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw RemindersProviderError.invalidRequest("Missing required argument: title")
|
||||
}
|
||||
|
||||
let store = EKEventStore()
|
||||
let destination: EKCalendar
|
||||
do {
|
||||
destination = try resolveForCreate(store: store, listId: listId, listTitle: listTitle)
|
||||
} catch let e as RemindersProviderError {
|
||||
throw e
|
||||
} catch {
|
||||
throw RemindersProviderError.unavailable("Reminders list lookup failed")
|
||||
}
|
||||
|
||||
let rem = EKReminder(eventStore: store)
|
||||
rem.title = title
|
||||
rem.calendar = destination
|
||||
rem.notes = notes
|
||||
if let p = priority {
|
||||
rem.priority = p
|
||||
}
|
||||
if let dueDate = due {
|
||||
let comps = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: dueDate)
|
||||
rem.dueDateComponents = comps
|
||||
}
|
||||
|
||||
do {
|
||||
try store.save(rem, commit: true)
|
||||
} catch {
|
||||
throw RemindersProviderError.unavailable("Failed to save reminder: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
return ReminderCreateResult(
|
||||
id: rem.calendarItemIdentifier,
|
||||
list_id: destination.calendarIdentifier,
|
||||
list_title: destination.title,
|
||||
title: rem.title ?? title
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
// MARK: - Pure, testable validation types
|
||||
|
||||
struct LStatInfo {
|
||||
var uid: uid_t
|
||||
var mode: mode_t // full st_mode
|
||||
var isSymlink: Bool
|
||||
var isDir: Bool
|
||||
var exists: Bool
|
||||
}
|
||||
|
||||
/// Authoritative result of an lstat call: present, absent (ENOENT), or failed with errno.
|
||||
enum LStatResult {
|
||||
case present(LStatInfo)
|
||||
case absent
|
||||
case failed(errnoCode: Int32)
|
||||
}
|
||||
|
||||
/// New authoritative provider that never swallows errors.
|
||||
typealias LStatResultProvider = (String) -> LStatResult
|
||||
|
||||
/// Legacy optional provider kept for existing pure tests: nil == absent (ENOENT).
|
||||
/// New code should use LStatResultProvider.
|
||||
typealias LStatProvider = (String) -> LStatInfo?
|
||||
|
||||
// MARK: - Trust boundary documentation
|
||||
|
||||
/*
|
||||
Trust boundary for socket parent-chain validation – tiered model:
|
||||
|
||||
Tiers (evaluated per existing component, fail-closed on lstat errors):
|
||||
|
||||
1) Platform-trusted ancestors – explicit allowlist ONLY:
|
||||
"/", "/Users", "/private", "/var", "/tmp",
|
||||
"/private/tmp", "/var/tmp", "/private/var", "/private/var/tmp"
|
||||
Hard-coded in `platformTrustedRootPaths`.
|
||||
- lstat non-symlink dir (except /var and /tmp which are known macOS symlinks and allowed as symlink)
|
||||
- uid 0
|
||||
- non-tmp platform paths ("/", "/Users", "/private", "/private/var"): no group/other write (mode & 022 == 0), 0755 allowed
|
||||
- tmp platform paths ("/private/tmp", "/var/tmp", "/private/var/tmp", plus "/tmp","/var" as dirs): uid 0 only, may be 1777 sticky
|
||||
|
||||
2) User-owned intermediate ancestors (e.g. $HOME = /Users/<user>, ~/Library, ~/Library/Application Support, ...):
|
||||
- not a symlink
|
||||
- a directory
|
||||
- owned by current uid (getuid())
|
||||
- no group/other *write* (mode & 022 == 0)
|
||||
-> allows 0700, 0750, 0755 (standard macOS home is 0750 = rwxr-x---) but rejects 0770/0777 or any writable bit
|
||||
Reason: home 0750 is default on some installs; privacy is still enforced by tier 3.
|
||||
|
||||
3) Dedicated runtime socket parent – the immediate parent dir of the socket (e.g. .../reyna-cli/privacy):
|
||||
- not a symlink
|
||||
- a directory
|
||||
- owned by current uid
|
||||
- strictly no group/other bits at all (mode & 077 == 0) => 0700 family only, rejects 0750/0755
|
||||
+ socket file itself must be 0600 (enforced in SocketServer bind/chmod)
|
||||
|
||||
- Never trust arbitrary root-owned intermediate paths outside explicit allowlist.
|
||||
|
||||
This is the fix for: home 0750 was incorrectly rejected (validator required 0700 for all user components),
|
||||
causing "Refusing socket path: parent component /Users/<user> has group/other permissions: 750".
|
||||
Now tier 2 allows 0750 for home/intermediates, tier 3 keeps 0700 for the privacy dir.
|
||||
*/
|
||||
|
||||
let platformTrustedRootPaths: Set<String> = [
|
||||
"/",
|
||||
"/Users",
|
||||
"/private",
|
||||
"/var",
|
||||
"/tmp",
|
||||
"/private/tmp",
|
||||
"/var/tmp",
|
||||
"/private/var",
|
||||
"/private/var/tmp"
|
||||
]
|
||||
|
||||
// Symlink-allowed platform paths – macOS ships /tmp -> private/tmp and /var -> private/var
|
||||
let platformSymlinkAllowedPaths: Set<String> = [
|
||||
"/tmp",
|
||||
"/var"
|
||||
]
|
||||
|
||||
func isRootTrustedPath(_ p: String) -> Bool {
|
||||
return platformTrustedRootPaths.contains(p)
|
||||
}
|
||||
|
||||
func isSymlinkAllowedPlatformPath(_ p: String) -> Bool {
|
||||
return platformSymlinkAllowedPaths.contains(p)
|
||||
}
|
||||
|
||||
func rejectIfDotComponentsPure(in socketPath: String) throws {
|
||||
let url = URL(fileURLWithPath: socketPath)
|
||||
for comp in url.pathComponents {
|
||||
if comp == "." || comp == ".." {
|
||||
throw NSError(domain: "SocketServer", code: 20, userInfo: [NSLocalizedDescriptionKey: "Socket path must not contain '.' or '..' components: \(socketPath)"])
|
||||
}
|
||||
}
|
||||
let standardized = url.standardized.path
|
||||
if standardized != socketPath {
|
||||
let stdComps = URL(fileURLWithPath: standardized).pathComponents
|
||||
let origComps = url.pathComponents
|
||||
if stdComps != origComps {
|
||||
for c in stdComps {
|
||||
if c == "." || c == ".." {
|
||||
throw NSError(domain: "SocketServer", code: 21, userInfo: [NSLocalizedDescriptionKey: "Socket path contains invalid components after standardization"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Core validation using authoritative provider
|
||||
|
||||
private func lstatInfo(from st: stat) -> LStatInfo {
|
||||
let isSymlink = (st.st_mode & S_IFMT) == S_IFLNK
|
||||
let isDir = (st.st_mode & S_IFMT) == S_IFDIR
|
||||
// For symlink itself, isDir should be false so caller can distinguish
|
||||
return LStatInfo(uid: st.st_uid, mode: st.st_mode, isSymlink: isSymlink, isDir: isSymlink ? false : isDir, exists: true)
|
||||
}
|
||||
|
||||
/// Single-component authoritative validator reused by both chain validation and ensureParentDirectories.
|
||||
/// This is the sole place that encodes trusted-root vs user-owned policy.
|
||||
/// Tiers:
|
||||
/// 1) platform trusted (explicit allowlist) – uid 0, dir, no g/o write except tmp exemptions, symlink allowed only for /tmp / /var
|
||||
/// 2) user-owned intermediate ancestors – uid current, not symlink, dir, mode & 022 == 0 (allows 0700/0750/0755, rejects writable)
|
||||
/// 3) dedicated runtime parent (immediate socket parent) – uid current, not symlink, dir, mode & 077 == 0 (requires 0700 family)
|
||||
///
|
||||
/// - For symlink: only /tmp and /var may be symlink (macOS aliases), else reject.
|
||||
/// - For non-dir file: always reject (including /tmp /var as dir target).
|
||||
func validateSingleLStatInfoOrThrow(path: String, info: LStatInfo, currentUID: uid_t, isDedicatedRuntimeParent: Bool = false) throws {
|
||||
if info.isSymlink {
|
||||
if isSymlinkAllowedPlatformPath(path) {
|
||||
return
|
||||
}
|
||||
throw NSError(domain: "SocketServer", code: 23, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) is a symlink"])
|
||||
}
|
||||
if !info.isDir {
|
||||
// A regular file (or other non-dir) at any parent component, including /tmp /var, must reject
|
||||
throw NSError(domain: "SocketServer", code: 10, userInfo: [NSLocalizedDescriptionKey: "Parent path exists but is not a directory: \(path)"])
|
||||
}
|
||||
if path == "/" || isRootTrustedPath(path) {
|
||||
if path == "/tmp" || path == "/var" {
|
||||
if info.uid != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"])
|
||||
}
|
||||
return
|
||||
}
|
||||
if path == "/private/tmp" || path == "/var/tmp" || path == "/private/var/tmp" {
|
||||
if info.uid != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"])
|
||||
}
|
||||
return
|
||||
}
|
||||
if info.uid != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"])
|
||||
}
|
||||
if (info.mode & 0o022) != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 25, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) has group/other permissions: \(String(info.mode & 0o777, radix: 8))"])
|
||||
}
|
||||
return
|
||||
}
|
||||
if info.uid != currentUID {
|
||||
throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"])
|
||||
}
|
||||
let perms = info.mode & 0o777
|
||||
if isDedicatedRuntimeParent {
|
||||
// Tier 3: dedicated runtime must be exactly 0700 family – no group/other bits
|
||||
if (perms & 0o077) != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 25, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) has group/other permissions: \(String(perms, radix: 8))"])
|
||||
}
|
||||
} else {
|
||||
// Tier 2: intermediate user-owned – no group/other write (allows 0750/0755, rejects 0770/0777)
|
||||
if (perms & 0o022) != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 25, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) has group/other permissions: \(String(perms, radix: 8))"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateParentChainPureResultProvider(socketPath: String, currentUID: uid_t, provider: LStatResultProvider) throws {
|
||||
try rejectIfDotComponentsPure(in: socketPath)
|
||||
let parentURL = URL(fileURLWithPath: socketPath).deletingLastPathComponent()
|
||||
let parentPath = parentURL.path
|
||||
if parentPath.isEmpty || parentPath == "/" { return }
|
||||
|
||||
let comps = parentURL.pathComponents // starts with "/"
|
||||
var cur = ""
|
||||
for comp in comps {
|
||||
if comp == "/" {
|
||||
cur = "/"
|
||||
continue
|
||||
}
|
||||
if cur == "/" {
|
||||
cur = "/" + comp
|
||||
} else if cur.isEmpty {
|
||||
cur = comp
|
||||
} else {
|
||||
cur = cur + "/" + comp
|
||||
}
|
||||
|
||||
let result = provider(cur)
|
||||
switch result {
|
||||
case .absent:
|
||||
continue
|
||||
case .failed(let errnoCode):
|
||||
throw NSError(domain: "SocketServer", code: 22, userInfo: [NSLocalizedDescriptionKey: "lstat failed for parent component \(cur): \(String(cString: strerror(errnoCode)))"])
|
||||
case .present(let info):
|
||||
let isDedicated = (cur == parentPath)
|
||||
try validateSingleLStatInfoOrThrow(path: cur, info: info, currentUID: currentUID, isDedicatedRuntimeParent: isDedicated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure parent-chain validator with injectable lstat and uid (legacy nil==ENOENT shim).
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - socketPath: absolute socket path
|
||||
/// - currentUID: uid of current process
|
||||
/// - provider: returns LStatInfo? (nil if ENOENT, else info). Must use lstat, not stat.
|
||||
/// - Throws: on policy violation
|
||||
func validateParentChainPure(socketPath: String, currentUID: uid_t, provider: LStatProvider) throws {
|
||||
// Adapt legacy optional provider into authoritative result provider
|
||||
let adapted: LStatResultProvider = { path in
|
||||
if let info = provider(path) {
|
||||
return .present(info)
|
||||
} else {
|
||||
return .absent
|
||||
}
|
||||
}
|
||||
try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: currentUID, provider: adapted)
|
||||
}
|
||||
|
||||
// MARK: - Live lstat adapter
|
||||
|
||||
/// Authoritative live lstat that never swallows non-ENOENT errors.
|
||||
func liveLStatResultProvider(path: String) -> LStatResult {
|
||||
var st = stat()
|
||||
if lstat(path, &st) != 0 {
|
||||
if errno == ENOENT { return .absent }
|
||||
return .failed(errnoCode: errno)
|
||||
}
|
||||
return .present(lstatInfo(from: st))
|
||||
}
|
||||
|
||||
/// Legacy optional lstat provider. Now fail-closed: returns nil ONLY for ENOENT, and for
|
||||
/// other errors returns a present but invalid sentinel that will cause validation to reject
|
||||
/// (never treated as missing). Prefer liveLStatResultProvider.
|
||||
func liveLStatProvider(path: String) -> LStatInfo? {
|
||||
switch liveLStatResultProvider(path: path) {
|
||||
case .absent:
|
||||
return nil
|
||||
case .present(let info):
|
||||
return info
|
||||
case .failed:
|
||||
// Fail-closed sentinel: not a directory, wrong uid, triggers rejection if misused directly
|
||||
// We return an info that will be rejected as non-directory
|
||||
return LStatInfo(uid: uid_t.max, mode: 0, isSymlink: false, isDir: false, exists: true)
|
||||
}
|
||||
}
|
||||
|
||||
func validateExistingParentChainLive(for socketPath: String) throws {
|
||||
let currentUID = getuid()
|
||||
// Single authoritative scan using liveLStatResultProvider; no pre-scan duplicate, no nil-swallow
|
||||
try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: currentUID, provider: { liveLStatResultProvider(path: $0) })
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import CSignalSupport
|
||||
|
||||
// Pure decision extracted for unit testing.
|
||||
func isPeerAuthorized(peerUID: uid_t, currentUID: uid_t) -> Bool {
|
||||
return peerUID == currentUID
|
||||
}
|
||||
|
||||
// MARK: - Path validation
|
||||
// Trust boundary: see SocketPathValidation.swift for full documentation.
|
||||
// Tiered model:
|
||||
// 1) platform allowlist root-owned (uid 0, no g/o write except tmp)
|
||||
// 2) user-owned intermediates: uid current, no g/o *write* (mode & 022 == 0) -> allows 0700/0750/0755, rejects 0770/0777
|
||||
// 3) dedicated runtime (immediate socket parent): uid current, mode & 077 == 0 -> requires 0700 family only.
|
||||
// Socket itself 0600.
|
||||
|
||||
// MARK: - Reused single-component validator (authoritative)
|
||||
// NOTE: this is the ONLY place allowed to decide if an existing component is safe.
|
||||
// It must stay in sync with validateParentChainPureResultProvider logic.
|
||||
func validateExistingComponentLiveOrThrow(path: String, info: LStatInfo, currentUID: uid_t, isDedicatedRuntimeParent: Bool = false) throws {
|
||||
// Centralized call to shared validation in SocketPathValidation
|
||||
try validateSingleLStatInfoOrThrow(path: path, info: info, currentUID: currentUID, isDedicatedRuntimeParent: isDedicatedRuntimeParent)
|
||||
}
|
||||
|
||||
func ensureParentDirectories(for socketPath: String) throws {
|
||||
// Authoritative validation reused; fail-closed on lstat errors
|
||||
try rejectIfDotComponentsPure(in: socketPath)
|
||||
try validateExistingParentChainLive(for: socketPath)
|
||||
|
||||
let fm = FileManager.default
|
||||
let url = URL(fileURLWithPath: socketPath)
|
||||
let parent = url.deletingLastPathComponent()
|
||||
let parentPath = parent.path
|
||||
if parentPath.isEmpty { return }
|
||||
|
||||
let comps = parent.pathComponents
|
||||
var cur = ""
|
||||
for comp in comps {
|
||||
if comp == "/" {
|
||||
cur = "/"
|
||||
continue
|
||||
}
|
||||
if cur == "/" {
|
||||
cur = "/" + comp
|
||||
} else if cur.isEmpty {
|
||||
cur = comp
|
||||
} else {
|
||||
cur = cur + "/" + comp
|
||||
}
|
||||
|
||||
switch liveLStatResultProvider(path: cur) {
|
||||
case .absent:
|
||||
// Create missing component with 0700 – privacy preserving. Even intermediates now get 0700.
|
||||
do {
|
||||
try fm.createDirectory(atPath: cur, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700])
|
||||
chmod(cur, 0o700)
|
||||
} catch {
|
||||
// mkdir race: re-lstat and revalidate rather than assuming missing
|
||||
switch liveLStatResultProvider(path: cur) {
|
||||
case .absent:
|
||||
throw error
|
||||
case .failed(let ec):
|
||||
throw NSError(domain: "SocketServer", code: 22, userInfo: [NSLocalizedDescriptionKey: "lstat failed for \(cur): \(String(cString: strerror(ec)))"])
|
||||
case .present(let info):
|
||||
do {
|
||||
let isDedicated = (cur == parentPath)
|
||||
try validateSingleLStatInfoOrThrow(path: cur, info: info, currentUID: getuid(), isDedicatedRuntimeParent: isDedicated)
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
case .failed(let ec):
|
||||
throw NSError(domain: "SocketServer", code: 22, userInfo: [NSLocalizedDescriptionKey: "lstat failed for \(cur): \(String(cString: strerror(ec)))"])
|
||||
case .present(let info):
|
||||
// Reuse authoritative single-component validator (no duplicated policy)
|
||||
let isDedicated = (cur == parentPath)
|
||||
try validateSingleLStatInfoOrThrow(path: cur, info: info, currentUID: getuid(), isDedicatedRuntimeParent: isDedicated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func safeUnlinkIfStaleSocket(at path: String) throws {
|
||||
var st = stat()
|
||||
let r = lstat(path, &st)
|
||||
if r != 0 {
|
||||
if errno == ENOENT { return }
|
||||
throw NSError(domain: "SocketServer", code: 11, userInfo: [NSLocalizedDescriptionKey: "lstat failed for \(path): \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
let isSock = (st.st_mode & S_IFMT) == S_IFSOCK
|
||||
if !isSock {
|
||||
throw NSError(domain: "SocketServer", code: 12, userInfo: [NSLocalizedDescriptionKey: "Refusing to unlink: \(path) exists and is not a socket"])
|
||||
}
|
||||
if st.st_uid != getuid() {
|
||||
throw NSError(domain: "SocketServer", code: 13, userInfo: [NSLocalizedDescriptionKey: "Refusing to unlink: socket at \(path) not owned by current uid"])
|
||||
}
|
||||
if unlink(path) != 0 && errno != ENOENT {
|
||||
throw NSError(domain: "SocketServer", code: 14, userInfo: [NSLocalizedDescriptionKey: "Failed to unlink stale socket \(path): \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
}
|
||||
|
||||
private let kMaxRequestBytes = 64 * 1024
|
||||
private let kClientRecvTimeoutSec = 5
|
||||
|
||||
private func makeErrorResponse(id: String, code: String, message: String) -> Data? {
|
||||
let err = ErrorPayload(code: code, message: message)
|
||||
let resp = Response(id: id, ok: false, result: nil, error: err)
|
||||
guard let json = try? JSONEncoder().encode(resp),
|
||||
let str = String(data: json, encoding: .utf8) else { return nil }
|
||||
return (str + "\n").data(using: .utf8)
|
||||
}
|
||||
|
||||
private func processRequestData(_ data: Data) -> Data? {
|
||||
if data.isEmpty { return nil }
|
||||
if let s = String(data: data, encoding: .utf8),
|
||||
s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return nil
|
||||
}
|
||||
if let req = try? JSONDecoder().decode(Request.self, from: data) {
|
||||
let resp = dispatch(request: req)
|
||||
guard let json = try? JSONEncoder().encode(resp),
|
||||
let str = String(data: json, encoding: .utf8) else { return nil }
|
||||
return (str + "\n").data(using: .utf8)
|
||||
} else {
|
||||
let recovered = extractRecoverableId(from: data) ?? ""
|
||||
return makeErrorResponse(id: recovered, code: "invalid_request", message: "Invalid request JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func runSocketServer(socketPath: String) -> Never {
|
||||
if !socketPath.hasPrefix("/") {
|
||||
fputs("error: --socket path must be absolute: \(socketPath)\n", stderr)
|
||||
Darwin.exit(2)
|
||||
}
|
||||
if socketPath.utf8.count >= 104 {
|
||||
fputs("error: --socket path too long\n", stderr)
|
||||
Darwin.exit(2)
|
||||
}
|
||||
|
||||
do {
|
||||
try ensureParentDirectories(for: socketPath)
|
||||
try safeUnlinkIfStaleSocket(at: socketPath)
|
||||
} catch {
|
||||
fputs("error: \(error.localizedDescription)\n", stderr)
|
||||
Darwin.exit(3)
|
||||
}
|
||||
|
||||
// Store for signal cleanup in C
|
||||
socketPath.withCString { cStr in
|
||||
reyna_store_socket_path(cStr)
|
||||
}
|
||||
reyna_install_signal_handlers()
|
||||
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
if fd < 0 {
|
||||
fputs("error: socket() failed: \(String(cString: strerror(errno)))\n", stderr)
|
||||
Darwin.exit(4)
|
||||
}
|
||||
_ = fcntl(fd, F_SETFD, FD_CLOEXEC)
|
||||
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
|
||||
_ = socketPath.withCString { cStr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { dst in
|
||||
dst.withMemoryRebound(to: CChar.self, capacity: 104) { p in
|
||||
strncpy(p, cStr, 103)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let oldMask = umask(0o077)
|
||||
let bindRes = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in
|
||||
bind(fd, saddr, socklen_t(MemoryLayout<sockaddr_un>.size))
|
||||
}
|
||||
}
|
||||
umask(oldMask)
|
||||
|
||||
if bindRes != 0 {
|
||||
fputs("error: bind() \(socketPath): \(String(cString: strerror(errno)))\n", stderr)
|
||||
close(fd)
|
||||
Darwin.exit(5)
|
||||
}
|
||||
|
||||
if chmod(socketPath, 0o600) != 0 {
|
||||
fputs("warning: chmod 0600 failed: \(String(cString: strerror(errno)))\n", stderr)
|
||||
}
|
||||
|
||||
if listen(fd, 32) != 0 {
|
||||
fputs("error: listen() failed: \(String(cString: strerror(errno)))\n", stderr)
|
||||
close(fd)
|
||||
reyna_cleanup_socket_sync()
|
||||
Darwin.exit(6)
|
||||
}
|
||||
|
||||
// Main loop: one client at a time, one request per connection
|
||||
while true {
|
||||
let cfd = accept(fd, nil, nil)
|
||||
if cfd < 0 {
|
||||
if errno == EINTR { continue }
|
||||
fputs("error: accept() failed: \(String(cString: strerror(errno)))\n", stderr)
|
||||
break
|
||||
}
|
||||
|
||||
// --- Peer credential check (macOS getpeereid) ---
|
||||
var peerEuid: uid_t = 0
|
||||
var peerEgid: gid_t = 0
|
||||
if getpeereid(cfd, &peerEuid, &peerEgid) != 0 {
|
||||
// If we cannot obtain peer credentials, reject
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
if !isPeerAuthorized(peerUID: peerEuid, currentUID: getuid()) {
|
||||
if let d = makeErrorResponse(id: "", code: "unauthorized", message: "Peer UID not authorized") {
|
||||
_ = d.withUnsafeBytes { p in send(cfd, p.baseAddress!, p.count, 0) }
|
||||
}
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set receive timeout to bound slow clients
|
||||
var tv = timeval()
|
||||
tv.tv_sec = kClientRecvTimeoutSec
|
||||
tv.tv_usec = 0
|
||||
setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
|
||||
|
||||
var buf = Data()
|
||||
buf.reserveCapacity(8192)
|
||||
var tmp = [UInt8](repeating: 0, count: 4096)
|
||||
var exceeded = false
|
||||
var gotAny = false
|
||||
var timedOut = false
|
||||
|
||||
// Poll-based timeout additionally enforced
|
||||
while true {
|
||||
// Wait for data with timeout
|
||||
var pfd = pollfd(fd: cfd, events: Int16(POLLIN), revents: 0)
|
||||
let pollTimeoutMs: Int32 = Int32(kClientRecvTimeoutSec * 1000)
|
||||
let pr = poll(&pfd, 1, pollTimeoutMs)
|
||||
if pr < 0 {
|
||||
if errno == EINTR { continue }
|
||||
break
|
||||
}
|
||||
if pr == 0 {
|
||||
// timeout
|
||||
timedOut = true
|
||||
break
|
||||
}
|
||||
let n = recv(cfd, &tmp, tmp.count, 0)
|
||||
if n < 0 {
|
||||
if errno == EINTR { continue }
|
||||
if errno == EWOULDBLOCK || errno == EAGAIN {
|
||||
timedOut = true
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
if n == 0 { break }
|
||||
gotAny = true
|
||||
|
||||
// Oversized handling with newline-in-same-chunk fix
|
||||
if buf.count + n > kMaxRequestBytes {
|
||||
// Look for newline in the new chunk
|
||||
var newlineIdx: Int? = nil
|
||||
for i in 0..<n {
|
||||
if tmp[i] == 0x0A {
|
||||
newlineIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if let nl = newlineIdx {
|
||||
// First line length = buf.count + nl (excluding newline char itself)
|
||||
let firstLineLen = buf.count + nl
|
||||
if firstLineLen <= kMaxRequestBytes {
|
||||
// Accept up to newline and ignore rest
|
||||
buf.append(contentsOf: tmp[0..<nl])
|
||||
// Break to process – we have a complete line within limit
|
||||
break
|
||||
} else {
|
||||
exceeded = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// No newline in this chunk and would exceed -> oversized
|
||||
exceeded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
buf.append(contentsOf: tmp[0..<n])
|
||||
if buf.contains(0x0A) { break }
|
||||
}
|
||||
|
||||
if timedOut {
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
if !gotAny && !exceeded {
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
if exceeded {
|
||||
if let d = makeErrorResponse(id: "", code: "payload_too_large", message: "Request exceeds 64KiB limit") {
|
||||
_ = d.withUnsafeBytes { p in send(cfd, p.baseAddress!, p.count, 0) }
|
||||
}
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
let lineData: Data
|
||||
if let idx = buf.firstIndex(of: 0x0A) {
|
||||
lineData = buf.prefix(upTo: idx)
|
||||
} else {
|
||||
lineData = buf
|
||||
}
|
||||
|
||||
if lineData.count > kMaxRequestBytes {
|
||||
if let d = makeErrorResponse(id: "", code: "payload_too_large", message: "Request exceeds 64KiB limit") {
|
||||
_ = d.withUnsafeBytes { p in send(cfd, p.baseAddress!, p.count, 0) }
|
||||
}
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
if let resp = processRequestData(lineData) {
|
||||
_ = resp.withUnsafeBytes { p in
|
||||
var sent = 0
|
||||
while sent < resp.count {
|
||||
let n = send(cfd, p.baseAddress!.advanced(by: sent), resp.count - sent, 0)
|
||||
if n <= 0 { break }
|
||||
sent += n
|
||||
}
|
||||
}
|
||||
}
|
||||
close(cfd)
|
||||
}
|
||||
|
||||
close(fd)
|
||||
reyna_cleanup_socket_sync()
|
||||
Darwin.exit(0)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - System info data models (read-only, no TCC)
|
||||
|
||||
struct SystemInfoItem: Codable, Equatable, Sendable {
|
||||
let macos_version: String
|
||||
let build: String?
|
||||
let uname: String?
|
||||
let hw_model: String?
|
||||
let cpu_brand: String?
|
||||
let is_macos_26_plus: Bool?
|
||||
let speech_analyzer_expected: String?
|
||||
}
|
||||
|
||||
struct SpeechApiStatusItem: Codable, Equatable, Sendable {
|
||||
let system: SystemInfoItem
|
||||
let swift_availability: [String: String]? // minimal stub
|
||||
let conclusion: String
|
||||
}
|
||||
|
||||
enum SystemProviderError: Error, Equatable, Sendable {
|
||||
case unavailable(String)
|
||||
}
|
||||
|
||||
protocol SystemInfoProviding: Sendable {
|
||||
func getSystemInfo() throws -> SystemInfoItem
|
||||
func getSpeechApiStatus() throws -> SpeechApiStatusItem
|
||||
}
|
||||
|
||||
// Production provider - reads sw_vers / uname / sysctl, no permission needed
|
||||
struct ProductionSystemInfoProvider: SystemInfoProviding {
|
||||
func getSystemInfo() throws -> SystemInfoItem {
|
||||
let ver = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let verString = "\(ver.majorVersion).\(ver.minorVersion).\(ver.patchVersion)"
|
||||
// Best-effort hw model / cpu / uname without spawning processes in Swift? Use sysctl/mib.
|
||||
var hwModel: String? = nil
|
||||
var cpuBrand: String? = nil
|
||||
var unameStr: String? = nil
|
||||
// Use ProcessInfo hostName as fallback for minimal
|
||||
// For hw.model, use sysctlbyname where possible - but keep simple fallback to avoid C interop complexity
|
||||
// We'll try reading via sysctl nametable via Foundation
|
||||
#if os(macOS)
|
||||
hwModel = sysctlString("hw.model")
|
||||
cpuBrand = sysctlString("machdep.cpu.brand_string")
|
||||
#endif
|
||||
let m = ver.majorVersion
|
||||
let is26 = m >= 26
|
||||
let expected = is26 ? "likely available (macOS 26+)" : "not available - requires macOS 26+"
|
||||
return SystemInfoItem(
|
||||
macos_version: verString,
|
||||
build: nil,
|
||||
uname: unameStr,
|
||||
hw_model: hwModel,
|
||||
cpu_brand: cpuBrand,
|
||||
is_macos_26_plus: is26,
|
||||
speech_analyzer_expected: expected
|
||||
)
|
||||
}
|
||||
|
||||
func getSpeechApiStatus() throws -> SpeechApiStatusItem {
|
||||
let info = try getSystemInfo()
|
||||
let major = ProcessInfo.processInfo.operatingSystemVersion.majorVersion
|
||||
let conclusion: String
|
||||
if major >= 26 {
|
||||
conclusion = "macOS 26+ detected — SpeechAnalyzer/SpeechTranscriber should be available per Apple docs."
|
||||
} else {
|
||||
conclusion = "macOS \(info.macos_version) detected — SpeechAnalyzer requires macOS 26+."
|
||||
}
|
||||
return SpeechApiStatusItem(system: info, swift_availability: nil, conclusion: conclusion)
|
||||
}
|
||||
|
||||
private func sysctlString(_ name: String) -> String? {
|
||||
var size = 0
|
||||
let rc1 = sysctlbyname(name, nil, &size, nil, 0)
|
||||
if rc1 != 0 { return nil }
|
||||
var buffer = [CChar](repeating: 0, count: size)
|
||||
let rc2 = sysctlbyname(name, &buffer, &size, nil, 0)
|
||||
if rc2 != 0 { return nil }
|
||||
return String(cString: buffer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import XCTest
|
||||
@testable import ReynaCLIHostCore
|
||||
|
||||
// Tests for calendar.request_full_access – TDD, fake providers only
|
||||
|
||||
final class CalendarAuthorizationTests: XCTestCase {
|
||||
|
||||
// Fake auth providers
|
||||
struct AlreadyAuthorizedProvider: CalendarAuthorizationProviding {
|
||||
var requested = false
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { .authorized }
|
||||
func requestFullAccess() throws -> Bool {
|
||||
XCTFail("requestFullAccess must not be called when already authorized")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
struct NotDeterminedGrantedProvider: CalendarAuthorizationProviding {
|
||||
var statusCall = 0
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { .notDetermined }
|
||||
func requestFullAccess() throws -> Bool { true }
|
||||
}
|
||||
|
||||
struct NotDeterminedDeniedProvider: CalendarAuthorizationProviding {
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { .notDetermined }
|
||||
func requestFullAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
struct DeniedProvider: CalendarAuthorizationProviding {
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { .denied }
|
||||
func requestFullAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
struct TimeoutProvider: CalendarAuthorizationProviding {
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { .notDetermined }
|
||||
func requestFullAccess() throws -> Bool {
|
||||
throw CalendarProviderError.unavailable("calendar authorization timed out")
|
||||
}
|
||||
}
|
||||
|
||||
struct ErrorProvider: CalendarAuthorizationProviding {
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { .notDetermined }
|
||||
func requestFullAccess() throws -> Bool {
|
||||
throw CalendarProviderError.unavailable("disk error")
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyListProvider: CalendarListProviding {
|
||||
func listCalendars() throws -> [CalendarListItem] { [] }
|
||||
}
|
||||
|
||||
// already-full permission returns state authorized without asking
|
||||
func testAlreadyAuthorizedReturnsAuthorizedWithoutRequesting() {
|
||||
let auth = AlreadyAuthorizedProvider()
|
||||
let req = Request(id: "a1", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.status, "authorized")
|
||||
XCTAssertEqual(resp.result?.operation, "calendar.request_full_access")
|
||||
XCTAssertEqual(resp.id, "a1")
|
||||
}
|
||||
|
||||
// notDetermined reaches request path
|
||||
func testNotDeterminedReachesRequestPath() {
|
||||
final class TrackingProvider: CalendarAuthorizationProviding, @unchecked Sendable {
|
||||
var didRequest = false
|
||||
var status: CalendarAuthorizationStatus = .notDetermined
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { status }
|
||||
func requestFullAccess() throws -> Bool {
|
||||
didRequest = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
let tracking = TrackingProvider()
|
||||
let req = Request(id: "a2", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: tracking)
|
||||
XCTAssertTrue(tracking.didRequest, "requestFullAccess must be called when notDetermined")
|
||||
XCTAssertTrue(resp.ok)
|
||||
}
|
||||
|
||||
// granted response returns {status:"authorized"}
|
||||
func testGrantedReturnsAuthorizedResult() {
|
||||
let auth = NotDeterminedGrantedProvider()
|
||||
let req = Request(id: "a3", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.status, "authorized")
|
||||
XCTAssertEqual(resp.result?.protocol_version, PROTOCOL_VERSION)
|
||||
XCTAssertNil(resp.error)
|
||||
XCTAssertNil(resp.result?.calendars, "must not output calendar content")
|
||||
}
|
||||
|
||||
// denied returns structured permission_denied
|
||||
func testDeniedReturnsPermissionDenied() {
|
||||
let auth = NotDeterminedDeniedProvider()
|
||||
let req = Request(id: "a4", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_denied")
|
||||
XCTAssertNotNil(resp.error?.message)
|
||||
XCTAssertNil(resp.result)
|
||||
}
|
||||
|
||||
// denied when already denied also permission_denied
|
||||
func testAlreadyDeniedPathAlsoDenies() {
|
||||
let auth = DeniedProvider()
|
||||
let req = Request(id: "a5", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_denied")
|
||||
}
|
||||
|
||||
// async timeout/error returns calendar_unavailable
|
||||
func testTimeoutReturnsCalendarUnavailable() {
|
||||
let auth = TimeoutProvider()
|
||||
let req = Request(id: "a6", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "calendar_unavailable")
|
||||
XCTAssertTrue(resp.error?.message.lowercased().contains("timed out") ?? false)
|
||||
}
|
||||
|
||||
func testErrorReturnsCalendarUnavailable() {
|
||||
let auth = ErrorProvider()
|
||||
let req = Request(id: "a7", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "calendar_unavailable")
|
||||
}
|
||||
|
||||
// event/calendar list cannot call request method (verify list path never requests)
|
||||
func testCalendarListDoesNotCallAuthRequest() {
|
||||
final class SpyListProvider: CalendarListProviding, @unchecked Sendable {
|
||||
var called = false
|
||||
func listCalendars() throws -> [CalendarListItem] {
|
||||
called = true
|
||||
return []
|
||||
}
|
||||
}
|
||||
final class SpyAuthProvider: CalendarAuthorizationProviding, @unchecked Sendable {
|
||||
var didCallStatus = false
|
||||
var didCallRequest = false
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus {
|
||||
didCallStatus = true
|
||||
return .authorized
|
||||
}
|
||||
func requestFullAccess() throws -> Bool {
|
||||
didCallRequest = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
let list = SpyListProvider()
|
||||
let auth = SpyAuthProvider()
|
||||
let req = Request(id: "list-1", operation: "calendar.list", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: list, authProvider: auth)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertFalse(auth.didCallRequest, "calendar.list must never call requestFullAccess")
|
||||
XCTAssertFalse(auth.didCallStatus, "calendar.list must not touch auth provider")
|
||||
XCTAssertTrue(list.called)
|
||||
}
|
||||
|
||||
// MARK: - Shared holder to satisfy Swift 6 Sendable checks
|
||||
final class TestBox<T>: @unchecked Sendable {
|
||||
var value: T
|
||||
init(_ v: T) { value = v }
|
||||
}
|
||||
|
||||
// MARK: - EventKitMainRunLoopBridge – deterministic pump tests (no real EventKit/TCC)
|
||||
|
||||
func testBridgePumpsMainRunLoopAndDeliversMainQueueCallback() throws {
|
||||
let exp = expectation(description: "bridge completes")
|
||||
let grantedBox = TestBox(false)
|
||||
let errorBox = TestBox<Error?>(nil)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let bridge = EventKitMainRunLoopBridge()
|
||||
do {
|
||||
// Deterministic seam: schedule callback onto next main run loop turn via Timer,
|
||||
// simulating EventKit delivering completion on main run loop.
|
||||
grantedBox.value = try bridge.requestAccess(timeout: 2) { completion in
|
||||
// Timer on main run loop – only fires when run loop is pumped
|
||||
Timer.scheduledTimer(withTimeInterval: 0.02, repeats: false) { _ in
|
||||
completion(true, nil)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorBox.value = error
|
||||
}
|
||||
exp.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [exp], timeout: 5)
|
||||
XCTAssertNil(errorBox.value, "bridge must not timeout when it pumps main run loop; got \(String(describing: errorBox.value))")
|
||||
XCTAssertTrue(grantedBox.value, "granted should be true after main-queue callback is pumped")
|
||||
}
|
||||
|
||||
func testSemaphoreDeadlocksButBridgeDoesNot() throws {
|
||||
let semExp = expectation(description: "old impl would timeout")
|
||||
let completedBox = TestBox(false)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
Timer.scheduledTimer(withTimeInterval: 0.02, repeats: false) { _ in
|
||||
completedBox.value = true
|
||||
sem.signal()
|
||||
}
|
||||
let res = sem.wait(timeout: .now() + 0.2)
|
||||
XCTAssertEqual(res, .timedOut, "Blocking semaphore on main thread must deadlock main-run-loop callback – proving old bug")
|
||||
XCTAssertFalse(completedBox.value, "Callback must not have run while semaphore blocked main loop")
|
||||
semExp.fulfill()
|
||||
}
|
||||
wait(for: [semExp], timeout: 2)
|
||||
}
|
||||
|
||||
func testBridgeHandlesCompletionExactlyOnce() throws {
|
||||
let exp = expectation(description: "exactly once")
|
||||
let resultBox = TestBox(false)
|
||||
DispatchQueue.main.async {
|
||||
let bridge = EventKitMainRunLoopBridge()
|
||||
do {
|
||||
resultBox.value = try bridge.requestAccess(timeout: 1) { completion in
|
||||
completion(true, nil)
|
||||
completion(false, NSError(domain: "should-be-ignored", code: 1))
|
||||
}
|
||||
} catch {}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 2)
|
||||
XCTAssertTrue(resultBox.value, "First completion should win")
|
||||
}
|
||||
|
||||
func testBridgeThreadSafetyForConcurrentCompletion() throws {
|
||||
let exp = expectation(description: "thread-safe")
|
||||
let grantedBox = TestBox(false)
|
||||
let doneBox = TestBox(false)
|
||||
DispatchQueue.main.async {
|
||||
let bridge = EventKitMainRunLoopBridge()
|
||||
do {
|
||||
grantedBox.value = try bridge.requestAccess(timeout: 1) { completion in
|
||||
DispatchQueue.global().async { completion(true, nil) }
|
||||
DispatchQueue.global().async { completion(false, nil) }
|
||||
}
|
||||
doneBox.value = true
|
||||
} catch {}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 2)
|
||||
XCTAssertTrue(doneBox.value, "bridge must complete even with concurrent completions")
|
||||
}
|
||||
|
||||
func testBridgePropagatesError() throws {
|
||||
let exp = expectation(description: "error propagation")
|
||||
let caughtBox = TestBox(false)
|
||||
DispatchQueue.main.async {
|
||||
let bridge = EventKitMainRunLoopBridge()
|
||||
do {
|
||||
_ = try bridge.requestAccess(timeout: 1) { completion in
|
||||
completion(false, NSError(domain: "test", code: 2, userInfo: [NSLocalizedDescriptionKey: "fake EK error"]))
|
||||
}
|
||||
} catch let err as CalendarProviderError {
|
||||
if case .unavailable(let msg) = err {
|
||||
caughtBox.value = msg.contains("fake EK error")
|
||||
}
|
||||
} catch {}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 2)
|
||||
XCTAssertTrue(caughtBox.value, "Error from EK completion must be wrapped as calendar_unavailable")
|
||||
}
|
||||
|
||||
func testBridgeTimeoutReturnsCorrectError() throws {
|
||||
let exp = expectation(description: "timeout")
|
||||
let codeBox = TestBox("")
|
||||
DispatchQueue.main.async {
|
||||
let bridge = EventKitMainRunLoopBridge()
|
||||
do {
|
||||
_ = try bridge.requestAccess(timeout: 0.15) { _ in }
|
||||
XCTFail("Should have thrown")
|
||||
} catch let err as CalendarProviderError {
|
||||
if case .unavailable(let msg) = err {
|
||||
codeBox.value = msg
|
||||
}
|
||||
} catch {}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 2)
|
||||
XCTAssertTrue(codeBox.value.lowercased().contains("timed out"), "Timeout must produce 'calendar authorization timed out' message, got \(codeBox.value)")
|
||||
}
|
||||
|
||||
// Production code location check: only CalendarAuthorizationProvider.swift calls requestFullAccessToEvents
|
||||
func testOnlyOneFileCallsRequestFullAccessToEvents() throws {
|
||||
let fm = FileManager.default
|
||||
// Walk up to find repo root containing native/ReynaCLIHost/Sources
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var dirs: [URL] = []
|
||||
for _ in 0..<10 {
|
||||
let cand = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost")
|
||||
if fm.fileExists(atPath: cand.path) {
|
||||
dirs.append(cand)
|
||||
}
|
||||
let candCore = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore")
|
||||
if fm.fileExists(atPath: candCore.path) {
|
||||
dirs.append(candCore)
|
||||
}
|
||||
let cand2 = cur.appendingPathComponent("Sources/ReynaCLIHost")
|
||||
if fm.fileExists(atPath: cand2.path) {
|
||||
dirs.append(cand2)
|
||||
}
|
||||
let candCore2 = cur.appendingPathComponent("Sources/ReynaCLIHostCore")
|
||||
if fm.fileExists(atPath: candCore2.path) {
|
||||
dirs.append(candCore2)
|
||||
}
|
||||
if !dirs.isEmpty { break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard !dirs.isEmpty else {
|
||||
XCTFail("Could not locate Sources/ReynaCLIHost dir")
|
||||
return
|
||||
}
|
||||
var hits: [String] = []
|
||||
for srcDir in dirs {
|
||||
let files = (try? fm.contentsOfDirectory(at: srcDir, includingPropertiesForKeys: nil)) ?? []
|
||||
for file in files where file.pathExtension == "swift" {
|
||||
guard let content = try? String(contentsOf: file) else { continue }
|
||||
if content.contains("requestFullAccessToEvents") {
|
||||
hits.append(file.lastPathComponent)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dedupe + sort for stable assertion
|
||||
let uniqueSorted = Array(Set(hits)).sorted()
|
||||
XCTAssertEqual(uniqueSorted, ["CalendarAuthorizationProvider.swift"], "requestFullAccessToEvents must only appear in CalendarAuthorizationProvider.swift, found in \(uniqueSorted)")
|
||||
}
|
||||
|
||||
func testNoOutputCalendarContentOnAuthOperations() {
|
||||
// Both authorized and denied paths must not include calendars
|
||||
let authOk = NotDeterminedGrantedProvider()
|
||||
let reqOk = Request(id: "ok", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let respOk = dispatch(request: reqOk, calendarProvider: EmptyListProvider(), authProvider: authOk)
|
||||
XCTAssertNil(respOk.result?.calendars)
|
||||
|
||||
let authDen = NotDeterminedDeniedProvider()
|
||||
let reqDen = Request(id: "den", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let respDen = dispatch(request: reqDen, calendarProvider: EmptyListProvider(), authProvider: authDen)
|
||||
// denied has nil result, so no calendars by construction
|
||||
XCTAssertNil(respDen.result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import XCTest
|
||||
@testable import ReynaCLIHostCore
|
||||
import Foundation
|
||||
|
||||
// TDD for calendar.events.list and calendar.event.create – fake providers only, no live EventKit
|
||||
|
||||
final class CalendarEventsTests: XCTestCase {
|
||||
|
||||
// MARK: - Helper types
|
||||
|
||||
struct FakeCalendarListForSelection: CalendarListProviding {
|
||||
var calendars: [CalendarListItem]
|
||||
func listCalendars() throws -> [CalendarListItem] { calendars }
|
||||
}
|
||||
|
||||
struct FakeEventsProvider: CalendarEventsListProviding {
|
||||
var events: [CalendarEventItem]
|
||||
var shouldThrow: CalendarProviderError? = nil
|
||||
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] {
|
||||
if let err = shouldThrow { throw err }
|
||||
var filtered = events
|
||||
if let cid = calendarId {
|
||||
filtered = filtered.filter { $0.calendar_id == cid }
|
||||
} else if let ctitle = calendarTitle {
|
||||
filtered = filtered.filter { $0.calendar_title == ctitle }
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
|
||||
struct SelectingEventsProvider: CalendarEventsListProviding {
|
||||
var availableCalendars: [CalendarListItem]
|
||||
var events: [CalendarEventItem]
|
||||
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] {
|
||||
if let cid = calendarId {
|
||||
guard availableCalendars.contains(where: { $0.id == cid }) else {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(cid)")
|
||||
}
|
||||
return events.filter { $0.calendar_id == cid }.prefix(limit).map { $0 }
|
||||
}
|
||||
if let title = calendarTitle {
|
||||
let matched = availableCalendars.filter { $0.title == title }
|
||||
if matched.isEmpty {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(title)")
|
||||
}
|
||||
if matched.count > 1 {
|
||||
throw CalendarProviderError.invalidRequest("Ambiguous calendar title: \(title) matches \(matched.count) calendars")
|
||||
}
|
||||
return events.filter { $0.calendar_title == title }.prefix(limit).map { $0 }
|
||||
}
|
||||
let sorted = events.sorted { $0.start < $1.start }
|
||||
return Array(sorted.prefix(limit))
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeCreateProvider: CalendarEventCreateProviding {
|
||||
var shouldThrow: CalendarProviderError? = nil
|
||||
var willReturn: CalendarEventItem
|
||||
|
||||
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
|
||||
if let err = shouldThrow { throw err }
|
||||
return willReturn
|
||||
}
|
||||
}
|
||||
|
||||
struct SelectingCreateProvider: CalendarEventCreateProviding {
|
||||
var availableCalendars: [CalendarListItem]
|
||||
var writableIds: Set<String>
|
||||
var willReturn: CalendarEventItem
|
||||
|
||||
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
|
||||
if let cid = calendarId {
|
||||
guard let cal = availableCalendars.first(where: { $0.id == cid }) else {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(cid)")
|
||||
}
|
||||
guard writableIds.contains(cal.id) else {
|
||||
throw CalendarProviderError.invalidRequest("Calendar is read-only: \(cal.title)")
|
||||
}
|
||||
return willReturn
|
||||
}
|
||||
if let t = calendarTitle {
|
||||
let matched = availableCalendars.filter { $0.title == t }
|
||||
if matched.isEmpty { throw CalendarProviderError.invalidRequest("Calendar not found: \(t)") }
|
||||
if matched.count > 1 { throw CalendarProviderError.invalidRequest("Ambiguous calendar title: \(t) matches \(matched.count) calendars") }
|
||||
guard writableIds.contains(matched[0].id) else {
|
||||
throw CalendarProviderError.invalidRequest("Calendar is read-only: \(matched[0].title)")
|
||||
}
|
||||
return willReturn
|
||||
}
|
||||
throw CalendarProviderError.invalidRequest("Calendar must be specified by id or exact unique title")
|
||||
}
|
||||
}
|
||||
|
||||
struct DeniedEventsProvider: CalendarEventsListProviding {
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] {
|
||||
throw CalendarProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
struct FailEventsProvider: CalendarEventsListProviding {
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] {
|
||||
throw CalendarProviderError.unavailable("disk fail")
|
||||
}
|
||||
}
|
||||
|
||||
struct DeniedCreateProvider: CalendarEventCreateProviding {
|
||||
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
|
||||
throw CalendarProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
struct MockAuthProvider: CalendarAuthorizationProviding {
|
||||
var status: CalendarAuthorizationStatus
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { status }
|
||||
func requestFullAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
final class CountingCreate: CalendarEventCreateProviding, @unchecked Sendable {
|
||||
var count = 0
|
||||
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
|
||||
count += 1
|
||||
return CalendarEventItem(id: "sample", title: "Sample", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "cal1", calendar_title: "Home", notes: nil, location: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func sampleEvent() -> CalendarEventItem {
|
||||
CalendarEventItem(id: "sample", title: "Sample", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "cal1", calendar_title: "Home", notes: nil, location: nil)
|
||||
}
|
||||
|
||||
// MARK: - List tests
|
||||
|
||||
func testEventsListMissingStartReturnsInvalidRequest() {
|
||||
let req = Request(id: "e1", operation: "calendar.events.list", arguments: .object(["end": .string("2026-01-02T00:00:00Z")]))
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testEventsListInvalidISODate() {
|
||||
let args: JSONValue = .object(["start": .string("not-a-date"), "end": .string("2026-01-02T00:00:00Z")])
|
||||
let req = Request(id: "e2", operation: "calendar.events.list", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testEventsListStartAfterEnd() {
|
||||
let args: JSONValue = .object(["start": .string("2026-01-03T00:00:00Z"), "end": .string("2026-01-02T00:00:00Z")])
|
||||
let req = Request(id: "e3", operation: "calendar.events.list", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testEventsListLimitBounded() {
|
||||
let argsLow: JSONValue = .object(["start": .string("2026-01-01T00:00:00Z"), "end": .string("2026-01-02T00:00:00Z"), "limit": .number(0)])
|
||||
let reqLow = Request(id: "e4", operation: "calendar.events.list", arguments: argsLow)
|
||||
let respLow = dispatch(request: reqLow, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(respLow.ok)
|
||||
XCTAssertEqual(respLow.error?.code, "invalid_request")
|
||||
|
||||
let argsHigh: JSONValue = .object(["start": .string("2026-01-01T00:00:00Z"), "end": .string("2026-01-02T00:00:00Z"), "limit": .number(251)])
|
||||
let reqHigh = Request(id: "e5", operation: "calendar.events.list", arguments: argsHigh)
|
||||
let respHigh = dispatch(request: reqHigh, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(respHigh.ok)
|
||||
XCTAssertEqual(respHigh.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testEventsListSuccessSortedAndMinimalFields() {
|
||||
let ev1 = CalendarEventItem(id: "2", title: "B", start: "2026-01-01T11:00:00Z", end: "2026-01-01T12:00:00Z", all_day: false, calendar_id: "cal1", calendar_title: "Home", notes: "n2", location: "loc2")
|
||||
let ev2 = CalendarEventItem(id: "1", title: "A", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "cal1", calendar_title: "Home", notes: nil, location: nil)
|
||||
let provider = FakeEventsProvider(events: [ev1, ev2])
|
||||
let args: JSONValue = .object(["start": .string("2026-01-01T00:00:00Z"), "end": .string("2026-01-02T00:00:00Z"), "limit": .number(10)])
|
||||
let req = Request(id: "e6", operation: "calendar.events.list", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: provider, createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.events?.count, 2)
|
||||
XCTAssertEqual(resp.result?.events?.first?.id, "1")
|
||||
let encoded = try! JSONEncoder().encode(resp)
|
||||
let obj = try! JSONSerialization.jsonObject(with: encoded) as! [String: Any]
|
||||
let result = obj["result"] as! [String: Any]
|
||||
let events = result["events"] as! [[String: Any]]
|
||||
for ev in events {
|
||||
XCTAssertNotNil(ev["id"])
|
||||
XCTAssertNotNil(ev["title"])
|
||||
XCTAssertNotNil(ev["start"])
|
||||
XCTAssertNotNil(ev["end"])
|
||||
XCTAssertNotNil(ev["calendar_id"])
|
||||
XCTAssertNotNil(ev["calendar_title"])
|
||||
let allowed = Set(["id","title","start","end","all_day","calendar_id","calendar_title","notes","location"])
|
||||
XCTAssertTrue(Set(ev.keys).isSubset(of: allowed), "Unexpected keys: \(ev.keys)")
|
||||
}
|
||||
}
|
||||
|
||||
func testEventsListCalendarIdWinsOverTitle() {
|
||||
let calendars = [
|
||||
CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav"),
|
||||
CalendarListItem(id: "id2", title: "Home", source: "Local", type: "local")
|
||||
]
|
||||
let ev1 = CalendarEventItem(id: "e1", title: "T", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "id1", calendar_title: "Home", notes: nil, location: nil)
|
||||
let ev2 = CalendarEventItem(id: "e2", title: "T", start: "2026-01-01T11:00:00Z", end: "2026-01-01T12:00:00Z", all_day: false, calendar_id: "id2", calendar_title: "Home", notes: nil, location: nil)
|
||||
let provider = SelectingEventsProvider(availableCalendars: calendars, events: [ev1, ev2])
|
||||
let args: JSONValue = .object([
|
||||
"start": .string("2026-01-01T00:00:00Z"),
|
||||
"end": .string("2026-01-02T00:00:00Z"),
|
||||
"calendar_id": .string("id1"),
|
||||
"calendar": .string("Home"),
|
||||
"limit": .number(10)
|
||||
])
|
||||
let req = Request(id: "e7", operation: "calendar.events.list", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: provider, createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.events?.count, 1)
|
||||
XCTAssertEqual(resp.result?.events?.first?.calendar_id, "id1")
|
||||
}
|
||||
|
||||
func testEventsListUnknownCalendarFailsDeterministic() {
|
||||
let calendars = [CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav")]
|
||||
let provider = SelectingEventsProvider(availableCalendars: calendars, events: [])
|
||||
let args: JSONValue = .object([
|
||||
"start": .string("2026-01-01T00:00:00Z"),
|
||||
"end": .string("2026-01-02T00:00:00Z"),
|
||||
"calendar": .string("Work")
|
||||
])
|
||||
let req = Request(id: "e8", operation: "calendar.events.list", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: provider, createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testEventsListAmbiguousTitleFails() {
|
||||
let calendars = [
|
||||
CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav"),
|
||||
CalendarListItem(id: "id2", title: "Home", source: "Local", type: "local")
|
||||
]
|
||||
let provider = SelectingEventsProvider(availableCalendars: calendars, events: [])
|
||||
let args: JSONValue = .object([
|
||||
"start": .string("2026-01-01T00:00:00Z"),
|
||||
"end": .string("2026-01-02T00:00:00Z"),
|
||||
"calendar": .string("Home")
|
||||
])
|
||||
let req = Request(id: "e9", operation: "calendar.events.list", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: provider, createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
XCTAssertTrue(resp.error?.message.contains("Ambiguous") ?? false)
|
||||
}
|
||||
|
||||
func testEventsListPermissionRequired() {
|
||||
let args: JSONValue = .object([
|
||||
"start": .string("2026-01-01T00:00:00Z"),
|
||||
"end": .string("2026-01-02T00:00:00Z")
|
||||
])
|
||||
let req = Request(id: "e10", operation: "calendar.events.list", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: DeniedEventsProvider(), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_required")
|
||||
}
|
||||
|
||||
func testEventsListProviderFailureMapsToUnavailable() {
|
||||
let args: JSONValue = .object([
|
||||
"start": .string("2026-01-01T00:00:00Z"),
|
||||
"end": .string("2026-01-02T00:00:00Z")
|
||||
])
|
||||
let req = Request(id: "e11", operation: "calendar.events.list", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FailEventsProvider(), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "calendar_unavailable")
|
||||
}
|
||||
|
||||
// MARK: - Create tests
|
||||
|
||||
func testCreateMissingTitleInvalid() {
|
||||
let args: JSONValue = .object([
|
||||
"start": .string("2026-01-01T10:00:00Z"),
|
||||
"end": .string("2026-01-01T11:00:00Z"),
|
||||
"calendar_id": .string("id1")
|
||||
])
|
||||
let req = Request(id: "c1", operation: "calendar.event.create", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testCreateStartAfterEndInvalid() {
|
||||
let args: JSONValue = .object([
|
||||
"title": .string("Meeting"),
|
||||
"start": .string("2026-01-01T12:00:00Z"),
|
||||
"end": .string("2026-01-01T11:00:00Z"),
|
||||
"calendar_id": .string("id1")
|
||||
])
|
||||
let req = Request(id: "c2", operation: "calendar.event.create", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testCreateInvalidISO() {
|
||||
let args: JSONValue = .object([
|
||||
"title": .string("Meeting"),
|
||||
"start": .string("bad-date"),
|
||||
"end": .string("2026-01-01T11:00:00Z"),
|
||||
"calendar_id": .string("id1")
|
||||
])
|
||||
let req = Request(id: "c3", operation: "calendar.event.create", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testCreateNoCalendarSpecifiedFailsNoDefault() {
|
||||
let args: JSONValue = .object([
|
||||
"title": .string("Meeting"),
|
||||
"start": .string("2026-01-01T10:00:00Z"),
|
||||
"end": .string("2026-01-01T11:00:00Z")
|
||||
])
|
||||
let req = Request(id: "c4", operation: "calendar.event.create", arguments: args)
|
||||
let calendars = [CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav")]
|
||||
let provider = SelectingCreateProvider(availableCalendars: calendars, writableIds: ["id1"], willReturn: sampleEvent())
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: FakeEventsProvider(events: []), createProvider: provider, authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
XCTAssertTrue(resp.error?.message.contains("must be specified") ?? false)
|
||||
}
|
||||
|
||||
func testCreateUnknownCalendarFails() {
|
||||
let args: JSONValue = .object([
|
||||
"title": .string("Meeting"),
|
||||
"start": .string("2026-01-01T10:00:00Z"),
|
||||
"end": .string("2026-01-01T11:00:00Z"),
|
||||
"calendar": .string("Nonexistent")
|
||||
])
|
||||
let req = Request(id: "c5", operation: "calendar.event.create", arguments: args)
|
||||
let calendars = [CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav")]
|
||||
let provider = SelectingCreateProvider(availableCalendars: calendars, writableIds: ["id1"], willReturn: sampleEvent())
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: FakeEventsProvider(events: []), createProvider: provider, authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testCreateAmbiguousTitleFails() {
|
||||
let args: JSONValue = .object([
|
||||
"title": .string("Meeting"),
|
||||
"start": .string("2026-01-01T10:00:00Z"),
|
||||
"end": .string("2026-01-01T11:00:00Z"),
|
||||
"calendar": .string("Home")
|
||||
])
|
||||
let req = Request(id: "c6", operation: "calendar.event.create", arguments: args)
|
||||
let calendars = [
|
||||
CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav"),
|
||||
CalendarListItem(id: "id2", title: "Home", source: "Local", type: "local")
|
||||
]
|
||||
let provider = SelectingCreateProvider(availableCalendars: calendars, writableIds: ["id1","id2"], willReturn: sampleEvent())
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: FakeEventsProvider(events: []), createProvider: provider, authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testCreateReadOnlyCalendarFails() {
|
||||
let args: JSONValue = .object([
|
||||
"title": .string("Meeting"),
|
||||
"start": .string("2026-01-01T10:00:00Z"),
|
||||
"end": .string("2026-01-01T11:00:00Z"),
|
||||
"calendar_id": .string("id1")
|
||||
])
|
||||
let req = Request(id: "c7", operation: "calendar.event.create", arguments: args)
|
||||
let calendars = [CalendarListItem(id: "id1", title: "Birthdays", source: "iCloud", type: "birthday")]
|
||||
let provider = SelectingCreateProvider(availableCalendars: calendars, writableIds: [], willReturn: sampleEvent())
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: FakeEventsProvider(events: []), createProvider: provider, authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testCreateSuccessReturnsMetadata() {
|
||||
let args: JSONValue = .object([
|
||||
"title": .string("Meeting"),
|
||||
"start": .string("2026-01-01T10:00:00Z"),
|
||||
"end": .string("2026-01-01T11:00:00Z"),
|
||||
"calendar_id": .string("id1"),
|
||||
"notes": .string("bring docs"),
|
||||
"location": .string("Room 1")
|
||||
])
|
||||
let req = Request(id: "c8", operation: "calendar.event.create", arguments: args)
|
||||
let created = CalendarEventItem(id: "new-id", title: "Meeting", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "id1", calendar_title: "Home", notes: "bring docs", location: "Room 1")
|
||||
let provider = FakeCreateProvider(willReturn: created)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: provider, authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.event?.id, "new-id")
|
||||
XCTAssertEqual(resp.result?.event?.calendar_id, "id1")
|
||||
XCTAssertEqual(resp.result?.operation, "calendar.event.create")
|
||||
}
|
||||
|
||||
func testCreatePermissionRequired() {
|
||||
let args: JSONValue = .object([
|
||||
"title": .string("Meeting"),
|
||||
"start": .string("2026-01-01T10:00:00Z"),
|
||||
"end": .string("2026-01-01T11:00:00Z"),
|
||||
"calendar_id": .string("id1")
|
||||
])
|
||||
let req = Request(id: "c9", operation: "calendar.event.create", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: DeniedCreateProvider(), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_required")
|
||||
}
|
||||
|
||||
func testListDoesNotTriggerCreate() {
|
||||
let args: JSONValue = .object([
|
||||
"start": .string("2026-01-01T00:00:00Z"),
|
||||
"end": .string("2026-01-02T00:00:00Z")
|
||||
])
|
||||
let req = Request(id: "iso", operation: "calendar.events.list", arguments: args)
|
||||
let counter = CountingCreate()
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: counter, authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(counter.count, 0, "list must not trigger create")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import XCTest
|
||||
@testable import ReynaCLIHostCore
|
||||
import Foundation
|
||||
|
||||
// RED tests first: TDD for calendar.list migration
|
||||
|
||||
final class CalendarListTests: XCTestCase {
|
||||
|
||||
// MARK: - JSONValue safe representation
|
||||
|
||||
func testJSONValueRoundTripsObjectArrayPrimitive() throws {
|
||||
// Expect JSONValue type to support object/array/string/bool/number/null
|
||||
let json = """
|
||||
{"id":"1","operation":"calendar.list","arguments":{"filter":"home","limit":2,"nested":{"a":1},"arr":[1,2,null,true],"flag":false}}
|
||||
"""
|
||||
let data = json.data(using: .utf8)!
|
||||
let req = try JSONDecoder().decode(Request.self, from: data)
|
||||
// arguments should not be empty struct; should retain values
|
||||
// We test via encoding back and check presence
|
||||
let encoded = try JSONEncoder().encode(req)
|
||||
let obj = try JSONSerialization.jsonObject(with: encoded) as! [String: Any]
|
||||
let args = obj["arguments"] as! [String: Any]
|
||||
XCTAssertEqual(args["filter"] as? String, "home")
|
||||
XCTAssertNotNil(args["nested"])
|
||||
XCTAssertNotNil(args["arr"])
|
||||
}
|
||||
|
||||
func testJSONValueCodableEquatableSendable() throws {
|
||||
// Verify JSONValue conforms to Codable, Equatable, Sendable (compile-time)
|
||||
let v1: JSONValue = .object(["a": .number(1)])
|
||||
let v2: JSONValue = .object(["a": .number(1)])
|
||||
XCTAssertEqual(v1, v2)
|
||||
// Codable roundtrip
|
||||
let data = try JSONEncoder().encode(v1)
|
||||
let decoded = try JSONDecoder().decode(JSONValue.self, from: data)
|
||||
XCTAssertEqual(decoded, v1)
|
||||
}
|
||||
|
||||
// MARK: - CalendarListProvider injection & sorting
|
||||
|
||||
// Fake provider for tests
|
||||
struct FakeSuccessProvider: CalendarListProviding {
|
||||
let calendars: [CalendarListItem]
|
||||
func listCalendars() throws -> [CalendarListItem] { calendars }
|
||||
}
|
||||
|
||||
func testCalendarListSuccessAndDeterministicSort() throws {
|
||||
// Unsorted input should be returned sorted by source/title/id
|
||||
let unsorted = [
|
||||
CalendarListItem(id: "c", title: "B", source: "iCloud", type: "caldav"),
|
||||
CalendarListItem(id: "a", title: "A", source: "Local", type: "local"),
|
||||
CalendarListItem(id: "b", title: "A", source: "iCloud", type: "caldav"),
|
||||
CalendarListItem(id: "aa", title: "A", source: "iCloud", type: "caldav"),
|
||||
]
|
||||
let provider = FakeSuccessProvider(calendars: unsorted)
|
||||
let req = Request(id: "id-1", operation: "calendar.list", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: provider)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.id, "id-1")
|
||||
// The new response/result model should still carry protocol_version/operation/status and calendars
|
||||
// We decode result payload to check calendars order
|
||||
// ResultPayload should support generic calendars? We'll check via JSON
|
||||
let encoded = try JSONEncoder().encode(resp)
|
||||
let obj = try JSONSerialization.jsonObject(with: encoded) as! [String: Any]
|
||||
let result = obj["result"] as! [String: Any]
|
||||
XCTAssertEqual(result["protocol_version"] as? String, PROTOCOL_VERSION)
|
||||
XCTAssertEqual(result["operation"] as? String, "calendar.list")
|
||||
XCTAssertEqual(result["status"] as? String, "ok")
|
||||
let cals = result["calendars"] as! [[String: Any]]
|
||||
// Deterministic sort: by source, then title, then id (lexicographic, case-sensitive)
|
||||
// 'L' (76) < 'i' (105) so Local < iCloud
|
||||
// Expected order: (Local,A,a), (iCloud,A,aa), (iCloud,A,b), (iCloud,B,c)
|
||||
XCTAssertEqual(cals[0]["id"] as? String, "a")
|
||||
XCTAssertEqual(cals[1]["id"] as? String, "aa")
|
||||
XCTAssertEqual(cals[2]["id"] as? String, "b")
|
||||
XCTAssertEqual(cals[3]["id"] as? String, "c")
|
||||
// Ensure only allowed fields
|
||||
for cal in cals {
|
||||
XCTAssertEqual(Set(cal.keys), Set(["id","title","source","type"]))
|
||||
}
|
||||
}
|
||||
|
||||
func testCalendarListPermissionRequired() throws {
|
||||
struct DeniedProvider: CalendarListProviding {
|
||||
func listCalendars() throws -> [CalendarListItem] {
|
||||
throw CalendarProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
let req = Request(id: "perm-1", operation: "calendar.list", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: DeniedProvider())
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_required")
|
||||
XCTAssertNil(resp.result, "permission_required must not leak calendar content")
|
||||
}
|
||||
|
||||
func testCalendarListProviderFailure() throws {
|
||||
struct FailProvider: CalendarListProviding {
|
||||
func listCalendars() throws -> [CalendarListItem] {
|
||||
throw CalendarProviderError.unavailable("disk error")
|
||||
}
|
||||
}
|
||||
let req = Request(id: "fail-1", operation: "calendar.list", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: FailProvider())
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "calendar_unavailable")
|
||||
}
|
||||
|
||||
func testCalendarListRequestJSONDecodeWithArgumentsObject() throws {
|
||||
let json = """
|
||||
{"id":"json-1","operation":"calendar.list","arguments":{"foo":"bar","num":42}}
|
||||
"""
|
||||
let data = json.data(using: .utf8)!
|
||||
let req = try JSONDecoder().decode(Request.self, from: data)
|
||||
XCTAssertEqual(req.id, "json-1")
|
||||
// arguments should be parsed and not throw
|
||||
let provider = FakeSuccessProvider(calendars: [])
|
||||
let resp = dispatch(request: req, calendarProvider: provider)
|
||||
XCTAssertTrue(resp.ok, "calendar.list with args object should succeed")
|
||||
}
|
||||
|
||||
func testServiceHealthStillWorksAfterMigration() throws {
|
||||
let req = Request(id: "health-1", operation: "service.health", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: FakeSuccessProvider(calendars: []))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.protocol_version, PROTOCOL_VERSION)
|
||||
XCTAssertEqual(resp.result?.operation, "service.health")
|
||||
XCTAssertEqual(resp.result?.status, "ok")
|
||||
}
|
||||
|
||||
// MARK: - EventKit provider must be read-only (static check)
|
||||
|
||||
func testProductionEventKitProviderNeverRequestsAccess() throws {
|
||||
// Read the EventKit provider source and ensure it never calls prompt-triggering APIs
|
||||
// Note: createEvent legitimately mutates (save) – allowed only inside createEvent method.
|
||||
let fm = FileManager.default
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var providerURL: URL? = nil
|
||||
for _ in 0..<8 {
|
||||
let cand = cur.appendingPathComponent("Sources/ReynaCLIHost/CalendarProvider.swift")
|
||||
if fm.fileExists(atPath: cand.path) { providerURL = cand; break }
|
||||
let candCore = cur.appendingPathComponent("Sources/ReynaCLIHostCore/CalendarProvider.swift")
|
||||
if fm.fileExists(atPath: candCore.path) { providerURL = candCore; break }
|
||||
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost/CalendarProvider.swift")
|
||||
if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break }
|
||||
let cand2Core = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/CalendarProvider.swift")
|
||||
if fm.fileExists(atPath: cand2Core.path) { providerURL = cand2Core; break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard let url = providerURL, let content = try? String(contentsOf: url) else {
|
||||
XCTFail("CalendarProvider.swift not found for read-only safety check")
|
||||
return
|
||||
}
|
||||
let lines = content.components(separatedBy: .newlines)
|
||||
for line in lines {
|
||||
let l = line.lowercased()
|
||||
if l.contains("ekeventstore") && l.contains(".request") {
|
||||
XCTFail("Production provider must not call request methods on EKEventStore: \(line)")
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(content.contains("authorizationStatus"), "Should check authorizationStatus")
|
||||
// Ensure no remove mutation anywhere
|
||||
let lower = content.lowercased()
|
||||
XCTAssertFalse(lower.contains("remove(") && lower.contains("ekevent"), "Should not remove EKEvent")
|
||||
// No AppleScript
|
||||
XCTAssertFalse(lower.contains("nsapplescript") || lower.contains("appleevent"), "Should not use AppleScript")
|
||||
// If save exists, it must be inside createEvent func (mutation allowed only there)
|
||||
if lower.contains("save(") {
|
||||
// crude check: ensure save appears after func createEvent
|
||||
let parts = content.components(separatedBy: "func createEvent")
|
||||
XCTAssertEqual(parts.count, 2, "save should only appear in createEvent, found multiple or none")
|
||||
let beforeCreate = parts[0].lowercased()
|
||||
XCTAssertFalse(beforeCreate.contains("save(") && beforeCreate.contains("ekevent"), "save(EKEvent) must not appear outside createEvent")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import XCTest
|
||||
@testable import ReynaCLIHostCore
|
||||
import Foundation
|
||||
|
||||
final class ContactsAuthorizationTests: XCTestCase {
|
||||
|
||||
struct AlreadyAuthorizedProvider: ContactsAuthorizationProviding {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus { .authorized }
|
||||
func requestAccess() throws -> Bool {
|
||||
XCTFail("must not call requestAccess when already authorized")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
struct NotDeterminedGranted: ContactsAuthorizationProviding {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus { .notDetermined }
|
||||
func requestAccess() throws -> Bool { true }
|
||||
}
|
||||
|
||||
struct NotDeterminedDenied: ContactsAuthorizationProviding {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus { .notDetermined }
|
||||
func requestAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
struct DeniedProvider: ContactsAuthorizationProviding {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus { .denied }
|
||||
func requestAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
struct ErrorProvider: ContactsAuthorizationProviding {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus { .notDetermined }
|
||||
func requestAccess() throws -> Bool {
|
||||
throw ContactsProviderError.unavailable("disk error")
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyContactsSearch: ContactsSearchProviding {
|
||||
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] { [] }
|
||||
}
|
||||
|
||||
struct EmptyContactsRead: ContactsReadProviding {
|
||||
func readContact(id: String) throws -> ContactDetailItem {
|
||||
throw ContactsProviderError.notFound("not found")
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyContactsCreate: ContactsCreateProviding {
|
||||
func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult {
|
||||
throw ContactsProviderError.unavailable("no create")
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyCalendarList: CalendarListProviding {
|
||||
func listCalendars() throws -> [CalendarListItem] { [] }
|
||||
}
|
||||
|
||||
func testAlreadyAuthorizedNoRequest() {
|
||||
let auth = AlreadyAuthorizedProvider()
|
||||
let req = Request(id: "c-a1", operation: "contacts.request_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: auth, contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate())
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.status, "authorized")
|
||||
XCTAssertNil(resp.result?.contacts, "auth response must not leak contacts")
|
||||
XCTAssertNil(resp.result?.contact)
|
||||
}
|
||||
|
||||
func testNotDeterminedGrantedReturnsAuthorized() {
|
||||
let auth = NotDeterminedGranted()
|
||||
let req = Request(id: "c-a2", operation: "contacts.request_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: auth, contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate())
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.status, "authorized")
|
||||
XCTAssertEqual(resp.result?.operation, "contacts.request_access")
|
||||
}
|
||||
|
||||
func testDeniedReturnsPermissionDenied() {
|
||||
let auth = NotDeterminedDenied()
|
||||
let req = Request(id: "c-a3", operation: "contacts.request_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: auth, contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate())
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_denied")
|
||||
}
|
||||
|
||||
func testErrorReturnsContactsUnavailable() {
|
||||
let auth = ErrorProvider()
|
||||
let req = Request(id: "c-a4", operation: "contacts.request_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: auth, contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate())
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "contacts_unavailable")
|
||||
}
|
||||
|
||||
func testContactsSearchDoesNotCallAuthRequest() {
|
||||
final class SpySearch: ContactsSearchProviding, @unchecked Sendable {
|
||||
var called = false
|
||||
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] {
|
||||
called = true
|
||||
return []
|
||||
}
|
||||
}
|
||||
final class SpyAuth: ContactsAuthorizationProviding, @unchecked Sendable {
|
||||
var didRequest = false
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus { .authorized }
|
||||
func requestAccess() throws -> Bool {
|
||||
didRequest = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
let search = SpySearch()
|
||||
let cAuth = SpyAuth()
|
||||
let req = Request(id: "cs-1", operation: "contacts.search", arguments: .object(["query": .string("john"), "limit": .number(10)]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: cAuth, contactsSearchProvider: search, contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate())
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertFalse(cAuth.didRequest, "contacts.search must never trigger authorization request")
|
||||
XCTAssertTrue(search.called)
|
||||
}
|
||||
|
||||
// Bridging tests reuse same bridge pattern – verify contacts bridge pumps run loop
|
||||
func testContactsBridgePumpsMainRunLoop() {
|
||||
let exp = expectation(description: "contacts bridge")
|
||||
final class Box: @unchecked Sendable { var granted = false; var error: Error? = nil }
|
||||
let box = Box()
|
||||
DispatchQueue.main.async {
|
||||
let bridge = ContactsMainRunLoopBridge()
|
||||
do {
|
||||
box.granted = try bridge.requestAccess(timeout: 2) { completion in
|
||||
Timer.scheduledTimer(withTimeInterval: 0.02, repeats: false) { _ in
|
||||
completion(true, nil)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
box.error = error
|
||||
}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 5)
|
||||
XCTAssertNil(box.error)
|
||||
XCTAssertTrue(box.granted)
|
||||
}
|
||||
|
||||
// MARK: - Helpers shared
|
||||
|
||||
struct FakeEventsProvider: CalendarEventsListProviding {
|
||||
var events: [CalendarEventItem]
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] { events }
|
||||
}
|
||||
|
||||
struct FakeCreateProvider: CalendarEventCreateProviding {
|
||||
var willReturn: CalendarEventItem
|
||||
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem { willReturn }
|
||||
}
|
||||
|
||||
func sampleEvent() -> CalendarEventItem {
|
||||
CalendarEventItem(id: "sample", title: "Sample", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "cal1", calendar_title: "Home", notes: nil, location: nil)
|
||||
}
|
||||
|
||||
struct MockCalAuth: CalendarAuthorizationProviding {
|
||||
var status: CalendarAuthorizationStatus
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { status }
|
||||
func requestFullAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
// Isolation: only ContactsAuthorizationProvider.swift should call requestAccess(for: .contacts)
|
||||
func testOnlyContactsAuthorizationProviderCallsRequestAccessForContacts() throws {
|
||||
let fm = FileManager.default
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var dirs: [URL] = []
|
||||
for _ in 0..<10 {
|
||||
let candCore = cur.appendingPathComponent("Sources/ReynaCLIHostCore")
|
||||
if fm.fileExists(atPath: candCore.path) { dirs.append(candCore); break }
|
||||
let cand = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore")
|
||||
if fm.fileExists(atPath: cand.path) { dirs.append(cand); break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard let srcDir = dirs.first else {
|
||||
XCTFail("Could not find ReynaCLIHostCore sources"); return
|
||||
}
|
||||
let files = (try? fm.contentsOfDirectory(at: srcDir, includingPropertiesForKeys: nil)) ?? []
|
||||
var hits: [String] = []
|
||||
for file in files where file.pathExtension == "swift" {
|
||||
guard let content = try? String(contentsOf: file) else { continue }
|
||||
if content.contains("requestAccess(for:") && file.lastPathComponent != "ContactsAuthorizationProvider.swift" {
|
||||
// Calendar provider calls requestFullAccessToEvents – not contacts
|
||||
if content.contains(".contacts") || content.contains("CNContact") {
|
||||
hits.append(file.lastPathComponent)
|
||||
}
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(hits.isEmpty, "Only ContactsAuthorizationProvider.swift should call requestAccess(for: .contacts), found extras: \(hits)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import XCTest
|
||||
@testable import ReynaCLIHostCore
|
||||
import Foundation
|
||||
|
||||
final class ContactsOperationsTests: XCTestCase {
|
||||
|
||||
// MARK: - Fake providers
|
||||
|
||||
struct FakeSearch: ContactsSearchProviding {
|
||||
var contacts: [ContactListItem]
|
||||
var shouldThrow: ContactsProviderError? = nil
|
||||
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] {
|
||||
if let e = shouldThrow { throw e }
|
||||
var filtered = contacts
|
||||
if let q = query, !q.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
let lower = q.lowercased()
|
||||
filtered = filtered.filter { ($0.name + "\n" + $0.organization).lowercased().contains(lower) }
|
||||
}
|
||||
filtered.sort { $0.name < $1.name }
|
||||
if filtered.count > limit { filtered = Array(filtered.prefix(limit)) }
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeRead: ContactsReadProviding {
|
||||
var contact: ContactDetailItem?
|
||||
var shouldThrow: ContactsProviderError? = nil
|
||||
func readContact(id: String) throws -> ContactDetailItem {
|
||||
if let e = shouldThrow { throw e }
|
||||
guard let c = contact, c.id == id else {
|
||||
throw ContactsProviderError.notFound("Contact not found: \(id)")
|
||||
}
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeCreate: ContactsCreateProviding {
|
||||
var result: ContactCreateResult
|
||||
var shouldThrow: ContactsProviderError? = nil
|
||||
func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult {
|
||||
if let e = shouldThrow { throw e }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyCalendarList: CalendarListProviding {
|
||||
func listCalendars() throws -> [CalendarListItem] { [] }
|
||||
}
|
||||
|
||||
struct EmptyEvents: CalendarEventsListProviding {
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] { [] }
|
||||
}
|
||||
|
||||
struct EmptyCreate: CalendarEventCreateProviding {
|
||||
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
|
||||
CalendarEventItem(id: "x", title: "t", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "c", calendar_title: "Home", notes: nil, location: nil)
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyContactsAuth: ContactsAuthorizationProviding {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus { .authorized }
|
||||
func requestAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
func mockCalAuth() -> MockCalendarAuth { MockCalendarAuth(status: .authorized) }
|
||||
|
||||
struct MockCalendarAuth: CalendarAuthorizationProviding {
|
||||
var status: CalendarAuthorizationStatus
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { status }
|
||||
func requestFullAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
// Helpers to build dispatch for contacts only
|
||||
func dispatchContacts(request: Request, search: ContactsSearchProviding = FakeSearch(contacts: []), read: ContactsReadProviding = FakeRead(), create: ContactsCreateProviding = FakeCreate(result: ContactCreateResult(id: "id", name: "Name", organization: "")), auth: ContactsAuthorizationProviding = EmptyContactsAuth()) -> Response {
|
||||
return dispatch(request: request, calendarProvider: EmptyCalendarList(), eventsProvider: EmptyEvents(), createProvider: EmptyCreate(), authProvider: mockCalAuth(), contactsAuthProvider: auth, contactsSearchProvider: search, contactsReadProvider: read, contactsCreateProvider: create)
|
||||
}
|
||||
|
||||
func sampleContactList() -> [ContactListItem] {
|
||||
[
|
||||
ContactListItem(id: "3", name: "Charlie", organization: "OrgC", modifiedAt: "2026-01-01T00:00:00Z"),
|
||||
ContactListItem(id: "1", name: "Alice", organization: "OrgA", modifiedAt: "2026-01-01T00:00:00Z"),
|
||||
ContactListItem(id: "2", name: "Bob", organization: "OrgB", modifiedAt: "2026-01-01T00:00:00Z"),
|
||||
]
|
||||
}
|
||||
|
||||
func sampleContactDetail() -> ContactDetailItem {
|
||||
ContactDetailItem(id: "1", name: "Alice Smith", firstName: "Alice", lastName: "Smith", organization: "OrgA", jobTitle: "Engineer", emails: [ContactEmailLabelValue(label: "work", value: "alice@example.com")], phones: [ContactPhoneLabelValue(label: "mobile", value: "123")], modifiedAt: "2026-01-01T00:00:00Z")
|
||||
}
|
||||
|
||||
// MARK: - Search tests
|
||||
|
||||
func testSearchMissingArgsReturnsAllWithDefaultLimit() {
|
||||
let req = Request(id: "s1", operation: "contacts.search", arguments: .object([:]))
|
||||
let resp = dispatchContacts(request: req, search: FakeSearch(contacts: sampleContactList()))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.contacts?.count, 3)
|
||||
}
|
||||
|
||||
func testSearchLimitBoundedLow() {
|
||||
let req = Request(id: "s2", operation: "contacts.search", arguments: .object(["limit": .number(0)]))
|
||||
let resp = dispatchContacts(request: req)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testSearchLimitBoundedHigh() {
|
||||
let req = Request(id: "s3", operation: "contacts.search", arguments: .object(["limit": .number(101)]))
|
||||
let resp = dispatchContacts(request: req)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testSearchQueryFiltersDeterministically() {
|
||||
let req = Request(id: "s4", operation: "contacts.search", arguments: .object(["query": .string("ali"), "limit": .number(10)]))
|
||||
let resp = dispatchContacts(request: req, search: FakeSearch(contacts: sampleContactList()))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.contacts?.count, 1)
|
||||
XCTAssertEqual(resp.result?.contacts?.first?.name, "Alice")
|
||||
}
|
||||
|
||||
func testSearchSortedByNameOrgId() {
|
||||
let req = Request(id: "s5", operation: "contacts.search", arguments: .object(["limit": .number(10)]))
|
||||
let resp = dispatchContacts(request: req, search: FakeSearch(contacts: sampleContactList()))
|
||||
XCTAssertTrue(resp.ok)
|
||||
let ids = resp.result?.contacts?.map { $0.id }
|
||||
XCTAssertEqual(ids, ["1","2","3"])
|
||||
}
|
||||
|
||||
func testSearchPermissionRequired() {
|
||||
let req = Request(id: "s6", operation: "contacts.search", arguments: .object([:]))
|
||||
let resp = dispatchContacts(request: req, search: FakeSearch(contacts: [], shouldThrow: .permissionRequired))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_required")
|
||||
XCTAssertNil(resp.result)
|
||||
}
|
||||
|
||||
func testSearchContactsNeverPrompts() throws {
|
||||
// Ensure Contacts search providers never call request-access APIs – static source check
|
||||
let fm = FileManager.default
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var providerURL: URL? = nil
|
||||
for _ in 0..<8 {
|
||||
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/ContactsProvider.swift")
|
||||
if fm.fileExists(atPath: cand.path) { providerURL = cand; break }
|
||||
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/ContactsProvider.swift")
|
||||
if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard let url = providerURL, let content = try? String(contentsOf: url) else {
|
||||
XCTFail("ContactsProvider.swift not found"); return
|
||||
}
|
||||
// Protocol declarations like `func requestAccess()` are allowed; actual prompt calls use `requestAccess(for:`
|
||||
// or call on CNContactStore. We forbid `requestAccess(for:` in this file.
|
||||
XCTAssertFalse(content.contains("requestAccess(for:"), "Contacts search/read/create must not call requestAccess(for:) – only auth provider should")
|
||||
XCTAssertFalse(content.contains("requestFullAccess"), "Contacts provider must not call calendar request")
|
||||
}
|
||||
|
||||
// MARK: - Read tests
|
||||
|
||||
func testReadMissingIdInvalidRequest() {
|
||||
let req = Request(id: "r1", operation: "contacts.read", arguments: .object([:]))
|
||||
let resp = dispatchContacts(request: req)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testReadSuccessMinimalFields() {
|
||||
let detail = sampleContactDetail()
|
||||
let req = Request(id: "r2", operation: "contacts.read", arguments: .object(["id": .string("1")]))
|
||||
let resp = dispatchContacts(request: req, read: FakeRead(contact: detail))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.contact?.id, "1")
|
||||
XCTAssertEqual(resp.result?.contact?.firstName, "Alice")
|
||||
XCTAssertEqual(resp.result?.contact?.emails.first?.value, "alice@example.com")
|
||||
}
|
||||
|
||||
func testReadPermissionRequired() {
|
||||
let req = Request(id: "r3", operation: "contacts.read", arguments: .object(["id": .string("1")]))
|
||||
let resp = dispatchContacts(request: req, read: FakeRead(shouldThrow: .permissionRequired))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_required")
|
||||
}
|
||||
|
||||
func testReadNotFoundMapsToInvalid() {
|
||||
let req = Request(id: "r4", operation: "contacts.read", arguments: .object(["id": .string("nope")]))
|
||||
let resp = dispatchContacts(request: req, read: FakeRead(contact: sampleContactDetail()))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
// MARK: - Create tests
|
||||
|
||||
func testCreateMissingNameFieldsInvalid() {
|
||||
let req = Request(id: "c1", operation: "contacts.create", arguments: .object([:]))
|
||||
let resp = dispatchContacts(request: req)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testCreateSuccessReturnsMetadataOnly() {
|
||||
let result = ContactCreateResult(id: "new-id", name: "Alice Smith", organization: "OrgA")
|
||||
let req = Request(id: "c2", operation: "contacts.create", arguments: .object(["firstName": .string("Alice"), "lastName": .string("Smith")]))
|
||||
let resp = dispatchContacts(request: req, create: FakeCreate(result: result))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.created_contact?.id, "new-id")
|
||||
XCTAssertEqual(resp.result?.created_contact?.name, "Alice Smith")
|
||||
// Ensure no excessive fields leaked
|
||||
let encoded = try! JSONEncoder().encode(resp)
|
||||
let obj = try! JSONSerialization.jsonObject(with: encoded) as! [String: Any]
|
||||
let resultObj = obj["result"] as! [String: Any]
|
||||
XCTAssertNotNil(resultObj["created_contact"])
|
||||
XCTAssertNil(resultObj["contact"], "create must not output full contact detail")
|
||||
XCTAssertNil(resultObj["contacts"])
|
||||
}
|
||||
|
||||
func testCreateWithEmailPhoneObjects() {
|
||||
let result = ContactCreateResult(id: "nid", name: "Bob", organization: "")
|
||||
let req = Request(id: "c3", operation: "contacts.create", arguments: .object([
|
||||
"firstName": .string("Bob"),
|
||||
"email": .object(["label": .string("work"), "value": .string("bob@example.com")]),
|
||||
"phone": .object(["label": .string("mobile"), "value": .string("+1555")])
|
||||
]))
|
||||
let resp = dispatchContacts(request: req, create: FakeCreate(result: result))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.created_contact?.name, "Bob")
|
||||
}
|
||||
|
||||
func testCreatePermissionRequired() {
|
||||
let req = Request(id: "c4", operation: "contacts.create", arguments: .object(["firstName": .string("Bob")]))
|
||||
let resp = dispatchContacts(request: req, create: FakeCreate(result: ContactCreateResult(id: "x", name: "x", organization: ""), shouldThrow: .permissionRequired))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_required")
|
||||
}
|
||||
|
||||
func testSearchDoesNotTriggerCreate() {
|
||||
final class CountingCreate: ContactsCreateProviding, @unchecked Sendable {
|
||||
var count = 0
|
||||
func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult {
|
||||
count += 1
|
||||
return ContactCreateResult(id: "x", name: "x", organization: "")
|
||||
}
|
||||
}
|
||||
let counter = CountingCreate()
|
||||
let req = Request(id: "iso", operation: "contacts.search", arguments: .object(["limit": .number(5)]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: EmptyEvents(), createProvider: EmptyCreate(), authProvider: mockCalAuth(), contactsAuthProvider: EmptyContactsAuth(), contactsSearchProvider: FakeSearch(contacts: []), contactsReadProvider: FakeRead(), contactsCreateProvider: counter)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(counter.count, 0, "search must not trigger create")
|
||||
}
|
||||
|
||||
// MARK: - Regression: contacts.search production crash (CNPropertyNotFetchedException)
|
||||
|
||||
func testSearchProductionDoesNotUseCNContactFormatter() throws {
|
||||
let fm = FileManager.default
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var providerURL: URL? = nil
|
||||
for _ in 0..<8 {
|
||||
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/ContactsProvider.swift")
|
||||
if fm.fileExists(atPath: cand.path) { providerURL = cand; break }
|
||||
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/ContactsProvider.swift")
|
||||
if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard let url = providerURL, let content = try? String(contentsOf: url) else {
|
||||
XCTFail("ContactsProvider.swift not found"); return
|
||||
}
|
||||
XCTAssertFalse(content.contains("CNContactFormatter.string("), "Production ContactsProvider must not call CNContactFormatter.string - use only fetched keys to avoid ObjC exception on middleName etc.")
|
||||
XCTAssertFalse(content.contains("CNContactMiddleNameKey"), "Do not add middleName to keysToFetch - fix is to avoid formatter, not fetch more")
|
||||
XCTAssertFalse(content.contains("CNContactNamePrefixKey"), "Avoid extra keys to satisfy formatter")
|
||||
XCTAssertFalse(content.contains("CNContactNameSuffixKey"), "Avoid extra keys to satisfy formatter")
|
||||
XCTAssertFalse(content.contains("CNContactNicknameKey"), "Avoid extra keys to satisfy formatter")
|
||||
}
|
||||
|
||||
func testSearchProductionKeysToFetchWhitelist() throws {
|
||||
let fm = FileManager.default
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var providerURL: URL? = nil
|
||||
for _ in 0..<8 {
|
||||
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/ContactsProvider.swift")
|
||||
if fm.fileExists(atPath: cand.path) { providerURL = cand; break }
|
||||
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/ContactsProvider.swift")
|
||||
if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard let url = providerURL, let content = try? String(contentsOf: url) else {
|
||||
XCTFail("ContactsProvider.swift not found"); return
|
||||
}
|
||||
let lines = content.components(separatedBy: "\n")
|
||||
guard let searchIdx = lines.firstIndex(where: { $0.contains("struct ContactsSearchProvider") }) else {
|
||||
XCTFail("ContactsSearchProvider not found"); return
|
||||
}
|
||||
let searchSlice = lines[searchIdx..<min(searchIdx+30, lines.count)].joined(separator: "\n")
|
||||
XCTAssertTrue(searchSlice.contains("CNContactIdentifierKey"), "search should fetch identifier")
|
||||
XCTAssertTrue(searchSlice.contains("CNContactGivenNameKey"), "search should fetch givenName")
|
||||
XCTAssertTrue(searchSlice.contains("CNContactFamilyNameKey"), "search should fetch familyName")
|
||||
XCTAssertTrue(searchSlice.contains("CNContactOrganizationNameKey"), "search should fetch org for filter")
|
||||
XCTAssertFalse(searchSlice.contains("CNContactEmailAddressesKey"), "search must not fetch emails")
|
||||
XCTAssertFalse(searchSlice.contains("CNContactPhoneNumbersKey"), "search must not fetch phones")
|
||||
XCTAssertFalse(searchSlice.contains("CNContactMiddleNameKey"), "search must not fetch middleName")
|
||||
}
|
||||
|
||||
func testSearchNonmatchingQueryProducesEmptyResultDeterministically() {
|
||||
func displayNameFromFetchedParts(givenName: String, familyName: String) -> String {
|
||||
let combined = "\(givenName) \(familyName)".trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return combined.components(separatedBy: .whitespaces).filter { !$0.isEmpty }.joined(separator: " ")
|
||||
}
|
||||
let contacts = [
|
||||
ContactListItem(id: "1", name: displayNameFromFetchedParts(givenName: "Alice", familyName: "Smith"), organization: "OrgA", modifiedAt: ""),
|
||||
ContactListItem(id: "2", name: displayNameFromFetchedParts(givenName: "Bob", familyName: "Jones"), organization: "OrgB", modifiedAt: ""),
|
||||
]
|
||||
let fake = FakeSearch(contacts: contacts)
|
||||
let syntheticQuery = "zzzz_synthetic_nonmatch_9f3a7c2e"
|
||||
let filtered = try! fake.searchContacts(query: syntheticQuery, limit: 20)
|
||||
XCTAssertEqual(filtered.count, 0, "Synthetic nonmatching query should yield empty result, not crash")
|
||||
|
||||
let req = Request(id: "s-nm", operation: "contacts.search", arguments: .object(["query": .string(syntheticQuery), "limit": .number(20)]))
|
||||
let resp = dispatchContacts(request: req, search: fake)
|
||||
XCTAssertTrue(resp.ok, "Nonmatching search must succeed with ok:true")
|
||||
XCTAssertEqual(resp.result?.contacts?.count, 0, "Nonmatching search must return empty list")
|
||||
}
|
||||
|
||||
func testReadProductionDoesNotUseCNContactFormatter() throws {
|
||||
let fm = FileManager.default
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var providerURL: URL? = nil
|
||||
for _ in 0..<8 {
|
||||
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/ContactsProvider.swift")
|
||||
if fm.fileExists(atPath: cand.path) { providerURL = cand; break }
|
||||
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/ContactsProvider.swift")
|
||||
if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard let url = providerURL, let content = try? String(contentsOf: url) else {
|
||||
XCTFail("ContactsProvider.swift not found"); return
|
||||
}
|
||||
XCTAssertFalse(content.contains("CNContactFormatter.string("), "Read must not use CNContactFormatter either")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import XCTest
|
||||
import Foundation
|
||||
|
||||
/// Executable-level integration tests invoking the compiled ReynaCLIHost binary.
|
||||
/// These prove persistent-pipe and malformed-request behavior.
|
||||
final class HostIntegrationTests: XCTestCase {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
func hostExecutableURL() throws -> URL {
|
||||
let fm = FileManager.default
|
||||
// When `swift test` runs, cwd is package root. But be robust.
|
||||
// Portable candidates only: package-relative .build locations for common triples.
|
||||
let candidates: [String] = [
|
||||
".build/debug/ReynaCLIHost",
|
||||
".build/arm64-apple-macosx/debug/ReynaCLIHost",
|
||||
".build/x86_64-apple-macosx/debug/ReynaCLIHost",
|
||||
]
|
||||
let cwd = fm.currentDirectoryPath
|
||||
var tried: [String] = []
|
||||
for c in candidates {
|
||||
let url = URL(fileURLWithPath: cwd).appendingPathComponent(c)
|
||||
tried.append(url.path)
|
||||
if fm.isExecutableFile(atPath: url.path) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
// Try surrounding .build directories walked upward from cwd
|
||||
var parent = URL(fileURLWithPath: cwd)
|
||||
for _ in 0..<6 {
|
||||
let p1 = parent.appendingPathComponent(".build/debug/ReynaCLIHost")
|
||||
tried.append(p1.path)
|
||||
if fm.isExecutableFile(atPath: p1.path) { return p1 }
|
||||
let p2 = parent.appendingPathComponent(".build/arm64-apple-macosx/debug/ReynaCLIHost")
|
||||
tried.append(p2.path)
|
||||
if fm.isExecutableFile(atPath: p2.path) { return p2 }
|
||||
parent = parent.deletingLastPathComponent()
|
||||
}
|
||||
throw NSError(domain: "HostIntegrationTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "ReynaCLIHost binary not found. Tried:\n" + tried.joined(separator: "\n")])
|
||||
}
|
||||
|
||||
/// Run host with given stdin string, return stdout lines (non-empty trimmed) after process exits.
|
||||
func runHost(input: String, timeout: TimeInterval = 5) throws -> [String] {
|
||||
let exe = try hostExecutableURL()
|
||||
let process = Process()
|
||||
process.executableURL = exe
|
||||
let stdinPipe = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.standardInput = stdinPipe
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
|
||||
try process.run()
|
||||
|
||||
// Write input then close
|
||||
if let data = input.data(using: .utf8) {
|
||||
stdinPipe.fileHandleForWriting.write(data)
|
||||
}
|
||||
stdinPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
// Wait with timeout
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while process.isRunning && Date() < deadline {
|
||||
usleep(100_000) // 0.1s
|
||||
}
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
throw NSError(domain: "HostIntegrationTests", code: 2, userInfo: [NSLocalizedDescriptionKey: "Host process timed out after \(timeout)s. stderr: \(String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "")"])
|
||||
}
|
||||
|
||||
let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let outStr = String(data: outData, encoding: .utf8) ?? ""
|
||||
// Split by newline, keep non-empty raw lines but preserve for debugging
|
||||
let lines = outStr.split(separator: "\n", omittingEmptySubsequences: false).map { String($0) }.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
return lines
|
||||
}
|
||||
|
||||
func decodeResponse(_ line: String) throws -> [String: Any] {
|
||||
guard let data = line.data(using: .utf8),
|
||||
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw NSError(domain: "HostIntegrationTests", code: 3, userInfo: [NSLocalizedDescriptionKey: "Line is not valid JSON: \(line)"])
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
func testPersistentPipeHandlesTwoHealthRequests() throws {
|
||||
// Two well-formed health requests on persistent stdin must yield two responses.
|
||||
let req1 = #"{"id":"1","operation":"service.health","arguments":{}}"#
|
||||
let req2 = #"{"id":"2","operation":"service.health","arguments":{}}"#
|
||||
let input = req1 + "\n" + req2 + "\n"
|
||||
let lines = try runHost(input: input)
|
||||
XCTAssertEqual(lines.count, 2, "Expected 2 responses for 2 requests, got \(lines.count). Output: \(lines)")
|
||||
|
||||
let resp1 = try decodeResponse(lines[0])
|
||||
XCTAssertEqual(resp1["id"] as? String, "1")
|
||||
XCTAssertEqual(resp1["ok"] as? Bool, true)
|
||||
|
||||
let resp2 = try decodeResponse(lines[1])
|
||||
XCTAssertEqual(resp2["id"] as? String, "2")
|
||||
XCTAssertEqual(resp2["ok"] as? Bool, true)
|
||||
}
|
||||
|
||||
func testMalformedJsonProducesInvalidRequestResponseWithoutId() throws {
|
||||
// Malformed nonempty JSON must produce a response with ok:false, error.code invalid_request, id = ""
|
||||
let bad = "not json at all"
|
||||
let input = bad + "\n"
|
||||
let lines = try runHost(input: input)
|
||||
XCTAssertEqual(lines.count, 1, "Malformed JSON should produce one error response, got \(lines.count). Output: \(lines)")
|
||||
|
||||
let resp = try decodeResponse(lines[0])
|
||||
XCTAssertEqual(resp["ok"] as? Bool, false, "Malformed JSON should be ok:false")
|
||||
XCTAssertEqual(resp["id"] as? String, "", "When id cannot be recovered, id should be empty string")
|
||||
if let err = resp["error"] as? [String: Any] {
|
||||
XCTAssertEqual(err["code"] as? String, "invalid_request")
|
||||
} else {
|
||||
XCTFail("Missing error object in response: \(resp)")
|
||||
}
|
||||
}
|
||||
|
||||
func testMalformedJsonPreservesIdWhenPossible() throws {
|
||||
// When malformed JSON still contains an id field, preserve it.
|
||||
let bad = #"{"id":"keep-me","operation":}"# // invalid JSON but id extractable
|
||||
let input = bad + "\n"
|
||||
let lines = try runHost(input: input)
|
||||
XCTAssertEqual(lines.count, 1, "Expected 1 error response for malformed JSON with id, got \(lines)")
|
||||
let resp = try decodeResponse(lines[0])
|
||||
XCTAssertEqual(resp["ok"] as? Bool, false)
|
||||
XCTAssertEqual(resp["id"] as? String, "keep-me", "Should preserve id when recoverable")
|
||||
if let err = resp["error"] as? [String: Any] {
|
||||
XCTAssertEqual(err["code"] as? String, "invalid_request")
|
||||
} else {
|
||||
XCTFail("Missing error object")
|
||||
}
|
||||
}
|
||||
|
||||
func testEmptyLinesAreIgnored() throws {
|
||||
// Empty lines should not produce responses or break subsequent messages.
|
||||
let req1 = #"{"id":"a","operation":"service.health","arguments":{}}"#
|
||||
let req2 = #"{"id":"b","operation":"service.health","arguments":{}}"#
|
||||
let input = "\n" + req1 + "\n\n\n" + req2 + "\n\n"
|
||||
let lines = try runHost(input: input)
|
||||
XCTAssertEqual(lines.count, 2, "Empty lines should be ignored, expected 2 responses got \(lines.count): \(lines)")
|
||||
let ids = try lines.map { try decodeResponse($0)["id"] as? String }
|
||||
XCTAssertEqual(ids, ["a", "b"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import XCTest
|
||||
@testable import ReynaCLIHostCore
|
||||
|
||||
final class ProtocolTests: XCTestCase {
|
||||
|
||||
func testServiceHealthReturnsOKWithSameIdAndProtocolVersion() throws {
|
||||
let req = Request(id: "x", operation: "service.health", arguments: Args())
|
||||
let resp = dispatch(request: req)
|
||||
|
||||
XCTAssertEqual(resp.id, "x", "response must echo same id")
|
||||
XCTAssertTrue(resp.ok, "service.health should be ok:true")
|
||||
XCTAssertNotNil(resp.result, "result must be present on success")
|
||||
XCTAssertEqual(resp.result?.operation, "service.health")
|
||||
XCTAssertFalse(resp.result?.protocol_version.isEmpty ?? true, "protocol_version must be nonempty")
|
||||
XCTAssertNil(resp.error, "error must be nil on success")
|
||||
}
|
||||
|
||||
func testServiceHealthDecodedFromJSON() throws {
|
||||
let json = #"{"id":"abc-123","operation":"service.health","arguments":{}}"#
|
||||
let data = json.data(using: .utf8)!
|
||||
let decoder = JSONDecoder()
|
||||
let req = try decoder.decode(Request.self, from: data)
|
||||
|
||||
let resp = dispatch(request: req)
|
||||
|
||||
XCTAssertEqual(resp.id, "abc-123")
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.operation, "service.health")
|
||||
XCTAssertFalse(resp.result?.protocol_version.isEmpty ?? true)
|
||||
}
|
||||
|
||||
func testUnknownOperationReturnsError() throws {
|
||||
let req = Request(id: "y", operation: "does.not.exist", arguments: Args())
|
||||
let resp = dispatch(request: req)
|
||||
|
||||
XCTAssertEqual(resp.id, "y")
|
||||
XCTAssertFalse(resp.ok, "unknown operation must return ok:false")
|
||||
XCTAssertNil(resp.result, "result must be nil on failure")
|
||||
XCTAssertNotNil(resp.error)
|
||||
XCTAssertEqual(resp.error?.code, "unknown_operation")
|
||||
}
|
||||
|
||||
func testUnknownOperationJSONRoundTrip() throws {
|
||||
let json = #"{"id":"1","operation":"foo.bar","arguments":{}}"#
|
||||
let req = try JSONDecoder().decode(Request.self, from: json.data(using: .utf8)!)
|
||||
let resp = dispatch(request: req)
|
||||
let encoded = try JSONEncoder().encode(resp)
|
||||
let decoded = try JSONDecoder().decode(Response.self, from: encoded)
|
||||
|
||||
XCTAssertEqual(decoded.id, "1")
|
||||
XCTAssertFalse(decoded.ok)
|
||||
XCTAssertEqual(decoded.error?.code, "unknown_operation")
|
||||
}
|
||||
|
||||
func testDispatchIsDeterministic() throws {
|
||||
let req = Request(id: "same", operation: "service.health", arguments: Args())
|
||||
let r1 = dispatch(request: req)
|
||||
let r2 = dispatch(request: req)
|
||||
XCTAssertEqual(r1.id, r2.id)
|
||||
XCTAssertEqual(r1.ok, r2.ok)
|
||||
XCTAssertEqual(r1.result?.protocol_version, r2.result?.protocol_version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import XCTest
|
||||
@testable import ReynaCLIHostCore
|
||||
import Foundation
|
||||
|
||||
// Tests for reminders.request_full_access – TDD, fake providers only
|
||||
// Modeled after CalendarAuthorizationTests.swift
|
||||
|
||||
final class RemindersAuthorizationTests: XCTestCase {
|
||||
|
||||
// MARK: - Fake auth providers
|
||||
|
||||
struct AlreadyAuthorizedProvider: RemindersAuthorizationProviding {
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { .authorized }
|
||||
func requestFullAccess() throws -> Bool {
|
||||
XCTFail("requestFullAccess must not be called when already authorized")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
struct NotDeterminedGrantedProvider: RemindersAuthorizationProviding {
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined }
|
||||
func requestFullAccess() throws -> Bool { true }
|
||||
}
|
||||
|
||||
struct NotDeterminedDeniedProvider: RemindersAuthorizationProviding {
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined }
|
||||
func requestFullAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
struct DeniedProvider: RemindersAuthorizationProviding {
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { .denied }
|
||||
func requestFullAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
struct TimeoutProvider: RemindersAuthorizationProviding {
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined }
|
||||
func requestFullAccess() throws -> Bool {
|
||||
throw RemindersProviderError.unavailable("reminders authorization timed out")
|
||||
}
|
||||
}
|
||||
|
||||
struct ErrorProvider: RemindersAuthorizationProviding {
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined }
|
||||
func requestFullAccess() throws -> Bool {
|
||||
throw RemindersProviderError.unavailable("disk error")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty reminders providers for dispatch
|
||||
|
||||
struct EmptyLists: RemindersListsProviding {
|
||||
func listReminderLists() throws -> [ReminderListItem] { [] }
|
||||
}
|
||||
|
||||
struct EmptyList: RemindersListProviding {
|
||||
func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem] { [] }
|
||||
}
|
||||
|
||||
struct EmptyCreate: RemindersCreateProviding {
|
||||
func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult {
|
||||
ReminderCreateResult(id: "r1", list_id: "l1", list_title: "t", title: title)
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyCalList: CalendarListProviding { func listCalendars() throws -> [CalendarListItem] { [] } }
|
||||
struct EmptyCalEvents: CalendarEventsListProviding {
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] { [] }
|
||||
}
|
||||
struct EmptyCalCreate: CalendarEventCreateProviding {
|
||||
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
|
||||
CalendarEventItem(id: "e", title: title, start: "2026-01-01T00:00:00Z", end: "2026-01-01T01:00:00Z", all_day: false, calendar_id: "c", calendar_title: "t", notes: nil, location: nil)
|
||||
}
|
||||
}
|
||||
struct MockCalAuth: CalendarAuthorizationProviding {
|
||||
var status: CalendarAuthorizationStatus = .authorized
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { status }
|
||||
func requestFullAccess() throws -> Bool { false }
|
||||
}
|
||||
struct EmptyContactsAuth: ContactsAuthorizationProviding {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus { .authorized }
|
||||
func requestAccess() throws -> Bool { false }
|
||||
}
|
||||
struct EmptyContactsSearch: ContactsSearchProviding { func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] { [] } }
|
||||
struct EmptyContactsRead: ContactsReadProviding {
|
||||
func readContact(id: String) throws -> ContactDetailItem { throw ContactsProviderError.notFound("nf") }
|
||||
}
|
||||
struct EmptyContactsCreate: ContactsCreateProviding {
|
||||
func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult {
|
||||
throw ContactsProviderError.unavailable("na")
|
||||
}
|
||||
}
|
||||
|
||||
private func dispatchRemindersAuth(op: String, id: String, auth: RemindersAuthorizationProviding) -> Response {
|
||||
let req = Request(id: id, operation: op, arguments: .object([:]))
|
||||
return dispatch(
|
||||
request: req,
|
||||
calendarProvider: EmptyCalList(),
|
||||
eventsProvider: EmptyCalEvents(),
|
||||
createProvider: EmptyCalCreate(),
|
||||
authProvider: MockCalAuth(),
|
||||
contactsAuthProvider: EmptyContactsAuth(),
|
||||
contactsSearchProvider: EmptyContactsSearch(),
|
||||
contactsReadProvider: EmptyContactsRead(),
|
||||
contactsCreateProvider: EmptyContactsCreate(),
|
||||
remindersAuthProvider: auth,
|
||||
remindersListsProvider: EmptyLists(),
|
||||
remindersListProvider: EmptyList(),
|
||||
remindersCreateProvider: EmptyCreate()
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Core auth behavior
|
||||
|
||||
func testAlreadyAuthorizedReturnsAuthorizedWithoutRequesting() {
|
||||
let auth = AlreadyAuthorizedProvider()
|
||||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra1", auth: auth)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.status, "authorized")
|
||||
XCTAssertEqual(resp.result?.operation, "reminders.request_full_access")
|
||||
XCTAssertEqual(resp.id, "ra1")
|
||||
}
|
||||
|
||||
func testNotDeterminedReachesRequestPath() {
|
||||
final class TrackingProvider: RemindersAuthorizationProviding, @unchecked Sendable {
|
||||
var didRequest = false
|
||||
var status: RemindersAuthorizationStatus = .notDetermined
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { status }
|
||||
func requestFullAccess() throws -> Bool {
|
||||
didRequest = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
let tracking = TrackingProvider()
|
||||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra2", auth: tracking)
|
||||
XCTAssertTrue(tracking.didRequest, "requestFullAccess must be called when notDetermined")
|
||||
XCTAssertTrue(resp.ok)
|
||||
}
|
||||
|
||||
func testGrantedReturnsAuthorizedResult() {
|
||||
let auth = NotDeterminedGrantedProvider()
|
||||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra3", auth: auth)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.status, "authorized")
|
||||
XCTAssertEqual(resp.result?.protocol_version, PROTOCOL_VERSION)
|
||||
XCTAssertNil(resp.error)
|
||||
XCTAssertNil(resp.result?.reminders, "must not output reminder content")
|
||||
XCTAssertNil(resp.result?.reminder_lists)
|
||||
XCTAssertNil(resp.result?.reminder)
|
||||
}
|
||||
|
||||
func testDeniedReturnsPermissionDenied() {
|
||||
let auth = NotDeterminedDeniedProvider()
|
||||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra4", auth: auth)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_denied")
|
||||
XCTAssertNotNil(resp.error?.message)
|
||||
XCTAssertNil(resp.result)
|
||||
}
|
||||
|
||||
func testAlreadyDeniedPathAlsoDenies() {
|
||||
let auth = DeniedProvider()
|
||||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra5", auth: auth)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_denied")
|
||||
}
|
||||
|
||||
func testTimeoutReturnsRemindersUnavailable() {
|
||||
let auth = TimeoutProvider()
|
||||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra6", auth: auth)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "reminders_unavailable")
|
||||
XCTAssertTrue(resp.error?.message.lowercased().contains("timed out") ?? false)
|
||||
}
|
||||
|
||||
func testErrorReturnsRemindersUnavailable() {
|
||||
let auth = ErrorProvider()
|
||||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra7", auth: auth)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "reminders_unavailable")
|
||||
}
|
||||
|
||||
func testNoReminderDataInAuthResponses() {
|
||||
let authOk = NotDeterminedGrantedProvider()
|
||||
let respOk = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ok", auth: authOk)
|
||||
XCTAssertNil(respOk.result?.reminders)
|
||||
XCTAssertNil(respOk.result?.reminder_lists)
|
||||
XCTAssertNil(respOk.result?.reminder)
|
||||
XCTAssertNil(respOk.result?.created_reminder)
|
||||
XCTAssertNil(respOk.result?.calendars)
|
||||
XCTAssertNil(respOk.result?.events)
|
||||
|
||||
let authDen = NotDeterminedDeniedProvider()
|
||||
let respDen = dispatchRemindersAuth(op: "reminders.request_full_access", id: "den", auth: authDen)
|
||||
XCTAssertNil(respDen.result)
|
||||
}
|
||||
|
||||
// MARK: - list / create must never prompt
|
||||
|
||||
func testRemindersListsDoesNotCallAuthRequest() {
|
||||
final class SpyLists: RemindersListsProviding, @unchecked Sendable {
|
||||
var called = false
|
||||
func listReminderLists() throws -> [ReminderListItem] { called = true; return [] }
|
||||
}
|
||||
final class SpyAuth: RemindersAuthorizationProviding, @unchecked Sendable {
|
||||
var didCallStatus = false
|
||||
var didCallRequest = false
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { didCallStatus = true; return .authorized }
|
||||
func requestFullAccess() throws -> Bool { didCallRequest = true; return false }
|
||||
}
|
||||
let lists = SpyLists()
|
||||
let auth = SpyAuth()
|
||||
let req = Request(id: "rl-1", operation: "reminders.lists", arguments: .object([:]))
|
||||
let resp = dispatch(
|
||||
request: req,
|
||||
calendarProvider: EmptyCalList(),
|
||||
eventsProvider: EmptyCalEvents(),
|
||||
createProvider: EmptyCalCreate(),
|
||||
authProvider: MockCalAuth(),
|
||||
contactsAuthProvider: EmptyContactsAuth(),
|
||||
contactsSearchProvider: EmptyContactsSearch(),
|
||||
contactsReadProvider: EmptyContactsRead(),
|
||||
contactsCreateProvider: EmptyContactsCreate(),
|
||||
remindersAuthProvider: auth,
|
||||
remindersListsProvider: lists,
|
||||
remindersListProvider: EmptyList(),
|
||||
remindersCreateProvider: EmptyCreate()
|
||||
)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertFalse(auth.didCallRequest, "reminders.lists must never call requestFullAccess")
|
||||
XCTAssertTrue(lists.called)
|
||||
}
|
||||
|
||||
func testRemindersListDoesNotCallAuthRequest() {
|
||||
final class SpyList: RemindersListProviding, @unchecked Sendable {
|
||||
var called = false
|
||||
func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem] { called = true; return [] }
|
||||
}
|
||||
final class SpyAuth: RemindersAuthorizationProviding, @unchecked Sendable {
|
||||
var didCallRequest = false
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { .authorized }
|
||||
func requestFullAccess() throws -> Bool { didCallRequest = true; return false }
|
||||
}
|
||||
let rl = SpyList()
|
||||
let auth = SpyAuth()
|
||||
let req = Request(id: "r-1", operation: "reminders.list", arguments: .object(["limit": .number(10)]))
|
||||
let resp = dispatch(
|
||||
request: req,
|
||||
calendarProvider: EmptyCalList(),
|
||||
eventsProvider: EmptyCalEvents(),
|
||||
createProvider: EmptyCalCreate(),
|
||||
authProvider: MockCalAuth(),
|
||||
contactsAuthProvider: EmptyContactsAuth(),
|
||||
contactsSearchProvider: EmptyContactsSearch(),
|
||||
contactsReadProvider: EmptyContactsRead(),
|
||||
contactsCreateProvider: EmptyContactsCreate(),
|
||||
remindersAuthProvider: auth,
|
||||
remindersListsProvider: EmptyLists(),
|
||||
remindersListProvider: rl,
|
||||
remindersCreateProvider: EmptyCreate()
|
||||
)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertFalse(auth.didCallRequest, "reminders.list must never trigger authorization request")
|
||||
XCTAssertTrue(rl.called)
|
||||
}
|
||||
|
||||
func testRemindersCreateDoesNotCallAuthRequest() {
|
||||
final class SpyCreate: RemindersCreateProviding, @unchecked Sendable {
|
||||
var called = false
|
||||
func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult {
|
||||
called = true
|
||||
return ReminderCreateResult(id: "x", list_id: "l", list_title: "t", title: title)
|
||||
}
|
||||
}
|
||||
final class SpyAuth: RemindersAuthorizationProviding, @unchecked Sendable {
|
||||
var didCallRequest = false
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { .authorized }
|
||||
func requestFullAccess() throws -> Bool { didCallRequest = true; return false }
|
||||
}
|
||||
let create = SpyCreate()
|
||||
let auth = SpyAuth()
|
||||
let req = Request(id: "rc-1", operation: "reminders.create", arguments: .object(["title": .string("Buy milk"), "list": .string("Groceries")]))
|
||||
let resp = dispatch(
|
||||
request: req,
|
||||
calendarProvider: EmptyCalList(),
|
||||
eventsProvider: EmptyCalEvents(),
|
||||
createProvider: EmptyCalCreate(),
|
||||
authProvider: MockCalAuth(),
|
||||
contactsAuthProvider: EmptyContactsAuth(),
|
||||
contactsSearchProvider: EmptyContactsSearch(),
|
||||
contactsReadProvider: EmptyContactsRead(),
|
||||
contactsCreateProvider: EmptyContactsCreate(),
|
||||
remindersAuthProvider: auth,
|
||||
remindersListsProvider: EmptyLists(),
|
||||
remindersListProvider: EmptyList(),
|
||||
remindersCreateProvider: create
|
||||
)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertFalse(auth.didCallRequest, "reminders.create must never trigger authorization request (permission check is via status only in real provider, but here we assert no prompt)")
|
||||
XCTAssertTrue(create.called)
|
||||
}
|
||||
|
||||
// MARK: - Bridge tests – deterministic pump without real EventKit
|
||||
|
||||
final class TestBox<T>: @unchecked Sendable {
|
||||
var value: T
|
||||
init(_ v: T) { value = v }
|
||||
}
|
||||
|
||||
func testBridgePumpsMainRunLoopAndDeliversMainQueueCallback() throws {
|
||||
let exp = expectation(description: "bridge completes")
|
||||
let grantedBox = TestBox(false)
|
||||
let errorBox = TestBox<Error?>(nil)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let bridge = RemindersMainRunLoopBridge()
|
||||
do {
|
||||
grantedBox.value = try bridge.requestAccess(timeout: 2) { completion in
|
||||
Timer.scheduledTimer(withTimeInterval: 0.02, repeats: false) { _ in
|
||||
completion(true, nil)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorBox.value = error
|
||||
}
|
||||
exp.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [exp], timeout: 5)
|
||||
XCTAssertNil(errorBox.value, "bridge must not timeout when it pumps main run loop; got \(String(describing: errorBox.value))")
|
||||
XCTAssertTrue(grantedBox.value, "granted should be true after main-queue callback is pumped")
|
||||
}
|
||||
|
||||
func testBridgeHandlesCompletionExactlyOnce() throws {
|
||||
let exp = expectation(description: "exactly once")
|
||||
let resultBox = TestBox(false)
|
||||
DispatchQueue.main.async {
|
||||
let bridge = RemindersMainRunLoopBridge()
|
||||
do {
|
||||
resultBox.value = try bridge.requestAccess(timeout: 1) { completion in
|
||||
completion(true, nil)
|
||||
completion(false, NSError(domain: "should-be-ignored", code: 1))
|
||||
}
|
||||
} catch {}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 2)
|
||||
XCTAssertTrue(resultBox.value, "First completion should win")
|
||||
}
|
||||
|
||||
func testBridgeThreadSafetyForConcurrentCompletion() throws {
|
||||
let exp = expectation(description: "thread-safe")
|
||||
let grantedBox = TestBox(false)
|
||||
let doneBox = TestBox(false)
|
||||
DispatchQueue.main.async {
|
||||
let bridge = RemindersMainRunLoopBridge()
|
||||
do {
|
||||
grantedBox.value = try bridge.requestAccess(timeout: 1) { completion in
|
||||
DispatchQueue.global().async { completion(true, nil) }
|
||||
DispatchQueue.global().async { completion(false, nil) }
|
||||
}
|
||||
doneBox.value = true
|
||||
} catch {}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 2)
|
||||
XCTAssertTrue(doneBox.value, "bridge must complete even with concurrent completions")
|
||||
}
|
||||
|
||||
func testBridgePropagatesError() throws {
|
||||
let exp = expectation(description: "error propagation")
|
||||
let caughtBox = TestBox(false)
|
||||
DispatchQueue.main.async {
|
||||
let bridge = RemindersMainRunLoopBridge()
|
||||
do {
|
||||
_ = try bridge.requestAccess(timeout: 1) { completion in
|
||||
completion(false, NSError(domain: "test", code: 2, userInfo: [NSLocalizedDescriptionKey: "fake EK error"]))
|
||||
}
|
||||
} catch let err as RemindersProviderError {
|
||||
if case .unavailable(let msg) = err {
|
||||
caughtBox.value = msg.contains("fake EK error")
|
||||
}
|
||||
} catch {}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 2)
|
||||
XCTAssertTrue(caughtBox.value, "Error from EK completion must be wrapped as reminders_unavailable")
|
||||
}
|
||||
|
||||
func testBridgeTimeoutReturnsCorrectError() throws {
|
||||
let exp = expectation(description: "timeout")
|
||||
let codeBox = TestBox("")
|
||||
DispatchQueue.main.async {
|
||||
let bridge = RemindersMainRunLoopBridge()
|
||||
do {
|
||||
_ = try bridge.requestAccess(timeout: 0.15) { _ in }
|
||||
XCTFail("Should have thrown")
|
||||
} catch let err as RemindersProviderError {
|
||||
if case .unavailable(let msg) = err {
|
||||
codeBox.value = msg
|
||||
}
|
||||
} catch {}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 2)
|
||||
XCTAssertTrue(codeBox.value.lowercased().contains("timed out"), "Timeout must produce 'reminders authorization timed out' message, got \(codeBox.value)")
|
||||
}
|
||||
|
||||
// MARK: - Production code location check
|
||||
|
||||
func testOnlyOneFileCallsRequestFullAccessToReminders() throws {
|
||||
let fm = FileManager.default
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var dirs: [URL] = []
|
||||
for _ in 0..<10 {
|
||||
let cand = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore")
|
||||
if fm.fileExists(atPath: cand.path) { dirs.append(cand); break }
|
||||
let cand2 = cur.appendingPathComponent("Sources/ReynaCLIHostCore")
|
||||
if fm.fileExists(atPath: cand2.path) { dirs.append(cand2); break }
|
||||
let cand3 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost")
|
||||
if fm.fileExists(atPath: cand3.path) { dirs.append(cand3) }
|
||||
let cand4 = cur.appendingPathComponent("Sources/ReynaCLIHost")
|
||||
if fm.fileExists(atPath: cand4.path) { dirs.append(cand4) }
|
||||
if !dirs.isEmpty { break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard !dirs.isEmpty else {
|
||||
XCTFail("Could not locate Sources/ReynaCLIHost dir")
|
||||
return
|
||||
}
|
||||
var hits: [String] = []
|
||||
for srcDir in dirs {
|
||||
let files = (try? fm.contentsOfDirectory(at: srcDir, includingPropertiesForKeys: nil)) ?? []
|
||||
for file in files where file.pathExtension == "swift" {
|
||||
guard let content = try? String(contentsOf: file) else { continue }
|
||||
if content.contains("requestFullAccessToReminders") {
|
||||
hits.append(file.lastPathComponent)
|
||||
}
|
||||
}
|
||||
}
|
||||
let uniqueSorted = Array(Set(hits)).sorted()
|
||||
XCTAssertEqual(uniqueSorted, ["RemindersAuthorizationProvider.swift"], "requestFullAccessToReminders must only appear in RemindersAuthorizationProvider.swift, found in \(uniqueSorted)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import XCTest
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
// Mirrors the pure auth decision we expect to exist in SocketServer.swift after fix.
|
||||
func referenceIsPeerAuthorized(peerUID: uid_t, currentUID: uid_t) -> Bool {
|
||||
return peerUID == currentUID
|
||||
}
|
||||
|
||||
final class SecurityHardeningTests: XCTestCase {
|
||||
|
||||
func hostExecutableURL() throws -> URL {
|
||||
let fm = FileManager.default
|
||||
let candidates = [
|
||||
".build/debug/ReynaCLIHost",
|
||||
".build/arm64-apple-macosx/debug/ReynaCLIHost",
|
||||
".build/x86_64-apple-macosx/debug/ReynaCLIHost",
|
||||
]
|
||||
let cwd = fm.currentDirectoryPath
|
||||
var tried: [String] = []
|
||||
for c in candidates {
|
||||
let url = URL(fileURLWithPath: cwd).appendingPathComponent(c)
|
||||
tried.append(url.path)
|
||||
if fm.isExecutableFile(atPath: url.path) { return url }
|
||||
}
|
||||
var parent = URL(fileURLWithPath: cwd)
|
||||
for _ in 0..<6 {
|
||||
let p1 = parent.appendingPathComponent(".build/debug/ReynaCLIHost")
|
||||
tried.append(p1.path)
|
||||
if fm.isExecutableFile(atPath: p1.path) { return p1 }
|
||||
parent = parent.deletingLastPathComponent()
|
||||
}
|
||||
throw NSError(domain: "SecurityHardeningTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "binary not found Tried:\n"+tried.joined(separator: "\n")])
|
||||
}
|
||||
|
||||
func makeShortUniqueDirChecked() throws -> URL {
|
||||
let fm = FileManager.default
|
||||
for _ in 0..<20 {
|
||||
let hex = String(format: "%08x", UInt32.random(in: 0...UInt32.max))
|
||||
let url = URL(fileURLWithPath: "/tmp/rh-\(hex)")
|
||||
if !fm.fileExists(atPath: url.path) { return url }
|
||||
}
|
||||
return URL(fileURLWithPath: "/tmp/rh-\(String(format: "%08x", UInt32.random(in: 0...UInt32.max)))")
|
||||
}
|
||||
|
||||
final class HostProcess {
|
||||
let process: Process
|
||||
let socketPath: String
|
||||
let tempDir: URL
|
||||
init(process: Process, socketPath: String, tempDir: URL) {
|
||||
self.process = process; self.socketPath = socketPath; self.tempDir = tempDir
|
||||
}
|
||||
func terminate() {
|
||||
if process.isRunning { process.terminate() }
|
||||
let deadline = Date().addingTimeInterval(2)
|
||||
while process.isRunning && Date() < deadline { usleep(100_000) }
|
||||
if process.isRunning { process.interrupt() }
|
||||
}
|
||||
deinit { terminate() }
|
||||
}
|
||||
|
||||
func startSocketHost(socketPath: String, tempDir: URL) throws -> HostProcess {
|
||||
let exe = try hostExecutableURL()
|
||||
let process = Process()
|
||||
process.executableURL = exe
|
||||
process.arguments = ["--socket", socketPath]
|
||||
let stderr = Pipe()
|
||||
process.standardError = stderr
|
||||
process.standardOutput = Pipe()
|
||||
process.standardInput = Pipe()
|
||||
try process.run()
|
||||
let fm = FileManager.default
|
||||
let deadline = Date().addingTimeInterval(5)
|
||||
while Date() < deadline {
|
||||
if fm.fileExists(atPath: socketPath) { break }
|
||||
if !process.isRunning {
|
||||
let data = stderr.fileHandleForReading.readDataToEndOfFile()
|
||||
let s = String(data: data, encoding: .utf8) ?? ""
|
||||
throw NSError(domain: "SecurityHardeningTests", code: 2, userInfo: [NSLocalizedDescriptionKey: "Host exited early. stderr: \(s)"])
|
||||
}
|
||||
usleep(100_000)
|
||||
}
|
||||
if !fm.fileExists(atPath: socketPath) {
|
||||
process.terminate()
|
||||
let data = stderr.fileHandleForReading.readDataToEndOfFile()
|
||||
let s = String(data: data, encoding: .utf8) ?? ""
|
||||
throw NSError(domain: "SecurityHardeningTests", code: 3, userInfo: [NSLocalizedDescriptionKey: "Socket not created at \(socketPath). stderr: \(s)"])
|
||||
}
|
||||
return HostProcess(process: process, socketPath: socketPath, tempDir: tempDir)
|
||||
}
|
||||
|
||||
func socketRequestResponse(socketPath: String, requestLine: String, timeout: TimeInterval = 3) throws -> String {
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { throw NSError(domain: "SecurityHardeningTests", code: 10, userInfo: nil) }
|
||||
defer { close(fd) }
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
|
||||
_ = socketPath.withCString { cStr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { dstPtr in
|
||||
dstPtr.withMemoryRebound(to: CChar.self, capacity: 104) { charPtr in
|
||||
strncpy(charPtr, cStr, 103)
|
||||
}
|
||||
}
|
||||
}
|
||||
let addrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
|
||||
let cr = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in
|
||||
connect(fd, saddr, addrLen)
|
||||
}
|
||||
}
|
||||
guard cr == 0 else { throw NSError(domain: "SecurityHardeningTests", code: 11, userInfo: [NSLocalizedDescriptionKey: "connect failed: \(String(cString: strerror(errno)))"]) }
|
||||
let toSend = requestLine.hasSuffix("\n") ? requestLine : requestLine + "\n"
|
||||
guard let data = toSend.data(using: .utf8) else { throw NSError(domain: "SecurityHardeningTests", code: 12, userInfo: nil) }
|
||||
var sent = 0
|
||||
while sent < data.count {
|
||||
let n = data.withUnsafeBytes { raw in send(fd, raw.baseAddress!.advanced(by: sent), data.count - sent, 0) }
|
||||
if n <= 0 { throw NSError(domain: "SecurityHardeningTests", code: 13, userInfo: nil) }
|
||||
sent += n
|
||||
}
|
||||
var responseData = Data()
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
let start = Date()
|
||||
while true {
|
||||
if Date().timeIntervalSince(start) > timeout {
|
||||
throw NSError(domain: "SecurityHardeningTests", code: 14, userInfo: [NSLocalizedDescriptionKey: "read timeout"])
|
||||
}
|
||||
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
||||
let pr = poll(&pfd, 1, 200)
|
||||
if pr < 0 { if errno == EINTR { continue }; throw NSError(domain: "SecurityHardeningTests", code: 15, userInfo: nil) }
|
||||
if pr == 0 { continue }
|
||||
let r = recv(fd, &buffer, buffer.count, 0)
|
||||
if r < 0 { if errno == EINTR { continue }; throw NSError(domain: "SecurityHardeningTests", code: 16, userInfo: nil) }
|
||||
if r == 0 { break }
|
||||
responseData.append(contentsOf: buffer[0..<r])
|
||||
if let str = String(data: responseData, encoding: .utf8), str.contains("\n") { break }
|
||||
}
|
||||
guard let respString = String(data: responseData, encoding: .utf8) else { throw NSError(domain: "SecurityHardeningTests", code: 17, userInfo: nil) }
|
||||
let first = respString.split(separator: "\n", omittingEmptySubsequences: false).first.map { String($0) } ?? respString
|
||||
return first.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
// MARK: - 1) peer UID pure decision
|
||||
|
||||
func testPeerAuthorizationPureDecision() {
|
||||
let me = getuid()
|
||||
let foreign: uid_t = (me == 0) ? 1 : 0
|
||||
XCTAssertTrue(referenceIsPeerAuthorized(peerUID: me, currentUID: me), "own UID should be authorized")
|
||||
XCTAssertFalse(referenceIsPeerAuthorized(peerUID: foreign, currentUID: me), "foreign UID should be rejected")
|
||||
}
|
||||
|
||||
func testHostPeerAuthorizationFunctionExists() throws {
|
||||
// If implementation exposes isPeerAuthorized, test it indirectly by exercising server.
|
||||
// We assert current process connecting is allowed (same UID) – existing health test proves this.
|
||||
// For this TDD RED, we also attempt to check source contains getpeereid.
|
||||
let fm = FileManager.default
|
||||
let srcURL = URL(fileURLWithPath: fm.currentDirectoryPath).appendingPathComponent("Sources/ReynaCLIHostCore/SocketServer.swift")
|
||||
// Walk up
|
||||
var found: URL? = nil
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
for _ in 0..<8 {
|
||||
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/SocketServer.swift")
|
||||
if fm.fileExists(atPath: cand.path) { found = cand; break }
|
||||
let candOld = cur.appendingPathComponent("Sources/ReynaCLIHost/SocketServer.swift")
|
||||
if fm.fileExists(atPath: candOld.path) { found = candOld; break }
|
||||
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/SocketServer.swift")
|
||||
if fm.fileExists(atPath: cand2.path) { found = cand2; break }
|
||||
let cand2Old = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost/SocketServer.swift")
|
||||
if fm.fileExists(atPath: cand2Old.path) { found = cand2Old; break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
let url = found ?? srcURL
|
||||
guard let content = try? String(contentsOf: url) else {
|
||||
XCTFail("Could not read SocketServer.swift at \(url.path)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(content.contains("getpeereid") || content.contains("getpeerid"), "SocketServer.swift must call getpeereid for peer UID check")
|
||||
}
|
||||
|
||||
// MARK: - 2) signal-handler safety
|
||||
|
||||
func testSignalHandlerNoUnsafeGlobals() throws {
|
||||
let fm = FileManager.default
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var srcPath: URL? = nil
|
||||
for _ in 0..<8 {
|
||||
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/SocketServer.swift")
|
||||
if fm.fileExists(atPath: cand.path) { srcPath = cand; break }
|
||||
let candOld = cur.appendingPathComponent("Sources/ReynaCLIHost/SocketServer.swift")
|
||||
if fm.fileExists(atPath: candOld.path) { srcPath = candOld; break }
|
||||
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/SocketServer.swift")
|
||||
if fm.fileExists(atPath: cand2.path) { srcPath = cand2; break }
|
||||
let cand2Old = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost/SocketServer.swift")
|
||||
if fm.fileExists(atPath: cand2Old.path) { srcPath = cand2Old; break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard let url = srcPath, let content = try? String(contentsOf: url) else {
|
||||
XCTFail("Cannot find SocketServer.swift for signal safety check")
|
||||
return
|
||||
}
|
||||
// No Swift mutable global storing path
|
||||
XCTAssertFalse(content.contains("gSocketPathCStr"), "Should not have Swift mutable global gSocketPathCStr")
|
||||
XCTAssertFalse(content.contains("nonisolated(unsafe)"), "Should not have nonisolated(unsafe) global for signal handling")
|
||||
// No unsafeBitCast to sig_t
|
||||
XCTAssertFalse(content.contains("unsafeBitCast") && content.contains("sig_t"), "Should not use unsafeBitCast to sig_t")
|
||||
// Signal handler itself should not be Swift using stat/lstat
|
||||
// Check that reynaSocketSignalHandler Swift func with lstat/stat is gone
|
||||
// Allow C file to handle signals; here check that Swift file doesn't define reynaSocketSignalHandler with lstat
|
||||
// This part will pass when we move handler to C target.
|
||||
// Also check Package.swift contains C target
|
||||
var pkgURL: URL? = nil
|
||||
cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
for _ in 0..<8 {
|
||||
let cand = cur.appendingPathComponent("Package.swift")
|
||||
if fm.fileExists(atPath: cand.path) { pkgURL = cand; break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
if let purl = pkgURL, (try? String(contentsOf: purl)) != nil {
|
||||
// Should contain C target for signal support OR no signal unsafe patterns above already covers
|
||||
// Not failing if C target missing yet, but signal safety tests still need to show RED via earlier checks
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 3) path validation
|
||||
|
||||
func testSocketPathRejectsDotDotComponents() throws {
|
||||
// Host should refuse paths containing .. or . components
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
let dotDotPath = unique.appendingPathComponent("../evil.sock").path
|
||||
// This contains .. – should be rejected, process exits non-zero quickly
|
||||
let exe = try hostExecutableURL()
|
||||
let proc = Process()
|
||||
proc.executableURL = exe
|
||||
proc.arguments = ["--socket", dotDotPath]
|
||||
proc.standardError = Pipe()
|
||||
proc.standardOutput = Pipe()
|
||||
try proc.run()
|
||||
let deadline = Date().addingTimeInterval(2)
|
||||
while proc.isRunning && Date() < deadline { usleep(100_000) }
|
||||
if proc.isRunning {
|
||||
proc.terminate()
|
||||
XCTFail("Host should reject path containing .. and exit, but kept running for \(dotDotPath)")
|
||||
} else {
|
||||
XCTAssertNotEqual(proc.terminationStatus, 0, "Should exit non-zero for .. path")
|
||||
}
|
||||
// Also test ./ component
|
||||
let dotPath = unique.appendingPathComponent("./evil.sock").path
|
||||
let proc2 = Process()
|
||||
proc2.executableURL = exe
|
||||
proc2.arguments = ["--socket", dotPath]
|
||||
proc2.standardError = Pipe()
|
||||
proc2.standardOutput = Pipe()
|
||||
try proc2.run()
|
||||
let deadline2 = Date().addingTimeInterval(2)
|
||||
while proc2.isRunning && Date() < deadline2 { usleep(100_000) }
|
||||
if proc2.isRunning {
|
||||
proc2.terminate()
|
||||
XCTFail("Host should reject path containing . and exit")
|
||||
} else {
|
||||
XCTAssertNotEqual(proc2.terminationStatus, 0, "Should exit non-zero for . path")
|
||||
}
|
||||
}
|
||||
|
||||
func testSocketParentRejectsSymlink() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
let real = unique.appendingPathComponent("real")
|
||||
try fm.createDirectory(at: real, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700])
|
||||
let link = unique.appendingPathComponent("linkdir")
|
||||
try fm.createSymbolicLink(at: link, withDestinationURL: real)
|
||||
let sockPath = link.appendingPathComponent("reyna.sock").path
|
||||
let exe = try hostExecutableURL()
|
||||
let proc = Process()
|
||||
proc.executableURL = exe
|
||||
proc.arguments = ["--socket", sockPath]
|
||||
proc.standardError = Pipe()
|
||||
proc.standardOutput = Pipe()
|
||||
try proc.run()
|
||||
let deadline = Date().addingTimeInterval(2)
|
||||
while proc.isRunning && Date() < deadline { usleep(100_000) }
|
||||
if proc.isRunning {
|
||||
proc.terminate()
|
||||
XCTFail("Host should reject symlink parent and exit")
|
||||
} else {
|
||||
XCTAssertNotEqual(proc.terminationStatus, 0, "Should exit non-zero when parent is symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func testSocketParentRejectsWorldWritable() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
// Make parent world-writable 0777 but owned by us – should be rejected
|
||||
chmod(unique.path, 0o777)
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let exe = try hostExecutableURL()
|
||||
let proc = Process()
|
||||
proc.executableURL = exe
|
||||
proc.arguments = ["--socket", sockPath]
|
||||
proc.standardError = Pipe()
|
||||
proc.standardOutput = Pipe()
|
||||
try proc.run()
|
||||
let deadline = Date().addingTimeInterval(2)
|
||||
while proc.isRunning && Date() < deadline { usleep(100_000) }
|
||||
if proc.isRunning {
|
||||
proc.terminate()
|
||||
XCTFail("Host should reject world-writable dedicated parent")
|
||||
} else {
|
||||
XCTAssertNotEqual(proc.terminationStatus, 0, "Should exit non-zero for world-writable parent")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 4) recv timeout / slow client
|
||||
|
||||
func testSlowClientDoesNotBlockHealthClient() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate() }
|
||||
|
||||
// Open slow client that connects and sends partial without newline and keeps open
|
||||
let slowFd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
XCTAssertTrue(slowFd >= 0)
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
|
||||
_ = sockPath.withCString { cStr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { dst in
|
||||
dst.withMemoryRebound(to: CChar.self, capacity: 104) { p in strncpy(p, cStr, 103) }
|
||||
}
|
||||
}
|
||||
let len = socklen_t(MemoryLayout<sockaddr_un>.size)
|
||||
let cr = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in connect(slowFd, saddr, len) }
|
||||
}
|
||||
XCTAssertEqual(cr, 0, "slow client connect should succeed")
|
||||
// Send incomplete data (no newline)
|
||||
let partial = "{\"id\":\"slow\",\"operation\":\"service.health\""
|
||||
_ = partial.withCString { cStr in send(slowFd, cStr, strlen(cStr), 0) }
|
||||
|
||||
// Give server a moment to be blocked in recv if vulnerable
|
||||
usleep(300_000)
|
||||
|
||||
// Now try health client – should succeed within timeout + small margin, not blocked forever.
|
||||
// Server should have a recv timeout ~5s, so this health client should succeed in < (timeout+2)s
|
||||
let start = Date()
|
||||
let req = #"{"id":"fast","operation":"service.health","arguments":{}}"#
|
||||
var gotResponse = false
|
||||
var lastError: Error? = nil
|
||||
for _ in 0..<3 {
|
||||
do {
|
||||
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: req, timeout: 6)
|
||||
if let data = respLine.data(using: .utf8),
|
||||
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
obj["id"] as? String == "fast",
|
||||
obj["ok"] as? Bool == true {
|
||||
gotResponse = true
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
lastError = error
|
||||
usleep(200_000)
|
||||
}
|
||||
}
|
||||
let elapsed = Date().timeIntervalSince(start)
|
||||
close(slowFd)
|
||||
|
||||
XCTAssertTrue(gotResponse, "Fast client should succeed despite slow client; lastError: \(String(describing: lastError)) elapsed: \(elapsed)s")
|
||||
XCTAssertLessThan(elapsed, 8, "Slow client should not block health client beyond timeout; elapsed \(elapsed)s")
|
||||
}
|
||||
|
||||
// MARK: - 5) oversized-line boundary
|
||||
|
||||
func testOversizedLineWithNewlineInSameChunk() throws {
|
||||
// This tests the bug where buf+chunk > limit and newline in most recent chunk is ignored.
|
||||
// Build a valid JSON line exactly 500 bytes, then newline, then extra garbage in same TCP chunk.
|
||||
// The server must accept the first line (<=64KiB) even if same recv includes bytes after newline.
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate() }
|
||||
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
XCTAssertTrue(fd >= 0)
|
||||
defer { close(fd) }
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
|
||||
_ = sockPath.withCString { cStr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { dst in
|
||||
dst.withMemoryRebound(to: CChar.self, capacity: 104) { p in strncpy(p, cStr, 103) }
|
||||
}
|
||||
}
|
||||
let addrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
|
||||
let cr = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in connect(fd, saddr, addrLen) }
|
||||
}
|
||||
XCTAssertEqual(cr, 0)
|
||||
|
||||
// Build payload: first line is valid health request (<64KiB) + "\n" + extra bytes that would make total > limit if counted, but second line invalid.
|
||||
// Actually to trigger bug, we need first line <=64KiB, but the chunk that contains newline also contains extra bytes making total > limit? The bug checks buf.count+n > limit before looking at newline in new chunk.
|
||||
// So simulate by sending one large send that is exactly 64KiB + extra.
|
||||
// We'll send a health request (~50 bytes) + "\n" + 70KiB of 'X's in ONE send call. The server reads up to 4096 at a time, but could still get newline in first recv.
|
||||
// Better: send health request + "\n" + large extra, and ensure server still returns ok for first line, not payload_too_large.
|
||||
let healthReq = #"{"id":"line-ok","operation":"service.health","arguments":{}}"#
|
||||
let extra = String(repeating: "X", count: 70*1024)
|
||||
let combined = healthReq + "\n" + extra
|
||||
guard let data = combined.data(using: .utf8) else { XCTFail("encode fail"); return }
|
||||
// Ignore SIGPIPE in this process to avoid signal 13 when server closes early
|
||||
signal(SIGPIPE, SIG_IGN)
|
||||
var sent = 0
|
||||
while sent < data.count {
|
||||
let n = data.withUnsafeBytes { ptr in send(fd, ptr.baseAddress!.advanced(by: sent), data.count - sent, 0) }
|
||||
if n <= 0 {
|
||||
if errno == EPIPE || errno == ECONNRESET { break }
|
||||
break
|
||||
}
|
||||
sent += n
|
||||
}
|
||||
|
||||
var responseData = Data()
|
||||
var buf = [UInt8](repeating: 0, count: 4096)
|
||||
let start = Date()
|
||||
while true {
|
||||
if Date().timeIntervalSince(start) > 3 { break }
|
||||
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
||||
let pr = poll(&pfd, 1, 200)
|
||||
if pr <= 0 { continue }
|
||||
let r = recv(fd, &buf, buf.count, 0)
|
||||
if r <= 0 { break }
|
||||
responseData.append(contentsOf: buf[0..<r])
|
||||
if let s = String(data: responseData, encoding: .utf8), s.contains("\n") { break }
|
||||
}
|
||||
guard let respStr = String(data: responseData, encoding: .utf8) else {
|
||||
XCTFail("No utf8 response")
|
||||
return
|
||||
}
|
||||
let firstLine = respStr.split(separator: "\n").first.map { String($0) } ?? respStr
|
||||
guard let d = firstLine.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: d) as? [String: Any] else {
|
||||
XCTFail("Response not JSON: \(firstLine)")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(obj["id"] as? String, "line-ok", "Should preserve id of first line")
|
||||
XCTAssertEqual(obj["ok"] as? Bool, true, "First line <=64KiB should be accepted even when same chunk has extra bytes after newline, got: \(obj)")
|
||||
}
|
||||
|
||||
func testOversizedFirstLineStillRejected() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate() }
|
||||
|
||||
let largeString = String(repeating: "A", count: 70*1024)
|
||||
let req = #"{"id":"big","operation":"service.health","arguments":{},"data":"\#(largeString)"}"#
|
||||
XCTAssertTrue(req.utf8.count > 65536)
|
||||
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: req)
|
||||
guard let data = respLine.data(using: .utf8),
|
||||
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
XCTFail("Not JSON: \(respLine)"); return
|
||||
}
|
||||
XCTAssertEqual(obj["ok"] as? Bool, false)
|
||||
let code = (obj["error"] as? [String: Any])?["code"] as? String ?? ""
|
||||
XCTAssertTrue(code.contains("too_large") || code.contains("payload") || code.contains("invalid"), "Expected too_large code, got \(code)")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import XCTest
|
||||
import Foundation
|
||||
|
||||
/// TDD tests for Unix-domain-socket server mode (--socket <path>).
|
||||
/// These start the compiled executable in a temp directory using real AF_UNIX sockets.
|
||||
final class SocketServerTests: XCTestCase {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
func hostExecutableURL() throws -> URL {
|
||||
let fm = FileManager.default
|
||||
// Portable candidates only: package-relative .build locations for common triples.
|
||||
let candidates: [String] = [
|
||||
".build/debug/ReynaCLIHost",
|
||||
".build/arm64-apple-macosx/debug/ReynaCLIHost",
|
||||
".build/x86_64-apple-macosx/debug/ReynaCLIHost",
|
||||
]
|
||||
let cwd = fm.currentDirectoryPath
|
||||
var tried: [String] = []
|
||||
for c in candidates {
|
||||
let url = URL(fileURLWithPath: cwd).appendingPathComponent(c)
|
||||
tried.append(url.path)
|
||||
if fm.isExecutableFile(atPath: url.path) { return url }
|
||||
}
|
||||
var parent = URL(fileURLWithPath: cwd)
|
||||
for _ in 0..<6 {
|
||||
let p1 = parent.appendingPathComponent(".build/debug/ReynaCLIHost")
|
||||
tried.append(p1.path)
|
||||
if fm.isExecutableFile(atPath: p1.path) { return p1 }
|
||||
let p2 = parent.appendingPathComponent(".build/arm64-apple-macosx/debug/ReynaCLIHost")
|
||||
tried.append(p2.path)
|
||||
if fm.isExecutableFile(atPath: p2.path) { return p2 }
|
||||
parent = parent.deletingLastPathComponent()
|
||||
}
|
||||
throw NSError(domain: "SocketServerTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "ReynaCLIHost binary not found. Tried:\n" + tried.joined(separator: "\n")])
|
||||
}
|
||||
|
||||
/// Short unique /tmp path to stay under sockaddr_un.sun_path 104-byte limit.
|
||||
/// e.g. /tmp/rh-a1b2c3d4
|
||||
func makeShortUniqueDirChecked() throws -> URL {
|
||||
let fm = FileManager.default
|
||||
for _ in 0..<20 {
|
||||
let hex = String(format: "%08x", UInt32.random(in: 0...UInt32.max))
|
||||
let url = URL(fileURLWithPath: "/tmp/rh-\(hex)")
|
||||
if !fm.fileExists(atPath: url.path) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return URL(fileURLWithPath: "/tmp/rh-\(String(format: "%08x", UInt32.random(in: 0...UInt32.max)))")
|
||||
}
|
||||
|
||||
final class HostProcess {
|
||||
let process: Process
|
||||
let socketPath: String
|
||||
let tempDir: URL
|
||||
init(process: Process, socketPath: String, tempDir: URL) {
|
||||
self.process = process
|
||||
self.socketPath = socketPath
|
||||
self.tempDir = tempDir
|
||||
}
|
||||
func terminate() {
|
||||
if process.isRunning { process.terminate() }
|
||||
// Give time to cleanup
|
||||
let deadline = Date().addingTimeInterval(2)
|
||||
while process.isRunning && Date() < deadline { usleep(100_000) }
|
||||
if process.isRunning { process.interrupt() }
|
||||
}
|
||||
deinit { terminate() }
|
||||
}
|
||||
|
||||
func startSocketHost(socketPath: String, tempDir: URL) throws -> HostProcess {
|
||||
let exe = try hostExecutableURL()
|
||||
let process = Process()
|
||||
process.executableURL = exe
|
||||
process.arguments = ["--socket", socketPath]
|
||||
let stderr = Pipe()
|
||||
process.standardError = stderr
|
||||
process.standardOutput = Pipe()
|
||||
process.standardInput = Pipe() // keep open, not used
|
||||
try process.run()
|
||||
// Wait for socket to appear (max 5s)
|
||||
let fm = FileManager.default
|
||||
let deadline = Date().addingTimeInterval(5)
|
||||
while Date() < deadline {
|
||||
if fm.fileExists(atPath: socketPath) { break }
|
||||
if !process.isRunning {
|
||||
let data = stderr.fileHandleForReading.readDataToEndOfFile()
|
||||
let s = String(data: data, encoding: .utf8) ?? ""
|
||||
throw NSError(domain: "SocketServerTests", code: 2, userInfo: [NSLocalizedDescriptionKey: "Host exited early. stderr: \(s)"])
|
||||
}
|
||||
// Do not call FileHandle.availableData here: it blocks while a healthy,
|
||||
// silent child keeps stderr open. Poll the socket path instead.
|
||||
usleep(100_000)
|
||||
}
|
||||
if !fm.fileExists(atPath: socketPath) {
|
||||
process.terminate()
|
||||
let data = stderr.fileHandleForReading.readDataToEndOfFile()
|
||||
let s = String(data: data, encoding: .utf8) ?? ""
|
||||
throw NSError(domain: "SocketServerTests", code: 3, userInfo: [NSLocalizedDescriptionKey: "Socket not created at \(socketPath) after timeout. stderr: \(s)"])
|
||||
}
|
||||
return HostProcess(process: process, socketPath: socketPath, tempDir: tempDir)
|
||||
}
|
||||
|
||||
// Low-level socket client: connect, send line, read one line response with timeout
|
||||
func socketRequestResponse(socketPath: String, requestLine: String, timeout: TimeInterval = 3) throws -> String {
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { throw NSError(domain: "SocketServerTests", code: 10, userInfo: [NSLocalizedDescriptionKey: "socket() failed: \(String(cString: strerror(errno)))"]) }
|
||||
defer { close(fd) }
|
||||
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
let pathBytes = socketPath.utf8
|
||||
guard pathBytes.count < MemoryLayout.size(ofValue: addr.sun_path) else {
|
||||
throw NSError(domain: "SocketServerTests", code: 11, userInfo: [NSLocalizedDescriptionKey: "Socket path too long"])
|
||||
}
|
||||
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
|
||||
_ = socketPath.withCString { cStr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { dstPtr in
|
||||
dstPtr.withMemoryRebound(to: CChar.self, capacity: 104) { charPtr in
|
||||
strncpy(charPtr, cStr, 103)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let addrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
|
||||
let connectResult = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in
|
||||
connect(fd, saddr, addrLen)
|
||||
}
|
||||
}
|
||||
guard connectResult == 0 else {
|
||||
throw NSError(domain: "SocketServerTests", code: 12, userInfo: [NSLocalizedDescriptionKey: "connect() failed to \(socketPath): \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
|
||||
let toSend = requestLine.hasSuffix("\n") ? requestLine : requestLine + "\n"
|
||||
guard let data = toSend.data(using: .utf8) else { throw NSError(domain: "SocketServerTests", code: 13, userInfo: [NSLocalizedDescriptionKey: "UTF8 encode fail"]) }
|
||||
var sent = 0
|
||||
while sent < data.count {
|
||||
let n = data.withUnsafeBytes { rawBuf in
|
||||
send(fd, rawBuf.baseAddress!.advanced(by: sent), data.count - sent, 0)
|
||||
}
|
||||
if n <= 0 { throw NSError(domain: "SocketServerTests", code: 14, userInfo: [NSLocalizedDescriptionKey: "send() failed: \(String(cString: strerror(errno)))"]) }
|
||||
sent += n
|
||||
}
|
||||
|
||||
var responseData = Data()
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
let start = Date()
|
||||
while true {
|
||||
if Date().timeIntervalSince(start) > timeout {
|
||||
let partial = String(data: responseData, encoding: .utf8) ?? "<binary>"
|
||||
throw NSError(domain: "SocketServerTests", code: 15, userInfo: [NSLocalizedDescriptionKey: "socket read timeout, partial: \(partial)"])
|
||||
}
|
||||
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
||||
let pr = poll(&pfd, 1, 200) // 200ms
|
||||
if pr < 0 {
|
||||
if errno == EINTR { continue }
|
||||
throw NSError(domain: "SocketServerTests", code: 16, userInfo: [NSLocalizedDescriptionKey: "poll failed: \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
if pr == 0 { continue }
|
||||
let r = recv(fd, &buffer, buffer.count, 0)
|
||||
if r < 0 {
|
||||
if errno == EINTR { continue }
|
||||
throw NSError(domain: "SocketServerTests", code: 17, userInfo: [NSLocalizedDescriptionKey: "recv failed: \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
if r == 0 { break }
|
||||
responseData.append(contentsOf: buffer[0..<r])
|
||||
if let str = String(data: responseData, encoding: .utf8), str.contains("\n") {
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let respString = String(data: responseData, encoding: .utf8) else {
|
||||
throw NSError(domain: "SocketServerTests", code: 18, userInfo: [NSLocalizedDescriptionKey: "response not utf8"])
|
||||
}
|
||||
let firstLine = respString.split(separator: "\n", omittingEmptySubsequences: false).first.map { String($0) } ?? respString
|
||||
return firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func decode(_ line: String) throws -> [String: Any] {
|
||||
guard let data = line.data(using: .utf8),
|
||||
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw NSError(domain: "SocketServerTests", code: 20, userInfo: [NSLocalizedDescriptionKey: "Not JSON: \(line)"])
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// MARK: - Tests (TDD RED first)
|
||||
|
||||
func testSocketHealthRequest() throws {
|
||||
// Prove that --socket mode health request works via real Unix socket.
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate(); try? fm.removeItem(atPath: sockPath) }
|
||||
|
||||
let req = #"{"id":"sock-1","operation":"service.health","arguments":{}}"#
|
||||
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: req)
|
||||
let resp = try decode(respLine)
|
||||
XCTAssertEqual(resp["id"] as? String, "sock-1")
|
||||
XCTAssertEqual(resp["ok"] as? Bool, true)
|
||||
if let result = resp["result"] as? [String: Any] {
|
||||
XCTAssertEqual(result["operation"] as? String, "service.health")
|
||||
XCTAssertFalse((result["protocol_version"] as? String ?? "").isEmpty)
|
||||
} else {
|
||||
XCTFail("Missing result: \(resp)")
|
||||
}
|
||||
}
|
||||
|
||||
func testSocketPermissionsAndParentCreation() throws {
|
||||
// Prove socket file mode 0600 and parent dir 0700, and auto-create parent.
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
// Do NOT create unique; let child subdir also not exist, testing parent creation
|
||||
let nestedParent = unique.appendingPathComponent("a/b/c")
|
||||
let sockPath = nestedParent.appendingPathComponent("reyna.sock").path
|
||||
// Ensure base exists for cleanup tracking but not nested
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate() }
|
||||
|
||||
var isDir: ObjCBool = false
|
||||
XCTAssertTrue(fm.fileExists(atPath: nestedParent.path, isDirectory: &isDir))
|
||||
XCTAssertTrue(isDir.boolValue)
|
||||
|
||||
let attrs = try fm.attributesOfItem(atPath: nestedParent.path)
|
||||
if let posix = attrs[.posixPermissions] as? NSNumber {
|
||||
let perms = posix.uint16Value & 0o777
|
||||
XCTAssertEqual(perms, 0o700, "Parent dir should be 0700, got \(String(perms, radix: 8))")
|
||||
} else {
|
||||
XCTFail("Could not get posixPermissions for parent")
|
||||
}
|
||||
|
||||
// Check socket file mode 0600 and type socket
|
||||
let sockAttrs = try fm.attributesOfItem(atPath: sockPath)
|
||||
if let posix = sockAttrs[.posixPermissions] as? NSNumber {
|
||||
let perms = posix.uint16Value & 0o777
|
||||
XCTAssertEqual(perms, 0o600, "Socket file should be 0600, got \(String(perms, radix: 8))")
|
||||
} else {
|
||||
XCTFail("Could not get posixPermissions for socket")
|
||||
}
|
||||
// Verify it's a socket using lstat mode check
|
||||
var st = stat()
|
||||
XCTAssertEqual(lstat(sockPath, &st), 0, "lstat should succeed")
|
||||
XCTAssertTrue((st.st_mode & S_IFMT) == S_IFSOCK, "File should be a socket")
|
||||
|
||||
// Also ensure owned by current uid
|
||||
XCTAssertEqual(st.st_uid, getuid(), "Socket should be owned by current uid")
|
||||
}
|
||||
|
||||
func testSocketMalformedRequest() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate() }
|
||||
|
||||
// Send malformed JSON
|
||||
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: "not json at all")
|
||||
let resp = try decode(respLine)
|
||||
XCTAssertEqual(resp["ok"] as? Bool, false)
|
||||
if let err = resp["error"] as? [String: Any] {
|
||||
XCTAssertEqual(err["code"] as? String, "invalid_request")
|
||||
} else {
|
||||
XCTFail("Missing error, got \(resp)")
|
||||
}
|
||||
// id should be empty when unrecoverable
|
||||
XCTAssertEqual(resp["id"] as? String, "")
|
||||
|
||||
// Send malformed but with id field to test preservation
|
||||
let respLine2 = try socketRequestResponse(socketPath: sockPath, requestLine: #"{"id":"keep-me","operation":}"#)
|
||||
let resp2 = try decode(respLine2)
|
||||
XCTAssertEqual(resp2["ok"] as? Bool, false)
|
||||
XCTAssertEqual(resp2["id"] as? String, "keep-me")
|
||||
if let err = resp2["error"] as? [String: Any] {
|
||||
XCTAssertEqual(err["code"] as? String, "invalid_request")
|
||||
} else {
|
||||
XCTFail("Missing error for second malformed")
|
||||
}
|
||||
}
|
||||
|
||||
func testSocketOversizedRequestBeyond64KiB() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate() }
|
||||
|
||||
// Create payload > 64KiB
|
||||
let largeString = String(repeating: "A", count: 70*1024)
|
||||
let req = #"{"id":"big","operation":"service.health","arguments":{},"data":"\#(largeString)"}"#
|
||||
// Must be > 65536 bytes
|
||||
XCTAssertTrue(req.utf8.count > 65536)
|
||||
|
||||
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: req)
|
||||
let resp = try decode(respLine)
|
||||
XCTAssertEqual(resp["ok"] as? Bool, false, "Oversized should be rejected")
|
||||
if let err = resp["error"] as? [String: Any] {
|
||||
let code = err["code"] as? String ?? ""
|
||||
XCTAssertTrue(code == "invalid_request" || code == "payload_too_large" || code == "request_too_large" || code.contains("too_large") || code.contains("invalid"), "Unexpected error code for oversized: \(code)")
|
||||
} else {
|
||||
XCTFail("Missing error for oversized: \(resp)")
|
||||
}
|
||||
}
|
||||
|
||||
func testSocketCleanupOnTermination() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
var maybeHost: HostProcess? = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
XCTAssertTrue(fm.fileExists(atPath: sockPath), "Socket should exist while host running")
|
||||
maybeHost?.terminate()
|
||||
maybeHost = nil
|
||||
// Wait a bit for cleanup
|
||||
let deadline = Date().addingTimeInterval(3)
|
||||
while fm.fileExists(atPath: sockPath) && Date() < deadline { usleep(100_000) }
|
||||
XCTAssertFalse(fm.fileExists(atPath: sockPath), "Socket file should be removed on SIGTERM cleanup")
|
||||
}
|
||||
|
||||
func testSocketRefusesNonSocketExistingFile() throws {
|
||||
// If path exists and is regular file owned by uid, should refuse (not unlink unsafe)
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
// Create regular file there
|
||||
fm.createFile(atPath: sockPath, contents: Data("hello".utf8))
|
||||
defer { try? fm.removeItem(atPath: sockPath) }
|
||||
|
||||
// Try start - should fail quickly (exit)
|
||||
let exe = try hostExecutableURL()
|
||||
let process = Process()
|
||||
process.executableURL = exe
|
||||
process.arguments = ["--socket", sockPath]
|
||||
let stderr = Pipe()
|
||||
process.standardError = stderr
|
||||
process.standardOutput = Pipe()
|
||||
try process.run()
|
||||
let deadline = Date().addingTimeInterval(3)
|
||||
while process.isRunning && Date() < deadline { usleep(100_000) }
|
||||
// Process should have exited with error, not be running and not have created socket replacing file
|
||||
var isSocket = false
|
||||
var st = stat()
|
||||
if lstat(sockPath, &st) == 0 {
|
||||
isSocket = (st.st_mode & S_IFMT) == S_IFSOCK
|
||||
}
|
||||
XCTAssertFalse(isSocket, "Should not have replaced regular file with socket")
|
||||
// If process still running, terminate and fail
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
XCTFail("Host should refuse to overwrite regular file and exit, but it is still running")
|
||||
} else {
|
||||
// Should exit non-zero
|
||||
XCTAssertNotEqual(process.terminationStatus, 0, "Should exit non-zero when refusing non-socket file")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user