From 8af92993be06fd61cf3ec875ef929f7bb5b50c19 Mon Sep 17 00:00:00 2001 From: Polar <343746874+PolarSeeker77@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:14:31 +0000 Subject: [PATCH 01/23] Create X11 windows with an EGL-compatible visual (NVIDIA black screen) On NVIDIA (RTX 2070 SUPER, driver 615.78, Xwayland) the game showed a black screen and logged "Metal Device timed out giving us a Drawable to present" every second (#3). X11Window picked its visual with glXChooseVisual, which on NVIDIA returns a GLX-only visual (0x304 here). NVIDIA's EGL configs only map to the default visual (0x69) and the 32-bit one (0x99), so CGLGetWindow's eglCreateWindowSurface failed with EGL_BAD_CONFIG for the window and its subwindows, for every one of the 40 window configs. CGLGetWindow returns NULL without logging, CALayerContext keeps _cglWindow == NULL, -render returns before prepareMetalLayers, and CAMetalLayer never gets its three drawables back. Mesa accepts the GLX visual, which is why AMD works. Use the default visual when it is TrueColor with depth >= 24, falling back to glXChooseVisual otherwise. This also fixes every other Darling window on NVIDIA: plain AppKit windows (title bars included), layer-backed views and NSOpenGLView were all black without it. --- patches/cocotron.patch | 64 +++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 899b568..baaed82 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -4528,7 +4528,7 @@ index bfe3839a..e8c8828f 100644 - (void) frameChanged; - (void) setLastKnownCursorPosition: (CGPoint) point; diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m -index f4925b66..f9dd38cc 100644 +index f4925b66..0296203b 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -23,6 +23,7 @@ @@ -4583,7 +4583,27 @@ index f4925b66..f9dd38cc 100644 BOOL isPanel = [delegate isKindOfClass: [NSPanel class]]; if (isPanel && _styleMask & NSDocModalWindowMask) _styleMask = NSBorderlessWindowMask; -@@ -193,8 +222,16 @@ static NSData *makeWindowIcon() { +@@ -177,7 +206,18 @@ static NSData *makeWindowIcon() { + + int screen = DefaultScreen(_display); + +- if ((_visualInfo = glXChooseVisual(_display, screen, att)) == NULL) { ++ // Rendering goes through EGL. NVIDIA's glXChooseVisual can return a GLX-only visual that every ++ // eglCreateWindowSurface rejects (EGL_BAD_CONFIG), so prefer the default visual when usable. ++ XVisualInfo visualTemplate; ++ int visualCount = 0; ++ visualTemplate.visualid = XVisualIDFromVisual(DefaultVisual(_display, screen)); ++ visualTemplate.screen = screen; ++ _visualInfo = XGetVisualInfo(_display, VisualIDMask | VisualScreenMask, &visualTemplate, &visualCount); ++ if (_visualInfo != NULL && (_visualInfo->class != TrueColor || _visualInfo->depth < 24)) { ++ XFree(_visualInfo); ++ _visualInfo = NULL; ++ } ++ if (_visualInfo == NULL && (_visualInfo = glXChooseVisual(_display, screen, att)) == NULL) { + NSLog(@"glXChooseVisual failed at %s %d", __FILE__, __LINE__); + } + +@@ -193,8 +233,16 @@ static NSData *makeWindowIcon() { XSetWindowAttributes xattr; unsigned long xattr_mask; @@ -4602,7 +4622,7 @@ index f4925b66..f9dd38cc 100644 xattr_mask = CWOverrideRedirect | CWColormap; xattr.colormap = cmap; -@@ -206,6 +243,12 @@ static NSData *makeWindowIcon() { +@@ -206,6 +254,12 @@ static NSData *makeWindowIcon() { (_visualInfo == NULL) ? CopyFromParent : _visualInfo->visual, xattr_mask, &xattr); @@ -4615,7 +4635,7 @@ index f4925b66..f9dd38cc 100644 [self syncDelegateProperties]; Atom atm = XInternAtom(_display, "WM_DELETE_WINDOW", False); -@@ -217,6 +260,15 @@ static NSData *makeWindowIcon() { +@@ -217,6 +271,15 @@ static NSData *makeWindowIcon() { .res_class = (char *) name}; XSetClassHint(_display, _window, &classHint); @@ -4631,7 +4651,7 @@ index f4925b66..f9dd38cc 100644 XSetWindowBackgroundPixmap(_display, _window, None); _isModal = [delegate isSheet] || [NSApp modalWindow] == delegate; -@@ -266,6 +318,13 @@ static NSData *makeWindowIcon() { +@@ -266,6 +329,13 @@ static NSData *makeWindowIcon() { [self setWindowIcon]; @@ -4645,7 +4665,7 @@ index f4925b66..f9dd38cc 100644 return self; } -@@ -279,24 +338,30 @@ static NSData *makeWindowIcon() { +@@ -279,24 +349,30 @@ static NSData *makeWindowIcon() { return _styleMask; } @@ -4692,7 +4712,7 @@ index f4925b66..f9dd38cc 100644 if (!_mapped) { long states[2]; int states_cnt = 0; -@@ -304,7 +369,7 @@ static NSData *makeWindowIcon() { +@@ -304,7 +380,7 @@ static NSData *makeWindowIcon() { states[states_cnt++] = (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); } @@ -4701,7 +4721,7 @@ index f4925b66..f9dd38cc 100644 states[states_cnt++] = (long) XInternAtom( _display, "_NET_WM_STATE_FULLSCREEN", False); } -@@ -312,14 +377,13 @@ static NSData *makeWindowIcon() { +@@ -312,14 +388,13 @@ static NSData *makeWindowIcon() { XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, 32, PropModeReplace, (const unsigned char *) states, states_cnt); @@ -4718,7 +4738,7 @@ index f4925b66..f9dd38cc 100644 event.data.l[1] = (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); event.data.l[3] = 1; -@@ -327,6 +391,35 @@ static NSData *makeWindowIcon() { +@@ -327,6 +402,35 @@ static NSData *makeWindowIcon() { SubstructureNotifyMask | SubstructureRedirectMask, (XEvent *) &event); } @@ -4754,7 +4774,7 @@ index f4925b66..f9dd38cc 100644 } - (void) setStyleMask: (NSUInteger) mask { -@@ -338,7 +431,6 @@ static NSData *makeWindowIcon() { +@@ -338,7 +442,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -4762,7 +4782,7 @@ index f4925b66..f9dd38cc 100644 struct { unsigned long flags; unsigned long functions; -@@ -358,6 +450,7 @@ static NSData *makeWindowIcon() { +@@ -358,6 +461,7 @@ static NSData *makeWindowIcon() { if (!_mapped) { XMapWindow(_display, _window); _mapped = YES; @@ -4770,7 +4790,7 @@ index f4925b66..f9dd38cc 100644 } } -@@ -377,15 +470,27 @@ static NSData *makeWindowIcon() { +@@ -377,15 +481,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -4803,7 +4823,7 @@ index f4925b66..f9dd38cc 100644 // TODO: background color } -@@ -393,6 +498,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +509,28 @@ static NSData *makeWindowIcon() { // This is essentially dealloc; we release our contexts // and windows, but unlike dealloc, this method can be called // several times, so set everything to nil/NULL/0. @@ -4832,7 +4852,7 @@ index f4925b66..f9dd38cc 100644 [_context release]; _context = nil; -@@ -421,6 +548,392 @@ static NSData *makeWindowIcon() { +@@ -421,6 +559,392 @@ static NSData *makeWindowIcon() { } } @@ -5225,7 +5245,7 @@ index f4925b66..f9dd38cc 100644 - (Window) windowHandle { return _window; } -@@ -470,14 +983,32 @@ static NSData *makeWindowIcon() { +@@ -470,14 +994,32 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -5259,7 +5279,7 @@ index f4925b66..f9dd38cc 100644 } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +1019,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1030,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -5285,7 +5305,7 @@ index f4925b66..f9dd38cc 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -543,6 +1093,21 @@ static NSData *makeWindowIcon() { +@@ -543,6 +1104,21 @@ static NSData *makeWindowIcon() { - (void) makeKey { [self ensureMapped]; XRaiseWindow(_display, _window); @@ -5307,7 +5327,7 @@ index f4925b66..f9dd38cc 100644 } - (void) makeMain { -@@ -559,7 +1124,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1135,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -5337,7 +5357,7 @@ index f4925b66..f9dd38cc 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -615,6 +1202,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1213,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -5350,7 +5370,7 @@ index f4925b66..f9dd38cc 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1245,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1256,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -5359,7 +5379,7 @@ index f4925b66..f9dd38cc 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1259,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1270,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -5367,7 +5387,7 @@ index f4925b66..f9dd38cc 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,14 +1304,6 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,14 +1315,6 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } From e581a8196bef35f703f81280a143720d25594a6e Mon Sep 17 00:00:00 2001 From: Polar <343746874+PolarSeeker77@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:14:31 +0000 Subject: [PATCH 02/23] Point the layer compositor's GL context at the window's back buffer Second half of the NVIDIA black screen (#3). With the visual fix the compositor gets a window surface and drawables recycle, but the window stays black: after CARenderer draws, the back buffer reads back black and even a glClear to blue changes nothing. GL_DRAW_BUFFER and GL_READ_BUFFER are GL_NONE. CALayerContext -setSubwindow: creates the context and then notifies the layer tree; CAMetalLayer -_setContext: makes the context current (to create its textures) before any window surface is attached. Per EGL_KHR_surfaceless_context, a context first made current without a default framebuffer starts with DRAW_BUFFER = NONE, and attaching a window later doesn't change it. NVIDIA follows that; Mesa resets it on bind, which is why AMD works. After CGLContextMakeCurrentAndAttachToWindow in -renderLayer:, set the draw and read buffers to GL_BACK. --- patches/cocotron.patch | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index baaed82..97aeb5d 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -8723,7 +8723,7 @@ index 5dcac115..5de4f03f 100644 + @end diff --git a/QuartzCore/CALayerContext.m b/QuartzCore/CALayerContext.m -index 5dce8c56..dee2b80c 100644 +index 5dce8c56..d76cdae4 100644 --- a/QuartzCore/CALayerContext.m +++ b/QuartzCore/CALayerContext.m @@ -1,47 +1,58 @@ @@ -8879,7 +8879,7 @@ index 5dce8c56..dee2b80c 100644 } - (void) assignTextureIdsToLayerTree: (CALayer *) layer { -@@ -93,7 +148,22 @@ +@@ -93,8 +148,27 @@ [self assignTextureIdsToLayerTree: child]; } @@ -8900,9 +8900,14 @@ index 5dce8c56..dee2b80c 100644 - (void) renderLayer: (CALayer *) layer { + CFTimeInterval renderLayerStart = CACurrentMediaTime(); CGLContextMakeCurrentAndAttachToWindow(_glContext, _cglWindow); ++ // CAMetalLayer first makes this context current without a surface, which leaves GL_DRAW_BUFFER ++ // at GL_NONE on NVIDIA even once the window is attached (Mesa resets it). ++ glDrawBuffer(GL_BACK); ++ glReadBuffer(GL_BACK); glEnable(GL_DEPTH_TEST); -@@ -118,27 +188,63 @@ + glShadeModel(GL_SMOOTH); +@@ -118,27 +192,63 @@ [_deleteTextureIds removeAllObjects]; @@ -8971,7 +8976,7 @@ index 5dce8c56..dee2b80c 100644 } - (void) startTimerIfNeeded { -@@ -147,7 +253,7 @@ +@@ -147,7 +257,7 @@ target: self selector: @selector(timer:) userInfo: nil @@ -8980,7 +8985,7 @@ index 5dce8c56..dee2b80c 100644 } - (void) deleteTextureId: (NSNumber *) textureId { -@@ -155,7 +261,10 @@ +@@ -155,7 +265,10 @@ } - (void) flush { From 58a1fdb6a06d9627fa6fae6dd91887ce69145891 Mon Sep 17 00:00:00 2001 From: Polar <343746874+PolarSeeker77@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:19:28 +0000 Subject: [PATCH 03/23] mldr: never replace another owner's descriptor with an RPC socket On NVIDIA the game aborts within minutes: a thread's darlingserver RPC socket is closed behind Darling's back, and its next RPC fails with EBADF (or ENOTSOCK once the number is reused), then __simple_abort in mach_msg_overwrite_trap_impl (#23). __mldr_create_rpc_socket moved each new socket onto its slot with dup2(), which silently closes whatever already holds that number. The slots count down from the soft RLIMIT_NOFILE read at the process's first socket. The game starts with a soft limit of 1024 (systemd's default, inherited from the terminal) and then holds ~5500 fds, one per NVIDIA driver allocation, so the kernel hands numbers in the slot range (900-1023) to the driver whenever a slot is free, for instance after a thread exits. The next new thread's dup2() replaced the driver's fd with its socket; when the driver later closed "its" fd, it closed the socket. Mesa never comes near 1000 fds, which is why AMD works. Use fcntl(F_DUPFD, slot) instead: it never replaces a descriptor. If the slot is taken, give it back and keep the socket at the lowest free number above it, which is above all of the bitmap's slots and owned by nobody else; socket_bitmap_put ignores sockets above the slots. The process lifetime pipe helper gets the same treatment. This is the remaining case of the 2026-10-10 LeagueClient crash: that fix stopped two Darling threads from sharing a slot, this one stops Darling from taking a slot that belongs to someone else. --- patches/darling.patch | 80 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/patches/darling.patch b/patches/darling.patch index 7e50093..87c850a 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -7796,7 +7796,7 @@ index fee1bde87..bee86604b 100644 #endif - diff --git a/src/startup/mldr/mldr.c b/src/startup/mldr/mldr.c -index 6617ef82b..1d12043c2 100644 +index 6617ef82b..a227abee8 100644 --- a/src/startup/mldr/mldr.c +++ b/src/startup/mldr/mldr.c @@ -154,7 +154,7 @@ int main(int argc, char** argv, char** envp) @@ -7825,7 +7825,19 @@ index 6617ef82b..1d12043c2 100644 static int socket_bitmap_get(socket_bitmap_t* bitmap) { int fd = -1; bool updated = false; -@@ -673,7 +683,13 @@ static void socket_bitmap_put(socket_bitmap_t* bitmap, int socket) { +@@ -648,6 +658,11 @@ static void socket_bitmap_put(socket_bitmap_t* bitmap, int socket) { + + pthread_mutex_lock(&bitmap->mutex); + ++ // darling-lol: placed above the slots because its slot was taken (socket_bitmap_place). ++ if (socket > bitmap->highest) { ++ goto out; ++ } ++ + index = bitmap->highest - socket; + + bitmap->bits[index / 8] &= ~(1 << (index % 8)); +@@ -673,7 +688,13 @@ static void socket_bitmap_put(socket_bitmap_t* bitmap, int socket) { } } @@ -7840,7 +7852,7 @@ index 6617ef82b..1d12043c2 100644 // this bit is in-use, so we can't shrink any further break; } -@@ -685,12 +701,20 @@ static void socket_bitmap_put(socket_bitmap_t* bitmap, int socket) { +@@ -685,12 +706,20 @@ static void socket_bitmap_put(socket_bitmap_t* bitmap, int socket) { if (old_byte_size != new_byte_size) { // we can free one or more bytes from the bitmap @@ -7866,6 +7878,68 @@ index 6617ef82b..1d12043c2 100644 } } +@@ -698,6 +727,22 @@ out: + pthread_mutex_unlock(&bitmap->mutex); + }; + ++/* darling-lol: move `fd` to the next slot. dup2() silently closed whatever the guest or a native library (NVIDIA's ++ * driver: one fd per allocation) already held on that number, and its owner later closed our socket. F_DUPFD never ++ * replaces a descriptor: if the slot is taken, it returns the lowest free number above it, which is ours alone. */ ++static int socket_bitmap_place(int fd) { ++ int slot = socket_bitmap_get(&socket_bitmap); ++ if (slot < 0) { ++ return -1; ++ } ++ ++ int placed = fcntl(fd, F_DUPFD, slot); ++ if (placed != slot) { ++ socket_bitmap_put(&socket_bitmap, slot); ++ } ++ return placed; ++} ++ + int __mldr_create_rpc_socket(void) { + int pre_fd = -1; + int fd = -1; +@@ -707,18 +752,11 @@ int __mldr_create_rpc_socket(void) { + goto err_out; + } + +- fd = socket_bitmap_get(&socket_bitmap); ++ fd = socket_bitmap_place(pre_fd); + if (fd < 0) { + goto err_out; + } + +- if (dup2(pre_fd, fd) < 0) { +- // we have to put it away ourselves here because `fd` is not yet valid, so we can't close() it in the error handler +- socket_bitmap_put(&socket_bitmap, fd); +- fd = -1; +- goto err_out; +- } +- + close(pre_fd); + pre_fd = -1; + +@@ -772,17 +810,11 @@ int __mldr_create_process_lifetime_pipe(int* fds) { + } + + for (int i = 0; i < 2; ++i) { +- fds[i] = socket_bitmap_get(&socket_bitmap); ++ fds[i] = socket_bitmap_place(pre_fds[i]); + if (fds[i] < 0) { + goto err_out; + } + +- if (dup2(pre_fds[i], fds[i]) < 0) { +- socket_bitmap_put(&socket_bitmap, fds[i]); +- fds[i] = -1; +- goto err_out; +- } +- + close(pre_fds[i]); + pre_fds[i] = -1; + } diff --git a/src/tools/open.m b/src/tools/open.m index 126bd394c..f62c2e0e8 100644 --- a/src/tools/open.m From 4b5144a31d65f4fc35df76a3201be0f3f1f7658b Mon Sep 17 00:00:00 2001 From: ztancrell Date: Sat, 17 Oct 2026 07:59:09 -0400 Subject: [PATCH 04/23] Fix Riot Client crashes on sign-in buttons and file dialogs The Riot Client (Electron) aborted with unrecognized selectors in Cocotron's AppKit: - -[NSOpenPanel setShowsTagField:]: Electron sets it on every file dialog. Add the missing NSSavePanel setters it uses (showsTagField, nameFieldLabel, extensionHidden, canSelectHiddenExtension) as no-ops, and implement beginWithCompletionHandler: and beginSheetModalForWindow:completionHandler: by running the panel modally and calling the handler (the old stub never called it). - -[MacWorkspace openURLs:withAppBundleIdentifier:options: additionalEventParamDescriptor:launchIdentifiers:]: used by shell.openExternal for the third-party sign-in buttons. Open each URL through openURL: (LaunchServices). Fixes #28 Co-Authored-By: Claude Opus 5.5 --- patches/cocotron.patch | 100 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 3 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 899b568..96258f2 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2069,7 +2069,7 @@ index d545120a..b2845478 100644 + (BOOL) allowsAutomaticWindowTabbing { diff --git a/AppKit/NSWorkspace.m b/AppKit/NSWorkspace.m -index ad5e4f9d..dbd997aa 100644 +index ad5e4f9d..6e9cb1c9 100644 --- a/AppKit/NSWorkspace.m +++ b/AppKit/NSWorkspace.m @@ -20,9 +20,58 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ @@ -2131,7 +2131,7 @@ index ad5e4f9d..dbd997aa 100644 NSString *const NSWorkspaceWillPowerOffNotification = @"NSWorkspaceWillPowerOffNotification"; -@@ -161,6 +210,24 @@ NSNotificationName NSWorkspaceWillUnmountNotification = @"NSWorkspaceWillUnmount +@@ -161,6 +210,41 @@ NSNotificationName NSWorkspaceWillUnmountNotification = @"NSWorkspaceWillUnmount return 0; } @@ -2152,11 +2152,28 @@ index ad5e4f9d..dbd997aa 100644 + } + }); +} ++ ++/* Electron's shell.openExternal({activate: false}) (Riot's "Sign in with Google") uses this ++ * pre-10.15 form. Launch options and the bundle identifier are ignored: the host decides ++ * which application opens the URL. */ ++- (BOOL) openURLs: (NSArray *) urls ++ withAppBundleIdentifier: (NSString *) bundleIdentifier ++ options: (NSUInteger) options ++ additionalEventParamDescriptor: (NSAppleEventDescriptor *) descriptor ++ launchIdentifiers: (NSArray **) identifiers ++{ ++ BOOL opened = [urls count] > 0; ++ for (NSURL *url in urls) ++ opened = [self openURL: url] && opened; ++ if (identifiers != NULL) ++ *identifiers = nil; ++ return opened; ++} + - (BOOL) selectFile: (NSString *) path inFileViewerRootedAtPath: (NSString *) rootedAtPath { -@@ -317,16 +384,3 @@ NSNotificationName NSWorkspaceWillUnmountNotification = @"NSWorkspaceWillUnmount +@@ -317,16 +401,3 @@ NSNotificationName NSWorkspaceWillUnmountNotification = @"NSWorkspaceWillUnmount } @end @@ -10367,3 +10384,80 @@ index 00000000..19cd9ea9 +- (instancetype) init; + +@end +diff --git a/AppKit/NSSavePanel.subproj/NSSavePanel.m b/AppKit/NSSavePanel.subproj/NSSavePanel.m +index 50c3d59e..5872e594 100644 +--- a/AppKit/NSSavePanel.subproj/NSSavePanel.m ++++ b/AppKit/NSSavePanel.subproj/NSSavePanel.m +@@ -124,8 +124,48 @@ static NSSavePanel *_newPanel = nil; + [NSApp stopModalWithCode: NSCancelButton]; + } + ++// Electron (Riot Client UI) opens its file dialogs through these and waits ++// for the handler; run the panel modally and report the result. + - (void) beginWithCompletionHandler: (void (^)(NSModalResponse result)) handler { +- NSUnimplementedMethod(); ++ NSModalResponse result = [self runModal]; ++ if (handler) ++ handler(result); ++} ++ ++- (void) beginSheetModalForWindow: (NSWindow *) window ++ completionHandler: (void (^)(NSModalResponse result)) handler ++{ ++ [self beginWithCompletionHandler: handler]; ++} ++ ++// Electron sets these unconditionally; Cocotron's panel has no tag field, ++// name label or extension toggle, so they are accepted and ignored. ++- (void) setNameFieldLabel: (NSString *) label { ++} ++ ++- (NSString *) nameFieldLabel { ++ return nil; ++} ++ ++- (void) setShowsTagField: (BOOL) flag { ++} ++ ++- (BOOL) showsTagField { ++ return NO; ++} ++ ++- (void) setExtensionHidden: (BOOL) flag { ++} ++ ++- (BOOL) isExtensionHidden { ++ return NO; ++} ++ ++- (void) setCanSelectHiddenExtension: (BOOL) flag { ++} ++ ++- (BOOL) canSelectHiddenExtension { ++ return NO; + } + + - (NSInteger) runModalForDirectory: (NSString *) directory +diff --git a/AppKit/include/AppKit/NSSavePanel.h b/AppKit/include/AppKit/NSSavePanel.h +index 53bacc46..3c6adfb7 100644 +--- a/AppKit/include/AppKit/NSSavePanel.h ++++ b/AppKit/include/AppKit/NSSavePanel.h +@@ -88,6 +88,18 @@ enum { + - (void) setPrompt: (NSString *) message; + - (NSString *) prompt; + ++- (void) setNameFieldLabel: (NSString *) label; ++- (NSString *) nameFieldLabel; ++- (void) setShowsTagField: (BOOL) flag; ++- (BOOL) showsTagField; ++- (void) setExtensionHidden: (BOOL) flag; ++- (BOOL) isExtensionHidden; ++- (void) setCanSelectHiddenExtension: (BOOL) flag; ++- (BOOL) canSelectHiddenExtension; ++ ++- (void) beginSheetModalForWindow: (NSWindow *) window ++ completionHandler: (void (^)(NSModalResponse result)) handler; ++ + - (void) beginSheetForDirectory: (NSString *) path + file: (NSString *) name + modalForWindow: (NSWindow *) docWindow From e4fd017e1f3a6e570d7a441edc50b7a926a650ab Mon Sep 17 00:00:00 2001 From: ztancrell Date: Sat, 17 Oct 2026 08:20:34 -0400 Subject: [PATCH 05/23] Return a valid NSScroller preferredScrollerStyle The unimplemented method returned whatever was left in a register. Chromium forwards the style to its renderer, which rejected the out-of-range value (VALIDATION_ERROR_DESERIALIZATION_FAILED) and left the Riot Client window black. Report NSScrollerStyleLegacy, as macOS does without a trackpad. Co-Authored-By: Claude Opus 5.5 --- patches/cocotron.patch | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 899b568..c6a82b2 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -1127,6 +1127,22 @@ index 5ca58772..54b60ef5 100644 } @end +diff --git a/AppKit/NSScroller.m b/AppKit/NSScroller.m +index 6c361fa3..55027e7e 100644 +--- a/AppKit/NSScroller.m ++++ b/AppKit/NSScroller.m +@@ -34,8 +34,10 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ + return [[NSDisplay currentDisplay] scrollerWidth]; + } + ++// Chromium forwards this to its renderer, which rejects an out-of-range value and leaves ++// the Riot Client black; macOS reports legacy scrollers when no trackpad is attached. + + (NSScrollerStyle) preferredScrollerStyle { +- NSUnimplementedMethod(); ++ return NSScrollerStyleLegacy; + } + /* OS X has a global default "AppleScrollBarVariant" with the values: Single, + DoubleMin, DoubleMax, and DoubleBoth This controls the default position of the diff --git a/AppKit/NSScrubberItemView.m b/AppKit/NSScrubberItemView.m index dea5e1d3..7b3a877b 100644 --- a/AppKit/NSScrubberItemView.m From 8d5a18aabc708c78a81c3b5f0a4028f758b4f00f Mon Sep 17 00:00:00 2001 From: ztancrell Date: Sat, 17 Oct 2026 11:05:28 -0400 Subject: [PATCH 06/23] Derive IOPlatformUUID and serial per host instead of hardcoding them Every install reported the same IOPlatformUUID and an Apple-format serial (C02ZL0GVMD6N) that may belong to a real MacBook Pro. Derive both from a SHA-256 of the host machine-id and a fixed app string: stable per host, distinct between hosts, and the raw machine-id is not exposed. The serial is DARLING-<8 hex>, a form no Mac uses. iokitd is built without ARC, so the derived strings are owned for the daemon's lifetime. Fix the header comment that said the values came from DMI. Refs #24 Co-Authored-By: Claude Opus 5.5 --- patches/iokitd.patch | 57 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/patches/iokitd.patch b/patches/iokitd.patch index 78a80bd..96d4963 100644 --- a/patches/iokitd.patch +++ b/patches/iokitd.patch @@ -663,6 +663,7 @@ index 91d11a1..37551e1 100644 -} diff --git a/src/IOPlatformExpertDevice.h b/src/IOPlatformExpertDevice.h new file mode 100644 +index 0000000..38ad635 --- /dev/null +++ b/src/IOPlatformExpertDevice.h @@ -0,0 +1,21 @@ @@ -673,8 +674,8 @@ new file mode 100644 +#include "ServiceRegistry.h" + +// The root platform device (what IOServiceMatching("IOPlatformExpertDevice") finds on macOS). -+// Its properties describe the host machine as reported by the Linux kernel's DMI tables. -+// Identifiers the host does not expose to us (serial number, UUID) are left out. ++// Its model strings name a fixed MacBookPro16,1. IOPlatformUUID and IOPlatformSerialNumber are ++// derived from the host machine-id, so they differ per host and stay stable across reinstalls. +class IOPlatformExpertDevice : public IOService +{ +public: @@ -689,14 +690,19 @@ new file mode 100644 +#endif diff --git a/src/IOPlatformExpertDevice.mm b/src/IOPlatformExpertDevice.mm new file mode 100644 +index 0000000..e330569 --- /dev/null +++ b/src/IOPlatformExpertDevice.mm -@@ -0,0 +1,54 @@ +@@ -0,0 +1,93 @@ +#include "IOPlatformExpertDevice.h" +#import +#import +#import +#import ++#import ++#include ++#include ++#include +#include + +// Platform expert strings are NUL-terminated data @@ -719,17 +725,52 @@ new file mode 100644 + }; +} + ++// Per-host identifiers, stable like a real Mac's: a SHA-256 of the host machine-id and a fixed ++// string, so the raw machine-id (which machine-id(5) says not to expose) never reaches apps. ++// The serial's "DARLING-" form is longer than any Apple serial and cannot match a real Mac. ++static bool hostIdentifiers(NSString** uuid, NSString** serial) ++{ ++ static const char app[] = "darling-lol IOPlatformExpertDevice"; ++ uuid_t machineID; ++ struct timespec wait = { 0, 0 }; ++ if (gethostuuid(machineID, &wait) != 0) ++ return false; ++ ++ unsigned char input[sizeof(machineID) + sizeof(app)]; ++ std::memcpy(input, machineID, sizeof(machineID)); ++ std::memcpy(input + sizeof(machineID), app, sizeof(app)); ++ unsigned char hash[CC_SHA256_DIGEST_LENGTH]; ++ CC_SHA256(input, sizeof(input), hash); ++ ++ hash[6] = (hash[6] & 0x0f) | 0x40; // version 4 ++ hash[8] = (hash[8] & 0x3f) | 0x80; // RFC 4122 variant ++ uuid_string_t text; ++ uuid_unparse_upper(hash, text); ++ // Owned (not autoreleased): the caller keeps them in statics for the daemon's lifetime ++ *uuid = [[NSString alloc] initWithUTF8String: text]; ++ *serial = [[NSString alloc] initWithFormat: @"DARLING-%02X%02X%02X%02X", hash[16], hash[17], hash[18], hash[19]]; ++ return true; ++} ++ +NSDictionary* IOPlatformExpertDevice::getProperties() +{ -+ // Fixed MacBookPro16,1 identity; the Linux DMI values describe the host hardware, not the Mac we emulate -+ return @{ ++ // MacBookPro16,1 model strings; the Linux DMI values describe the host hardware, not the Mac we emulate ++ NSMutableDictionary* properties = [NSMutableDictionary dictionaryWithDictionary: @{ + @"manufacturer": dataForString(@"Apple Inc."), + @"model": dataForString(@"MacBookPro16,1"), + @"product-name": dataForString(@"MacBookPro16,1"), + @"board-id": dataForString(@"Mac-E1008331FDC96864"), -+ @"IOPlatformUUID": @"6F3B2A1E-8C4D-4E5F-9A7B-1D2C3E4F5A6B", -+ @"IOPlatformSerialNumber": @"C02ZL0GVMD6N", -+ }; ++ }]; ++ ++ static NSString* uuid; ++ static NSString* serial; ++ static bool known = hostIdentifiers(&uuid, &serial); ++ if (known) ++ { ++ properties[@"IOPlatformUUID"] = uuid; ++ properties[@"IOPlatformSerialNumber"] = serial; ++ } ++ return properties; +} + +bool IOPlatformExpertDevice::conformsTo(const char* className) From 40f7a650d1719c4992577067b836f3de0dc7d91a Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:49:42 +0200 Subject: [PATCH 07/23] Report the host GPU name and VRAM from MTLDevice -[MTLDevice name] returned "Darling Indium Device" for every GPU. It now returns the selected Vulkan device's name in macOS Metal style: the trailing driver note in brackets and the (R)/(TM) marks are removed, and NVIDIA names are unchanged. The name is never replaced with another vendor's. recommendedMaxWorkingSetSize is the device-local heap size on discrete GPUs and all heaps on integrated/CPU devices, with the old 1 GiB as fallback. Both are computed once per device. hasUnifiedMemory stays YES by default: synchronizeResource:/ synchronizeTexture: are missing and Private buffers created with data abort in Indium, so reporting NO on discrete GPUs would send apps down unimplemented paths. DARLING_METAL_HOST_UNIFIED_MEMORY=1 opts in to the GPU-type answer. DARLING_METAL_GPU_NAME and DARLING_METAL_VRAM_MB override the name and working set. The cleaning logic lives in private-include/Metal/MTLDeviceInfo.h and is covered by the host check tools/test-metal-device-info.cpp. Metal now links indium_private for the Vulkan properties, and the runtime build script adds Indium's private headers before compiling MTLDevice.mm. Co-Authored-By: Claude Opus 5.5 --- docs/TESTING.md | 10 ++ docs/TROUBLESHOOTING.md | 12 ++ patches/metal.patch | 276 ++++++++++++++++++++++++++++-- tools/build-riot-metal-runtime.py | 3 +- tools/test-metal-device-info.cpp | 89 ++++++++++ upstream/manifest.json | 1 + 6 files changed, 377 insertions(+), 14 deletions(-) create mode 100644 tools/test-metal-device-info.cpp diff --git a/docs/TESTING.md b/docs/TESTING.md index a1e6e80..4c3b06a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -14,6 +14,16 @@ python3 tools/riot-foundation-installer-offline-check.py python3 tools/riot-llvm-version-offline-check.py ``` +The Metal device-report check compiles on the host and needs no Darling: + +```sh +c++ -std=c++17 -I build/darling-src/src/external/metal/private-include \ + tools/test-metal-device-info.cpp -o build/test-metal-device-info +build/test-metal-device-info +``` + +Expected: `METAL_DEVICE_INFO failures=0 pass=1`. It covers how `MTLDevice.name` turns Vulkan names into macOS style (`AMD Radeon RX 6800 XT (RADV NAVI21)` becomes `AMD Radeon RX 6800 XT`, `Intel(R) UHD Graphics 630 (CML GT2)` becomes `Intel UHD Graphics 630`, NVIDIA names stay as they are), the `DARLING_METAL_VRAM_MB` parser, and the working-set sum over Vulkan heaps. + The LLVM version check compiles Iridium's actual dynamic loader against small stub libraries. It verifies that a matching major initializes, older/newer majors produce a version mismatch, and a missing library fails initialization. It does not start Darling. The native AIR translator (`python3 tools/build-riot-air-check.py --native`) selects both headers and the shared library from the same `llvm-config`; its build prints those paths so shader results can be attributed to the tested LLVM installation. The patch check uses temporary base indexes and does not reset or modify the source checkout. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, and preserves Darling runtime library/framework paths. The memory-installer check imports the installer and exercises its policy (default source is the prepared artifact; the artifact is never a destination of a different library) in temporary directories. The Foundation-installer check does the same for the overlay installer: it seeds a temporary framework from a stub SDK, replaces `Versions/C/Foundation`, keeps the standard symlinks, stages a backup, treats a re-run as current and refuses a non-Mach-O source. The installer handoff mock covers 28 scenarios, including HTTP 409, interrupted verification and preserving active downloads. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a44f5bd..973f429 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -234,6 +234,18 @@ A non-zero error or `FAILED` means the frames cannot reach the window. Before 20 An additional NVIDIA report on `68f816d` showed successful optimal-tiling import but repeated `Metal Device timed out giving us a Drawable to present` messages. Its [presentation trace](https://github.com/lucannez64/darling-lol/issues/3#issuecomment-6214732591) shows all three drawables queued and all three main-thread presentation callbacks running, followed by timeouts with three frames still queued. The compositor only prepared a Metal layer when it was the root layer, skipping nested Metal layers. It now prepares Metal layers throughout the tree. A standalone nested-layer reproduction exhausts the pool before this correction and passes ten presentations with pixel readback after it; the root-layer probe also passes. Confirmation on the reporter's NVIDIA system is still needed. +### What Metal reports about the GPU + +`MTLDevice.name` is the selected Vulkan device's name in macOS style: the trailing driver note in brackets and the `(R)`/`(TM)` marks are removed (`AMD Radeon RX 6800 XT (RADV NAVI21)` becomes `AMD Radeon RX 6800 XT`, `Intel(R) UHD Graphics 630 (CML GT2)` becomes `Intel UHD Graphics 630`, `llvmpipe (LLVM …)` becomes `llvmpipe`). NVIDIA names are unchanged. The name is never replaced with another vendor's GPU. Before 2026-10-17 it was always `Darling Indium Device`. + +`recommendedMaxWorkingSetSize` is the size of the device-local Vulkan heaps (the VRAM) on a discrete GPU. On integrated and software GPUs it is the size of all heaps, because their device-local heap can be just the firmware carve-out while they also use system RAM; macOS likewise reports a large share of system RAM for unified-memory GPUs. It falls back to the old 1 GiB when Vulkan reports no usable heap. `hasUnifiedMemory` stays `YES` by default: with `NO`, apps switch to managed and private resource paths (`synchronizeResource:`, private buffers created with data) that are not implemented here. Set these in the game's environment to override: + +- `DARLING_METAL_GPU_NAME=`: report this name. +- `DARLING_METAL_VRAM_MB=`: report this working-set size in MiB. Other values are ignored. +- `DARLING_METAL_HOST_UNIFIED_MEMORY=1`: report `hasUnifiedMemory` from the GPU type (`YES` for integrated and software GPUs, `NO` for discrete ones). This is experimental; on a discrete GPU it can break rendering. + +Each process reads these once. + ### Host-side checks These are host settings, not game settings. Run on AC power, and use a performance power profile (`powerprofilesctl set performance`, or `asusctl profile -P Performance` on ASUS laptops). On the tested desktop the game is limited mostly by its own main thread on the CPU, not by the GPU (see [status](STATUS.md#game-fps-2026-10-1314)), so laptop CPU power limits matter as much as the GPU choice. diff --git a/patches/metal.patch b/patches/metal.patch index d529851..adeb448 100644 --- a/patches/metal.patch +++ b/patches/metal.patch @@ -1,5 +1,5 @@ diff --git a/CMakeLists.txt b/CMakeLists.txt -index 431f29a..7f10875 100644 +index 431f29a..eb3eec8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,8 +42,10 @@ add_framework(Metal @@ -13,6 +13,17 @@ index 431f29a..7f10875 100644 src/Metal/MTLCommandQueue.mm src/Metal/MTLCompileOptions.mm src/Metal/MTLComputeCommandEncoder.mm +@@ -97,6 +99,10 @@ if (BUILD_METAL) + target_compile_definitions(Metal PUBLIC + DARLING_METAL_ENABLED=1 + ) ++ # MTLDevice.mm reads the Vulkan device properties from Indium's private headers. ++ target_link_libraries(Metal PRIVATE ++ indium_private ++ ) + endif() + + # diff --git a/include/Metal/MTLCommandBuffer.h b/include/Metal/MTLCommandBuffer.h index ef43df8..9045a84 100644 --- a/include/Metal/MTLCommandBuffer.h @@ -1123,10 +1134,10 @@ index 2a57796..0cbedf2 100644 - @end diff --git a/src/Metal/MTLDevice.mm b/src/Metal/MTLDevice.mm -index f58184a..6cfc697 100644 +index f58184a..a80a625 100644 --- a/src/Metal/MTLDevice.mm +++ b/src/Metal/MTLDevice.mm -@@ -2,13 +2,26 @@ +@@ -2,14 +2,32 @@ // SPDX-License-Identifier: MPL-2.0 #import @@ -1151,9 +1162,15 @@ index f58184a..6cfc697 100644 +#import +#include #import ++#import ++#if DARLING_METAL_ENABLED ++#include ++#include ++#endif MTL_EXTERN const MTLDeviceNotificationName MTLDeviceWasAddedNotification = @"MTLDeviceWasAdded"; -@@ -88,6 +101,37 @@ void MTLRemoveDeviceObserver(id observer) { + MTL_EXTERN const MTLDeviceNotificationName MTLDeviceRemovalRequestedNotification = @"MTLDeviceRemovalRequested"; +@@ -88,6 +106,37 @@ void MTLRemoveDeviceObserver(id observer) { [observer release]; }; @@ -1191,7 +1208,73 @@ index f58184a..6cfc697 100644 @implementation MTLDeviceInternal #if DARLING_METAL_ENABLED -@@ -153,12 +197,46 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -96,6 +145,9 @@ void MTLRemoveDeviceObserver(id observer) { + NSThread* _pollingThread; + NSCondition* _threadExitCondition; + BOOL _threadIsRunning; ++ NSString* _name; ++ uint64_t _workingSetSize; ++ BOOL _sharesSystemMemory; + } + + @synthesize device = _device; +@@ -116,6 +168,39 @@ void MTLRemoveDeviceObserver(id observer) { + [_threadExitCondition unlock]; + } + ++// Real host GPU facts, computed once per device in -initWithDevice: (games may query them per frame). ++- (void)loadHostDeviceInfo ++{ ++ // Overrides are read once per process. ++ static const char* const nameOverride = getenv("DARLING_METAL_GPU_NAME"); ++ static const uint64_t vramOverride = MTLDeviceInfo::parseMegabytes(getenv("DARLING_METAL_VRAM_MB")); ++ ++ std::string vulkanName = _device->name(); ++ _name = [[NSString alloc] initWithUTF8String: MTLDeviceInfo::resolveName(nameOverride, vulkanName.c_str()).c_str()]; ++ if (_name == nil) { ++ _name = [[NSString alloc] initWithUTF8String: MTLDeviceInfo::kFallbackName]; ++ } ++ ++ uint64_t workingSet = 0; ++ _sharesSystemMemory = NO; ++ if (auto priv = dynamic_cast(_device.get())) { ++ const VkPhysicalDeviceType type = priv->properties().deviceType; ++ _sharesSystemMemory = type == VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU || type == VK_PHYSICAL_DEVICE_TYPE_CPU; ++ const VkPhysicalDeviceMemoryProperties memory = priv->memoryProperties(); ++ MTLDeviceInfo::Heap heaps[VK_MAX_MEMORY_HEAPS]; ++ const size_t count = memory.memoryHeapCount < VK_MAX_MEMORY_HEAPS ? memory.memoryHeapCount : VK_MAX_MEMORY_HEAPS; ++ for (size_t i = 0; i < count; ++i) { ++ heaps[i] = { memory.memoryHeaps[i].size, (memory.memoryHeaps[i].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) != 0 }; ++ } ++ workingSet = MTLDeviceInfo::workingSetBytes(heaps, count, _sharesSystemMemory); ++ } ++ if (vramOverride != 0) { ++ workingSet = vramOverride; ++ } ++ // 1 GiB was the previous fixed value; keep it when Vulkan reports nothing usable. ++ _workingSetSize = workingSet != 0 ? workingSet : (1ull << 30); ++} ++ + - (instancetype)initWithDevice: (std::shared_ptr)device + { + self = [super init]; +@@ -123,6 +208,7 @@ void MTLRemoveDeviceObserver(id observer) { + _device = device; + _threadExitCondition = [NSCondition new]; + _threadIsRunning = NO; ++ [self loadHostDeviceInfo]; + _pollingThread = [[NSThread alloc] initWithTarget: self selector: @selector(pollingLoop) object: nil]; + [_pollingThread start]; + } +@@ -133,6 +219,7 @@ void MTLRemoveDeviceObserver(id observer) { + { + [_pollingThread release]; + [_threadExitCondition release]; ++ [_name release]; + + [super dealloc]; + } +@@ -153,12 +240,60 @@ void MTLRemoveDeviceObserver(id observer) { [_threadExitCondition unlock]; } @@ -1207,13 +1290,27 @@ index f58184a..6cfc697 100644 + +/* darling-lol M57: feature queries ANGLE's Metal backend needs. */ +- (BOOL)isDepth24Stencil8PixelFormatSupported { return _device->supportsDepth24Stencil8PixelFormat(); } -+- (BOOL)hasUnifiedMemory { return YES; } ++- (BOOL)hasUnifiedMemory ++{ ++ // Stays YES by default: with NO, ANGLE and games take the Managed/Private paths ++ // (synchronizeResource:, Private uploads) that this Metal/Indium does not fully implement. ++ // DARLING_METAL_HOST_UNIFIED_MEMORY=1 reports the host GPU type instead ++ // (YES for integrated and CPU devices, NO for discrete ones). ++ static const bool reportHost = [] { ++ const char* value = getenv("DARLING_METAL_HOST_UNIFIED_MEMORY"); ++ return value && value[0] == '1' && value[1] == '\0'; ++ }(); ++ return reportHost ? _sharesSystemMemory : YES; ++} +- (NSUInteger)maxBufferLength { return 256u * 1024u * 1024u; } -+- (NSUInteger)recommendedMaxWorkingSetSize { return 1ull << 30; } ++- (NSUInteger)recommendedMaxWorkingSetSize ++{ ++ return _workingSetSize > NSUIntegerMax ? NSUIntegerMax : static_cast(_workingSetSize); ++} +- (BOOL)isLowPower { return NO; } +- (BOOL)isRemovable { return NO; } +- (BOOL)isHeadless { return NO; } -+- (NSString*)name { return @"Darling Indium Device"; } ++- (NSString*)name { return _name; } +- (uint64_t)registryID { return 0; } +- (BOOL)supportsFamily: (NSUInteger)family { return YES; } +- (BOOL)supportsFeatureSet: (NSUInteger)featureSet { return YES; } @@ -1239,7 +1336,7 @@ index f58184a..6cfc697 100644 if (!pso) { if (error) { // TODO: better error and/or match what the official Metal method does -@@ -166,6 +244,7 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -166,6 +301,7 @@ void MTLRemoveDeviceObserver(id observer) { } return nil; } @@ -1247,7 +1344,7 @@ index f58184a..6cfc697 100644 return [[MTLComputePipelineStateInternal alloc] initWithState: pso device: self label: descriptor.label]; } -@@ -183,7 +262,13 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -183,7 +319,13 @@ void MTLRemoveDeviceObserver(id observer) { reflection: (MTLAutoreleasedComputePipelineReflection*)reflection error: (NSError**)error { @@ -1262,7 +1359,7 @@ index f58184a..6cfc697 100644 if (!pso) { if (error) { // TODO: better error and/or match what the official Metal method does -@@ -191,13 +276,44 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -191,13 +333,44 @@ void MTLRemoveDeviceObserver(id observer) { } return nil; } @@ -1308,7 +1405,7 @@ index f58184a..6cfc697 100644 if (!pso) { if (error) { // TODO: better error and/or match what the official Metal method does -@@ -205,7 +321,114 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -205,7 +378,114 @@ void MTLRemoveDeviceObserver(id observer) { } return nil; } @@ -1424,7 +1521,7 @@ index f58184a..6cfc697 100644 } - (id)newCommandQueue -@@ -275,8 +498,21 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -275,8 +555,21 @@ void MTLRemoveDeviceObserver(id observer) { - (id)newLibraryWithData: (dispatch_data_t)data error: (NSError**)error { @@ -3369,6 +3466,159 @@ new file mode 100644 +const std::shared_ptr& MTLDepthStencilGetIndium(id object); + +#endif +diff --git a/private-include/Metal/MTLDeviceInfo.h b/private-include/Metal/MTLDeviceInfo.h +new file mode 100644 +--- /dev/null ++++ b/private-include/Metal/MTLDeviceInfo.h +@@ -0,0 +1,148 @@ ++// SPDX-FileCopyrightText: 2026 Darling Developers ++// SPDX-License-Identifier: MPL-2.0 ++ ++#ifndef _METAL_MTLDEVICEINFO_H_ ++#define _METAL_MTLDEVICEINFO_H_ ++ ++// Host-testable helpers for the values MTLDevice reports about the GPU. ++// Plain C++ only (no Objective-C, Vulkan or Indium) so tools/test-metal-device-info.cpp ++// can compile this header on the host. ++ ++#include ++#include ++#include ++ ++namespace MTLDeviceInfo { ++ ++// Used when neither the cleaned nor the raw Vulkan name has any characters. ++static constexpr const char* kFallbackName = "Darling Indium Device"; ++ ++static inline bool isSpace(char c) { ++ return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'; ++} ++ ++static inline std::string collapseWhitespace(const std::string& text) { ++ std::string out; ++ bool pendingSpace = false; ++ for (char c: text) { ++ if (isSpace(c)) { ++ pendingSpace = !out.empty(); ++ continue; ++ } ++ if (pendingSpace) { ++ out.push_back(' '); ++ pendingSpace = false; ++ } ++ out.push_back(c); ++ } ++ return out; ++} ++ ++static inline char lower(char c) { ++ return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; ++} ++ ++// Removes "(R)" and "(TM)" in any letter case (Intel's Vulkan names use both). ++static inline std::string removeTrademarks(const std::string& text) { ++ std::string out; ++ for (size_t i = 0; i < text.size();) { ++ if (text[i] == '(') { ++ if (i + 2 < text.size() && lower(text[i + 1]) == 'r' && text[i + 2] == ')') { ++ i += 3; ++ continue; ++ } ++ if (i + 3 < text.size() && lower(text[i + 1]) == 't' && lower(text[i + 2]) == 'm' && text[i + 3] == ')') { ++ i += 4; ++ continue; ++ } ++ } ++ out.push_back(text[i++]); ++ } ++ return out; ++} ++ ++// Drops one trailing parenthetical group (Mesa/driver annotation such as ++// " (RADV NAVI21)", " (CML GT2)" or " (LLVM 22.1.0, 256 bits)"), but never when ++// the group is the whole name. Expects whitespace already collapsed and trimmed. ++static inline std::string removeTrailingGroup(const std::string& text) { ++ if (text.empty() || text.back() != ')') { ++ return text; ++ } ++ int depth = 0; ++ for (size_t i = text.size(); i-- > 0;) { ++ if (text[i] == ')') { ++ ++depth; ++ } else if (text[i] == '(' && --depth == 0) { ++ std::string prefix = collapseWhitespace(text.substr(0, i)); ++ return prefix.empty() ? text : prefix; ++ } ++ } ++ return text; // unbalanced: leave it alone ++} ++ ++// Converts a VkPhysicalDeviceProperties::deviceName into macOS Metal style, ++// e.g. "AMD Radeon RX 6800 XT (RADV NAVI21)" -> "AMD Radeon RX 6800 XT" and ++// "Intel(R) UHD Graphics 630 (CML GT2)" -> "Intel UHD Graphics 630". ++// This only cleans the real name; it never substitutes another GPU. ++static inline std::string metalNameFromVulkanName(const char* vulkanName) { ++ std::string raw = vulkanName ? vulkanName : ""; ++ std::string cleaned = removeTrailingGroup(collapseWhitespace(removeTrademarks(raw))); ++ if (!cleaned.empty()) { ++ return cleaned; ++ } ++ std::string rawTrimmed = collapseWhitespace(raw); ++ return rawTrimmed.empty() ? std::string(kFallbackName) : rawTrimmed; ++} ++ ++// DARLING_METAL_GPU_NAME (used verbatim when non-empty) wins over the Vulkan name. ++static inline std::string resolveName(const char* overrideName, const char* vulkanName) { ++ if (overrideName && overrideName[0] != '\0') { ++ return overrideName; ++ } ++ return metalNameFromVulkanName(vulkanName); ++} ++ ++// Parses DARLING_METAL_VRAM_MB: a positive decimal integer of mebibytes, digits only. ++// Returns 0 (meaning "ignore") for anything else, including overflow. ++static inline uint64_t parseMegabytes(const char* text) { ++ if (!text || text[0] == '\0') { ++ return 0; ++ } ++ uint64_t megabytes = 0; ++ for (const char* p = text; *p; ++p) { ++ if (*p < '0' || *p > '9') { ++ return 0; ++ } ++ uint64_t digit = static_cast(*p - '0'); ++ if (megabytes > (UINT64_MAX / (1024ull * 1024ull) - digit) / 10) { ++ return 0; ++ } ++ megabytes = megabytes * 10 + digit; ++ } ++ return megabytes * 1024ull * 1024ull; ++} ++ ++struct Heap { ++ uint64_t size; ++ bool deviceLocal; ++}; ++ ++// Working-set size from the Vulkan memory heaps; 0 when nothing usable is reported. ++// Discrete GPUs report their device-local (VRAM) heaps. GPUs sharing system RAM ++// (integrated/CPU device types) report every heap: their device-local heap may be ++// only the firmware carve-out (RADV APUs) while the GPU can also use the system heap, ++// which matches macOS reporting a large share of system RAM for unified-memory GPUs. ++static inline uint64_t workingSetBytes(const Heap* heaps, size_t count, bool sharesSystemMemory) { ++ uint64_t total = 0; ++ for (size_t i = 0; i < count; ++i) { ++ if (!sharesSystemMemory && !heaps[i].deviceLocal) { ++ continue; ++ } ++ total = heaps[i].size > UINT64_MAX - total ? UINT64_MAX : total + heaps[i].size; ++ } ++ return total; ++} ++ ++} // namespace MTLDeviceInfo ++ ++#endif // _METAL_MTLDEVICEINFO_H_ diff --git a/private-include/Metal/MTLSamplerStateInternal.h b/private-include/Metal/MTLSamplerStateInternal.h new file mode 100644 --- /dev/null diff --git a/tools/build-riot-metal-runtime.py b/tools/build-riot-metal-runtime.py index 415bf4a..ed27f58 100644 --- a/tools/build-riot-metal-runtime.py +++ b/tools/build-riot-metal-runtime.py @@ -13,6 +13,8 @@ for line in (folder / 'CMakeFiles/Metal.dir/flags.make').read_text().splitlines( flags[key] = shlex.split(value) resource_include = subprocess.check_output(['clang++', '-print-resource-dir'], text=True).strip() + '/include' flags['CXX_INCLUDES'] = [resource_include if value.startswith('/usr/lib/clang/') and value.endswith('/include') else value for value in flags['CXX_INCLUDES']] +# MTLDevice.mm reads Vulkan device properties through Indium's private headers. +flags['CXX_INCLUDES'].append('-I' + str(root / 'build/darling-src/src/external/metal/deps/indium/private-include')) link = shlex.split((folder / 'CMakeFiles/Metal.dir/link.txt').read_text()) # These objects passed live League validation. Keep the broader resource/queue # optimization experiment out of the default build after fresh-match stalls. @@ -28,7 +30,6 @@ for name in ('MTLBuffer', 'MTLTexture', 'MTLDevice', 'MTLSamplerDescriptor', 'MT link.append(str(obj)) link[link.index('-o') + 1] = str(root / 'build/riot-Metal') subprocess.run(link, cwd=folder, check=True) -flags['CXX_INCLUDES'].append('-I' + str(root / 'build/darling-src/src/external/metal/deps/indium/private-include')) obj = root / 'build/riot-metal-sampler-check.o' subprocess.run(['clang++', *flags['CXX_DEFINES'], *flags['CXX_INCLUDES'], *flags['CXX_FLAGS'], '-c', str(root / 'tools/riot-metal-sampler-check.mm'), diff --git a/tools/test-metal-device-info.cpp b/tools/test-metal-device-info.cpp new file mode 100644 index 0000000..926dd75 --- /dev/null +++ b/tools/test-metal-device-info.cpp @@ -0,0 +1,89 @@ +// Host check for the GPU name and working-set helpers behind -[MTLDevice name] and +// -[MTLDevice recommendedMaxWorkingSetSize] (private-include/Metal/MTLDeviceInfo.h). +// Build and run after preparing the sources (see docs/TESTING.md): +// c++ -std=c++17 -I build/darling-src/src/external/metal/private-include +// tools/test-metal-device-info.cpp -o build/test-metal-device-info +// build/test-metal-device-info +#include +#include +#include + +static int failures = 0; + +static void expectName(const char* vulkan, const char* expected) { + std::string actual = MTLDeviceInfo::metalNameFromVulkanName(vulkan); + bool ok = actual == expected; + failures += !ok; + std::printf("%s name \"%s\" -> \"%s\"%s%s%s\n", ok ? "PASS" : "FAIL", vulkan ? vulkan : "(null)", + actual.c_str(), ok ? "" : " (expected \"", ok ? "" : expected, ok ? "" : "\")"); +} + +static void expectBytes(const char* what, unsigned long long actual, unsigned long long expected) { + bool ok = actual == expected; + failures += !ok; + std::printf("%s %s -> %llu%s\n", ok ? "PASS" : "FAIL", what, actual, ok ? "" : " (mismatch)"); + if (!ok) std::printf(" expected %llu\n", expected); +} + +int main() { + // Mesa and proprietary Vulkan names -> macOS Metal style. + expectName("AMD Radeon RX 6800 XT (RADV NAVI21)", "AMD Radeon RX 6800 XT"); + expectName("AMD Radeon RX 6650 XT (RADV NAVI23)", "AMD Radeon RX 6650 XT"); + expectName("AMD Radeon Graphics (RADV RENOIR)", "AMD Radeon Graphics"); + expectName("AMD Radeon Pro 5500M (RADV NAVI14)", "AMD Radeon Pro 5500M"); + expectName("AMD RADV NAVI21 (ACO)", "AMD RADV NAVI21"); + expectName("Intel(R) UHD Graphics 630 (CML GT2)", "Intel UHD Graphics 630"); + expectName("Intel(R) Arc(TM) A770 Graphics (DG2)", "Intel Arc A770 Graphics"); + expectName("NVIDIA GeForce GTX 680", "NVIDIA GeForce GTX 680"); + expectName("NVIDIA GeForce RTX 2070 SUPER", "NVIDIA GeForce RTX 2070 SUPER"); + expectName("NVIDIA GeForce RTX 4090", "NVIDIA GeForce RTX 4090"); + expectName("llvmpipe (LLVM 22.1.0, 256 bits)", "llvmpipe"); + // Whitespace, nesting and edge cases. + expectName(" AMD Radeon\tRX 7900 XTX (RADV NAVI31) ", "AMD Radeon RX 7900 XTX"); + expectName("Foo GPU (driver (nested) x)", "Foo GPU"); + expectName("Foo (Bar) GPU", "Foo (Bar) GPU"); // not trailing: kept + expectName("(RADV NAVI21)", "(RADV NAVI21)"); // whole name: kept + expectName("Broken (unbalanced", "Broken (unbalanced"); + expectName("(R)(TM)", "(R)(TM)"); // cleaned empty -> raw name + expectName(" ", MTLDeviceInfo::kFallbackName); // raw empty -> neutral fallback + expectName("", MTLDeviceInfo::kFallbackName); + expectName(nullptr, MTLDeviceInfo::kFallbackName); + + // DARLING_METAL_GPU_NAME override. + { + bool ok = MTLDeviceInfo::resolveName("My GPU", "AMD Radeon RX 6800 XT (RADV NAVI21)") == "My GPU" + && MTLDeviceInfo::resolveName("", "AMD Radeon RX 6800 XT (RADV NAVI21)") == "AMD Radeon RX 6800 XT" + && MTLDeviceInfo::resolveName(nullptr, "NVIDIA GeForce RTX 4090") == "NVIDIA GeForce RTX 4090"; + failures += !ok; + std::printf("%s name override\n", ok ? "PASS" : "FAIL"); + } + + // DARLING_METAL_VRAM_MB parsing: positive decimal integer only. + const unsigned long long MiB = 1024ull * 1024ull; + expectBytes("vram \"8192\"", MTLDeviceInfo::parseMegabytes("8192"), 8192 * MiB); + expectBytes("vram \"1\"", MTLDeviceInfo::parseMegabytes("1"), MiB); + expectBytes("vram \"0\"", MTLDeviceInfo::parseMegabytes("0"), 0); + expectBytes("vram \"\"", MTLDeviceInfo::parseMegabytes(""), 0); + expectBytes("vram null", MTLDeviceInfo::parseMegabytes(nullptr), 0); + expectBytes("vram \"-5\"", MTLDeviceInfo::parseMegabytes("-5"), 0); + expectBytes("vram \"8G\"", MTLDeviceInfo::parseMegabytes("8G"), 0); + expectBytes("vram \" 8\"", MTLDeviceInfo::parseMegabytes(" 8"), 0); + expectBytes("vram \"1.5\"", MTLDeviceInfo::parseMegabytes("1.5"), 0); + expectBytes("vram max", MTLDeviceInfo::parseMegabytes("17592186044415"), 17592186044415ull * MiB); + expectBytes("vram overflow", MTLDeviceInfo::parseMegabytes("17592186044416"), 0); + expectBytes("vram huge", MTLDeviceInfo::parseMegabytes("99999999999999999999999"), 0); + + // Working set from Vulkan heaps. + const MTLDeviceInfo::Heap discrete[] = {{8192 * MiB, true}, {16384 * MiB, false}, {256 * MiB, true}}; + expectBytes("discrete heaps", MTLDeviceInfo::workingSetBytes(discrete, 3, false), 8448 * MiB); + const MTLDeviceInfo::Heap apu[] = {{512 * MiB, true}, {15000 * MiB, false}}; + expectBytes("integrated heaps", MTLDeviceInfo::workingSetBytes(apu, 2, true), 15512 * MiB); + const MTLDeviceInfo::Heap hostOnly[] = {{4096 * MiB, false}}; + expectBytes("discrete without device-local heap", MTLDeviceInfo::workingSetBytes(hostOnly, 1, false), 0); + expectBytes("no heaps", MTLDeviceInfo::workingSetBytes(nullptr, 0, false), 0); + const MTLDeviceInfo::Heap huge[] = {{UINT64_MAX, true}, {UINT64_MAX, true}}; + expectBytes("saturating sum", MTLDeviceInfo::workingSetBytes(huge, 2, false), UINT64_MAX); + + std::printf("METAL_DEVICE_INFO failures=%d pass=%d\n", failures, failures == 0); + return failures == 0 ? 0 : 1; +} diff --git a/upstream/manifest.json b/upstream/manifest.json index bc13a46..1fff96e 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -244,6 +244,7 @@ "private-include/Metal/MTLArgumentInternal.h", "private-include/Metal/MTLBlitCommandEncoderInternal.h", "private-include/Metal/MTLDepthStencilInternal.h", + "private-include/Metal/MTLDeviceInfo.h", "private-include/Metal/MTLSamplerStateInternal.h", "private-include/Metal/MTLVertexDescriptorInternal.h", "src/Metal/MTLArgument.mm", From 52a6dae432e387793efcf9b2e5da2330d27bf040 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:03:32 +0200 Subject: [PATCH 08/23] Build Darling's system root store from the host CA bundle Security reads the system trust domain from /System/Library/Keychains/SystemRootCertificates.keychain and SystemTrustSettings.plist (TrustSettingsSchema.h). Darling ships neither, so SecTrustSettingsCopyCertificates(kSecTrustSettingsDomainSystem) returns errSecNoTrustSettings (-25263); Chromium's known_roots_mac and Riot's CertBundle then report that they cannot read the system roots. ensure-darling-root-certificates.py parses each host root with a minimal DER walker and writes the trust record keyed by the uppercase SHA-1, with the encoded issuer Name, the serial INTEGER's content octets, modDate and an empty (unrestricted) trustSettings array. The keychain is built with the guest security tool in one darling shell, in a private temporary directory, and published from inside the guest so a running Darling sees it, falling back to the host when the guest cannot write the directory. A digest stamp skips later runs without starting Darling. The launcher warns and continues on failure; install-runtime.sh runs it before stopping Darling. Co-Authored-By: Claude Opus 5.5 --- docs/TESTING.md | 1 + docs/TROUBLESHOOTING.md | 12 + scripts/install-runtime.sh | 3 + tests/test_root_certificates.py | 162 ++++++++++++ tools/ensure-darling-root-certificates.py | 285 ++++++++++++++++++++++ tools/riot-launcher-offline-check.py | 3 +- tools/run-riot-client.sh | 5 + 7 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 tests/test_root_certificates.py create mode 100644 tools/ensure-darling-root-certificates.py diff --git a/docs/TESTING.md b/docs/TESTING.md index a1e6e80..d7e4eb7 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -12,6 +12,7 @@ python3 tools/riot-launcher-offline-check.py python3 tools/riot-memory-installer-offline-check.py python3 tools/riot-foundation-installer-offline-check.py python3 tools/riot-llvm-version-offline-check.py +python3 -m unittest tests.test_root_certificates ``` The LLVM version check compiles Iridium's actual dynamic loader against small stub libraries. It verifies that a matching major initializes, older/newer majors produce a version mismatch, and a missing library fails initialization. It does not start Darling. The native AIR translator (`python3 tools/build-riot-air-check.py --native`) selects both headers and the shared library from the same `llvm-config`; its build prints those paths so shader results can be attributed to the tested LLVM installation. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a44f5bd..377d422 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -56,6 +56,18 @@ The installer fetcher no longer treats a missing archive `Info.plist` or the inf There is also a separate known compatibility gap: native LaunchAgent watch registration can return HTTP 409 after the League install path is accepted, leaving the download idle. A historical inspector hook handles this and interrupted `pending_verify` states in 28 mock cases, but that hook is disabled in normal launches and excluded from the manual workflow. There is no verified runtime-only replacement for that handoff in this snapshot. Do not assume the native permission and launcher fixes resolve every idle Install case, or enable debugger/API automation to work around it. +## `Unable to read system root certificates` or `Error -25263` + +Riot logs `CertBundle: Unable to read system root certificates` and Chromium logs `[known_roots_mac.cc(60)] Unable to determine trusted roots; assuming all roots are trusted! Error -25263` when the prefix has no system trust domain. Security reads that domain from `/System/Library/Keychains/SystemRootCertificates.keychain` and `SystemTrustSettings.plist`, which Darling does not ship, so `SecTrustSettingsCopyCertificates` returns `errSecNoTrustSettings`. + +`tools/ensure-darling-root-certificates.py` builds both from the host CA bundle (`/etc/ssl/cert.pem` or whichever bundle Python's `ssl` reports; `--bundle` overrides it). The launcher and `scripts/install-runtime.sh` run it. Each self-signed root gets an unrestricted trust entry. The keychain is built with the guest `security` tool in a single `darling shell`. A stamp in the same directory records the bundle's digest, so later launches skip it without starting Darling until the host bundle changes. If Darling was already running and the guest could not write the directory, the script installs the files on the host and says they apply after `darling shutdown`. A failure prints `Darling system root certificates were not installed: …` and the launch continues. To rebuild by hand: + +```sh +python3 build/runtime/tools/ensure-darling-root-certificates.py --force +``` + +`python3 -m unittest tests.test_root_certificates` compares the generated SHA-1 keys, issuer names and serial numbers with OpenSSL, and checks that an unchanged bundle is skipped. It uses stub `darling` and `security` commands. Reading the keychain and the system trust domain inside a real guest (`security find-certificate -a /System/Library/Keychains/SystemRootCertificates.keychain`, `security dump-trust-settings -s`) is not yet confirmed. + ## Riot closes after a network lookup is cancelled Two local crashes were traced to `CFNetwork::_HostCancel` calling `CFRetain(NULL)`. diff --git a/scripts/install-runtime.sh b/scripts/install-runtime.sh index ad26e21..fa150b6 100755 --- a/scripts/install-runtime.sh +++ b/scripts/install-runtime.sh @@ -13,6 +13,9 @@ darling shell /usr/bin/true # installed layer and every write there (the Riot installer's temp files) fails with EACCES. mkdir -p "$HOME/.darling/private/tmp" chmod 1777 "$HOME/.darling/private/tmp" +# Build the system root store now that the prefix exists; Darling is stopped next anyway. +python3 build/runtime/tools/ensure-darling-root-certificates.py || + echo 'Darling system root store not installed; the launcher retries it' >&2 python3 build/runtime/tools/stop-darling-runtime.py sh build/runtime/tools/install-riot-presentation-runtime.sh sh build/runtime/tools/install-riot-gpu-runtime.sh diff --git a/tests/test_root_certificates.py b/tests/test_root_certificates.py new file mode 100644 index 0000000..d802459 --- /dev/null +++ b/tests/test_root_certificates.py @@ -0,0 +1,162 @@ +"""Check the system root store generator against OpenSSL without starting Darling. + +A stub `darling` runs the guest script on the host with a stub `security` tool, so +the generated trust record, the guest/host hand-off and the idempotency stamp are +exercised; the real keychain database is not. +""" +import contextlib +import hashlib +import io +import os +from pathlib import Path +import plistlib +import re +import runpy +import shutil +import subprocess +import tempfile +import textwrap +import unittest + +SCRIPT = Path(__file__).resolve().parents[1] / 'tools/ensure-darling-root-certificates.py' +tool = runpy.run_path(str(SCRIPT)) + +DARLING_STUB = textwrap.dedent('''\ + #!/usr/bin/env python3 + import os, subprocess, sys + with open(os.environ['STUB_LOG'], 'a') as log: + log.write(' '.join(sys.argv[1:]) + '\\n') + if os.environ.get('STUB_FAIL'): + sys.exit(3) + assert sys.argv[1] == 'shell' + strip = lambda value: value[len('/Volumes/SystemRoot'):] if value.startswith('/Volumes/SystemRoot/') else value + env = dict(os.environ, PATH=os.environ['STUB_BIN'] + ':' + os.environ['PATH']) + sys.exit(subprocess.run([strip(arg) for arg in sys.argv[2:]], env=env).returncode) +''') + +SECURITY_STUB = textwrap.dedent('''\ + #!/usr/bin/env python3 + import hashlib, sys + command, args = sys.argv[1], sys.argv[2:] + if command == 'create-keychain': + open(args[-1], 'wb').close() + elif command == 'add-certificates': + keychain = args[args.index('-k') + 1] + with open(keychain, 'a') as stream: + for path in args[args.index('-k') + 2:]: + data = open(path, 'rb').read() + stream.write(hashlib.sha1(data).hexdigest().upper() + '\\n') + elif command == 'find-certificate': + for line in open(args[-1]): + print('keychain: "%s"' % args[-1]) + print('SHA-1 hash: ' + line.strip()) +''') + + +def openssl(der, *options): + return subprocess.run(['openssl', 'x509', '-inform', 'DER', '-noout', *options], input=der, + capture_output=True, check=True).stdout.decode() + + +def asn1_issuer(der): + """Issuer bytes located by OpenSSL's own ASN.1 parser: the fourth TBSCertificate field + (third without an explicit version).""" + lines = subprocess.run(['openssl', 'asn1parse', '-inform', 'DER'], input=der, + capture_output=True, check=True).stdout.decode().splitlines() + fields = [] + for line in lines: + match = re.match(r'\s*(\d+):d=(\d+)\s+hl=(\d+)\s+l=\s*(\d+)\s+(\w+):\s*(.*)', line) + if match and match.group(2) == '2': + fields.append(match) + if fields[0].group(6).startswith('cont [ 0 ]'): + fields = fields[1:] + offset, header, length = (int(fields[2].group(index)) for index in (1, 3, 4)) + return der[offset:offset + header + length] + + +@unittest.skipUnless(shutil.which('openssl'), 'openssl is required') +class RootCertificateTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.bundle = tool['default_bundle']() + self.data = self.bundle.read_bytes() + self.prefix = self.root / 'prefix' + self.prefix.mkdir() + stubs = self.root / 'bin' + stubs.mkdir() + for name, text in (('darling', DARLING_STUB), ('security', SECURITY_STUB)): + (stubs / name).write_text(text) + (stubs / name).chmod(0o755) + self.darling = [str(stubs / 'darling')] + self.log = self.root / 'darling.log' + self.log.touch() + self.environment = {'STUB_LOG': str(self.log), 'STUB_BIN': str(stubs)} + + def run_tool(self, bundle=None, **extra): + output = io.StringIO() + saved = dict(os.environ) + os.environ.update(self.environment, **extra) + try: + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): + status = tool['main'](['--prefix', str(self.prefix), '--bundle', str(bundle or self.bundle), + '--darling', ' '.join(self.darling)]) + finally: + os.environ.clear() + os.environ.update(saved) + return status, output.getvalue() + + def test_fields_match_openssl(self): + roots = tool['load_roots'](self.data, report=lambda line: None) + self.assertGreater(len(roots), 50) + samples = [roots[0], roots[len(roots) // 2], roots[-1]] + # Include a serial whose INTEGER needs a leading zero octet, if the bundle has one. + samples += [cert for cert in roots if cert['serial'][0] == 0][:1] + for cert in samples: + fingerprint = openssl(cert['der'], '-fingerprint', '-sha1').split('=', 1)[1].strip() + self.assertEqual(cert['sha1'], fingerprint.replace(':', '')) + serial = openssl(cert['der'], '-serial').split('=', 1)[1].strip() + self.assertEqual(int.from_bytes(cert['serial'], 'big'), int(serial, 16)) + self.assertEqual(cert['issuer'], asn1_issuer(cert['der'])) + + def test_trust_record_and_idempotency(self): + status, output = self.run_tool() + self.assertEqual(status, 0, output) + folder = self.prefix / 'System/Library/Keychains' + record = plistlib.loads((folder / 'SystemTrustSettings.plist').read_bytes()) + self.assertEqual(record['trustVersion'], 1) + roots = tool['load_roots'](self.data, report=lambda line: None) + self.assertEqual(set(record['trustList']), {cert['sha1'] for cert in roots}) + for cert in roots[:5]: + entry = record['trustList'][cert['sha1']] + self.assertEqual(entry['issuerName'], cert['issuer']) + self.assertEqual(entry['serialNumber'], cert['serial']) + self.assertEqual(entry['trustSettings'], []) + self.assertEqual(hashlib.sha1(cert['der']).hexdigest().upper(), cert['sha1']) + self.assertEqual(len((folder / 'SystemRootCertificates.keychain').read_text().split()), len(roots)) + self.assertEqual(len(self.log.read_text().splitlines()), 1) + status, output = self.run_tool() + self.assertEqual((status, output), (0, '')) + self.assertEqual(len(self.log.read_text().splitlines()), 1, 'unchanged bundle restarted Darling') + # A changed bundle is rebuilt. + smaller = self.root / 'smaller.pem' + smaller.write_bytes(b''.join(re.findall(rb'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----\n?', + self.data, re.S)[:3])) + status, output = self.run_tool(smaller) + self.assertEqual(status, 0, output) + self.assertEqual(len(self.log.read_text().splitlines()), 2) + self.assertEqual(len(plistlib.loads((folder / 'SystemTrustSettings.plist').read_bytes())['trustList']), 3) + + def test_failure_is_reported_and_not_stamped(self): + status, output = self.run_tool(STUB_FAIL='1') + self.assertEqual(status, 1) + self.assertIn('Darling system root certificates were not installed', output) + self.assertFalse((self.prefix / 'System/Library/Keychains' / tool['STAMP']).exists()) + kept = re.search(r'see (\S+)/guest\.log', output) + self.assertTrue(kept, output) + shutil.rmtree(kept.group(1)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/ensure-darling-root-certificates.py b/tools/ensure-darling-root-certificates.py new file mode 100644 index 0000000..f5591b2 --- /dev/null +++ b/tools/ensure-darling-root-certificates.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +"""Give Darling's system trust domain the host's CA roots. + +Security.framework reads the system domain from two immutable files +(libsecurity_keychain TrustSettingsSchema.h): the root store +/System/Library/Keychains/SystemRootCertificates.keychain and its trust record +SystemTrustSettings.plist. Darling ships neither, so SecTrustSettingsCopyCertificates +for the system domain fails with errSecNoTrustSettings (-25263) and Chromium and +Riot's CertBundle report that they cannot read the system roots. + +The trust record is generated here from the host CA bundle. The keychain uses the +guest `security` tool, since no host-side writer for its database format exists. +Darling is only started when the bundle (or this script) changed; a stamp next to +the outputs records the input digest. +""" +import argparse +import base64 +import binascii +import datetime +import hashlib +import json +import os +from pathlib import Path +import plistlib +import re +import shutil +import ssl +import subprocess +import sys +import tempfile + +VERSION = 1 +KEYCHAIN = 'SystemRootCertificates.keychain' +TRUST_SETTINGS = 'SystemTrustSettings.plist' +STAMP = '.darling-lol-root-certificates.json' +SYSTEM_ROOT = '/Volumes/SystemRoot' +PEM = re.compile(rb'-----BEGIN CERTIFICATE-----\s*(.*?)\s*-----END CERTIFICATE-----', re.S) + +# One guest invocation: build the keychain in the private work directory, list what +# it holds, then publish both files from inside the guest so a running Darling sees +# them (host writes to the prefix are invisible to a mounted overlay). The guest +# cannot create directories in the root-owned base layer; the host then publishes. +GUEST_SCRIPT = r'''#!/bin/sh +set -u +PATH=$PATH:/usr/bin:/bin +work=$1 +kc=$work/SystemRootCertificates.keychain +security create-keychain -p '' "$kc" || exit 10 +security unlock-keychain -p '' "$kc" || exit 11 +# Certificates the guest cannot parse are reported and left out; the host checks found.txt. +security add-certificates -k "$kc" "$work"/certs/*.cer 2> "$work/add-errors.txt" +security find-certificate -a -Z "$kc" > "$work/found.txt" || exit 12 +grep -q 'SHA-1 hash:' "$work/found.txt" || exit 13 +dest=/System/Library/Keychains +if [ -d "$dest" ] && [ -w "$dest" ] && + cp "$kc" "$dest/.SystemRootCertificates.keychain.next" 2>/dev/null && + cp "$work/SystemTrustSettings.plist" "$dest/.SystemTrustSettings.plist.next" 2>/dev/null && + mv -f "$dest/.SystemRootCertificates.keychain.next" "$dest/SystemRootCertificates.keychain" && + mv -f "$dest/.SystemTrustSettings.plist.next" "$dest/SystemTrustSettings.plist"; then + : > "$work/placed" +else + rm -f "$dest/.SystemRootCertificates.keychain.next" "$dest/.SystemTrustSettings.plist.next" 2>/dev/null +fi +exit 0 +''' + + +class Failure(Exception): + pass + + +def default_bundle(): + paths = ssl.get_default_verify_paths() + for candidate in (paths.cafile, paths.openssl_cafile, '/etc/ssl/certs/ca-certificates.crt', + '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/ca-bundle.pem', '/etc/ssl/cert.pem'): + if candidate and Path(candidate).is_file(): + return Path(candidate) + raise Failure('no host CA bundle found; pass --bundle') + + +def der_element(data, offset): + """Return (tag, content start, end) of the DER element at offset.""" + if offset + 2 > len(data): + raise ValueError('truncated element') + tag = data[offset] + if tag & 0x1f == 0x1f: + raise ValueError('high tag numbers are not used in certificates') + length = data[offset + 1] + start = offset + 2 + if length & 0x80: + count = length & 0x7f + if count == 0 or count > 4 or start + count > len(data): + raise ValueError('unsupported length') + length = int.from_bytes(data[start:start + count], 'big') + start += count + end = start + length + if end > len(data): + raise ValueError('truncated element') + return tag, start, end + + +def parse_certificate(der): + """Fields TrustSettings::copyIssuerAndSerial stores: the issuer Name as encoded + (CSSMOID_X509V1IssuerNameStd) and the serial INTEGER's content octets.""" + tag, start, end = der_element(der, 0) + if tag != 0x30 or end != len(der): + raise ValueError('not a single DER certificate') + tag, offset, tbs_end = der_element(der, start) + if tag != 0x30: + raise ValueError('missing TBSCertificate') + tag, content, end = der_element(der, offset) + if tag == 0xa0: # explicit version + offset = end + tag, content, end = der_element(der, offset) + if tag != 0x02 or content == end: + raise ValueError('missing serial number') + serial = der[content:end] + offset = end + for name in ('signature', 'issuer', 'validity', 'subject'): + tag, content, end = der_element(der, offset) + if tag != 0x30 or end > tbs_end: + raise ValueError(f'missing {name}') + if name == 'issuer': + issuer = der[offset:end] + elif name == 'subject': + subject = der[offset:end] + offset = end + return {'sha1': hashlib.sha1(der).hexdigest().upper(), 'issuer': issuer, + 'serial': serial, 'subject': subject, 'der': der} + + +def load_roots(data, report=print): + roots, seen, identities, skipped = [], set(), {}, [] + for index, match in enumerate(PEM.finditer(data)): + try: + der = base64.b64decode(b''.join(match.group(1).split()), validate=True) + cert = parse_certificate(der) + except (binascii.Error, ValueError) as error: + skipped.append(f'#{index + 1}: unreadable ({error})') + continue + if cert['sha1'] in seen: + continue + seen.add(cert['sha1']) + if cert['subject'] != cert['issuer']: + skipped.append(f'{cert["sha1"]}: not self-issued, not a root') + continue + # The keychain keys certificates by issuer and serial; a second one is a duplicate item. + identity = (cert['issuer'], cert['serial']) + if identity in identities: + skipped.append(f'{cert["sha1"]}: same issuer and serial as {identities[identity]}') + continue + identities[identity] = cert['sha1'] + roots.append(cert) + for line in skipped: + report(f'Root certificates: skipped {line}') + return roots + + +def trust_settings(roots, now): + # An empty trustSettings array means "trusted as root for every use" + # (TrustSettings::evaluateCert / qualifyUsageWithCertDict, numSpecs == 0). + return plistlib.dumps({ + 'trustVersion': 1, + 'trustList': {cert['sha1']: {'issuerName': cert['issuer'], 'serialNumber': cert['serial'], + 'modDate': now, 'trustSettings': []} for cert in roots}, + }, fmt=plistlib.FMT_XML, sort_keys=True) + + +def bundle_digest(data): + return hashlib.sha256(b'darling-root-certificates-v%d\0' % VERSION + data).hexdigest() + + +def current(folder, digest): + try: + stamp = json.loads((folder / STAMP).read_text()) + except (OSError, ValueError): + return False + return (stamp.get('digest') == digest and (folder / KEYCHAIN).is_file() + and (folder / TRUST_SETTINGS).is_file()) + + +def server_running(prefix): + try: + pid = int((prefix / '.init.pid').read_text().strip()) + status = (Path('/proc') / str(pid) / 'status').read_text() + except (OSError, ValueError): + return False + return not any(line.split()[1:2] == ['Z'] for line in status.splitlines() if line.startswith('State:')) + + +def publish_host(work, folder): + folder.mkdir(parents=True, exist_ok=True) + for name in (KEYCHAIN, TRUST_SETTINGS): + temporary = folder / f'.{name}.next' + shutil.copyfile(work / name, temporary) + os.chmod(temporary, 0o644) + os.replace(temporary, folder / name) + + +def write_stamp(folder, digest, bundle, roots, present): + temporary = folder / (STAMP + '.next') + temporary.write_text(json.dumps({'version': VERSION, 'digest': digest, 'bundle': str(bundle), + 'roots': len(roots), 'in_keychain': len(present)}, indent=1) + '\n') + os.replace(temporary, folder / STAMP) + + +def ensure(prefix, bundle, darling, force=False, timeout=600): + data = bundle.read_bytes() + digest = bundle_digest(data) + folder = prefix / 'System/Library/Keychains' + if not force and current(folder, digest): + return 0 + roots = load_roots(data) + if not roots: + raise Failure(f'no root certificates in {bundle}') + now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0, tzinfo=None) + work = Path(tempfile.mkdtemp(prefix='darling-root-certificates-')).resolve() + keep = True + try: + (work / 'certs').mkdir() + for index, cert in enumerate(roots): + (work / 'certs' / f'{index:04d}.cer').write_bytes(cert['der']) + (work / TRUST_SETTINGS).write_bytes(trust_settings(roots, now)) + script = work / 'build.sh' + script.write_text(GUEST_SCRIPT) + # Before Darling starts, the overlay picks up a host-created directory, which + # lets the guest publish into it. + running = server_running(prefix) + if not running: + folder.mkdir(parents=True, exist_ok=True) + print(f'Building Darling system root store from {len(roots)} certificates in {bundle}', flush=True) + try: + with open(work / 'guest.log', 'wb') as log: + result = subprocess.run([*darling, 'shell', '/bin/sh', SYSTEM_ROOT + str(script), + SYSTEM_ROOT + str(work)], stdin=subprocess.DEVNULL, + stdout=log, stderr=subprocess.STDOUT, timeout=timeout) + except (OSError, subprocess.SubprocessError) as error: + raise Failure(f'could not run the guest security tool: {error}; work files kept in {work}') + if result.returncode or not (work / 'found.txt').is_file(): + raise Failure(f'guest keychain build failed (exit {result.returncode}); see {work}/guest.log') + found = set(re.findall(r'SHA-1 hash:\s*([0-9A-Fa-f]{40})', (work / 'found.txt').read_text(errors='replace'))) + found = {value.upper() for value in found} + wanted = {cert['sha1'] for cert in roots} + present = wanted & found + if not present: + raise Failure(f'the guest keychain holds none of the certificates; see {work}') + if present != wanted: + print(f'Root certificates: the guest rejected {len(wanted - present)} of {len(wanted)}; ' + f'see {work}/add-errors.txt', file=sys.stderr, flush=True) + # Kept for inspection; the outputs are still published. + else: + keep = False + if not (work / 'placed').exists(): + # A running guest could not see a new directory; publish on the host for + # the next Darling start. + publish_host(work, folder) + if running: + print('Darling system root store installed; it takes effect after `darling shutdown`', + file=sys.stderr, flush=True) + write_stamp(folder, digest, bundle, roots, present) + print(f'Darling system root store has {len(present)} certificates', flush=True) + return 0 + except OSError as error: + raise Failure(f'{error}; work files kept in {work}') + finally: + if not keep: + shutil.rmtree(work, ignore_errors=True) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) + parser.add_argument('--bundle', type=Path, help='PEM CA bundle (default: the host system bundle)') + parser.add_argument('--prefix', type=Path, default=Path(os.environ.get('DPREFIX', str(Path.home() / '.darling')))) + parser.add_argument('--darling', default='darling', help='Darling command (for tests)') + parser.add_argument('--force', action='store_true', help='rebuild even when the stamp is current') + args = parser.parse_args(argv) + try: + return ensure(args.prefix.expanduser(), args.bundle or default_bundle(), args.darling.split(), args.force) + except (Failure, OSError) as error: + print(f'Darling system root certificates were not installed: {error}', file=sys.stderr, flush=True) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/riot-launcher-offline-check.py b/tools/riot-launcher-offline-check.py index 248444d..717f09a 100644 --- a/tools/riot-launcher-offline-check.py +++ b/tools/riot-launcher-offline-check.py @@ -60,7 +60,8 @@ with open({str(log)!r}, "a") as output: records = [json.loads(line) for line in log.read_text().splitlines()] maintenance = [r for r in records if r["command"] == "python3"] assert [Path(r["args"][0]).name for r in maintenance] == [ - "ensure-darling-runtime.py", "ensure-darling-service-accounts.py", "ensure-league-runtime-layout.py" + "ensure-darling-runtime.py", "ensure-darling-service-accounts.py", "ensure-league-runtime-layout.py", + "ensure-darling-root-certificates.py", ], "unexpected automatic helper" launches = [r for r in records if r["command"] == "darling"] assert len(launches) == 1, "unexpected launch count" diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 74b480c..5789d78 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -11,6 +11,11 @@ runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) python3 "$runner_dir/ensure-darling-runtime.py" python3 "$runner_dir/ensure-darling-service-accounts.py" python3 "$runner_dir/ensure-league-runtime-layout.py" +# Security's system trust domain (SecTrustSettingsCopyCertificates) needs a root keychain and +# trust record built from the host CA bundle. Only a changed bundle starts Darling; a failure +# is reported but does not stop the launch. +python3 "$runner_dir/ensure-darling-root-certificates.py" || + echo 'Continuing without a Darling system root store; TLS clients may report untrusted roots' >&2 # Discard argument-rewriting settings from the former injected shim. unset RCLOL_APP_ARGS RCLOL_APP_ARGS_SLOT RCLOL_APP_ARG2 RCLOL_APP_ARG2_SLOT unset RCLOL_APP_ARG3 RCLOL_APP_ARG3_SLOT RCLOL_UX_ARG1 RCLOL_UX_ARG1_SLOT From 2e201dd19b0a0a1b5bfb4a375ddab7cafb1cf3c1 Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Sat, 17 Oct 2026 22:09:29 +0200 Subject: [PATCH 09/23] fix(setup): use consistent runtime paths and reject incomplete installs --- docs/SETUP.md | 17 +- scripts/configure.py | 7 +- scripts/install-runtime.sh | 10 +- tests/test_setup_portability.py | 222 +++++++++++++++++++++ tools/check-darwin-tlv-calls.py | 3 + tools/install-darling-system-profiler.py | 6 +- tools/install-riot-appkit-runtime.sh | 2 +- tools/install-riot-carboncore-runtime.sh | 4 +- tools/install-riot-foundation-runtime.py | 9 +- tools/install-riot-gpu-runtime.sh | 27 +-- tools/install-riot-libs-runtime.py | 15 +- tools/install-riot-memory-runtime.py | 9 +- tools/install-riot-presentation-runtime.sh | 14 +- tools/riot-launcher-offline-check.py | 6 +- tools/run-riot-client.sh | 16 +- tools/runtime-paths.sh | 18 ++ tools/runtime_paths.py | 24 +++ tools/validate-riot-runtime.py | 78 ++++++++ 18 files changed, 439 insertions(+), 48 deletions(-) create mode 100644 tests/test_setup_portability.py create mode 100644 tools/runtime-paths.sh create mode 100644 tools/runtime_paths.py create mode 100644 tools/validate-riot-runtime.py diff --git a/docs/SETUP.md b/docs/SETUP.md index 1b5a89a..10d22b9 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -12,7 +12,22 @@ python3 scripts/configure.py --guest-user my-linux-user python3 scripts/configure.py --sdk /usr/local/libexec/darling ``` -The normal wrappers currently support the default `~/.darling` prefix and `/usr/local` base installation. The configurator exposes a custom prefix for developers, but several archived tools assume the default layout. Custom prefixes/install locations have not been tested end to end. +The runtime installation wrappers resolve `DPREFIX` first, otherwise the saved +`configure.py --prefix` setting. The configured guest account supplies the +default guest home. `RIOT_GUEST_HOME`, when set, is an absolute **host** path +inside the selected prefix, not a guest `/Users/...` path. Installers, runtime +validation and the Riot launcher's guest library paths use that selection. +Paths containing spaces are supported by isolated regression fixtures. Explicit +environment overrides win over saved configuration; clear stale values before +selecting a different installation. + +A complete real installation using a custom prefix has not been verified. +Archived tools may still assume the default layout. Runtime validation fails on +missing expected binaries, unreadable/missing directories or checker errors; +an empty input set is not a successful validation. + +Runtime installation requires the selected runtime to stop before overlay +replacement. Failed shutdown blocks replacement. The build uses Unix Makefiles because the incremental runtime helpers read CMake `flags.make` and `link.txt`. Ninja is not a drop-in replacement for those helpers. diff --git a/scripts/configure.py b/scripts/configure.py index 3ea8d2f..7fcce7b 100755 --- a/scripts/configure.py +++ b/scripts/configure.py @@ -17,7 +17,12 @@ build=ROOT/'build';build.mkdir(exist_ok=True) folder=build/'runtime/tools';folder.mkdir(parents=True,exist_ok=True) for source in (ROOT/'tools').iterdir(): if not source.is_file():continue - target=folder/source.name;target.write_text(render(source.read_text(),c));os.chmod(target,source.stat().st_mode & 0o777) + target=folder/source.name + # Unlink the directory entry, including dangling symlinks. Any other error + # must abort rather than following a leftover link or writing through it. + target.unlink(missing_ok=True) + target.write_text(render(source.read_text(),c)) + os.chmod(target,source.stat().st_mode & 0o777) link=folder.parent/'build' if not link.exists():link.symlink_to(build,target_is_directory=True) print('Configured',len(list(folder.iterdir())),'runtime sources for',c['guest_home']) diff --git a/scripts/install-runtime.sh b/scripts/install-runtime.sh index ad26e21..4bb394f 100755 --- a/scripts/install-runtime.sh +++ b/scripts/install-runtime.sh @@ -4,6 +4,7 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" python3 scripts/configure.py +. build/runtime/tools/runtime-paths.sh if [ -d build/venv/bin ]; then PATH="$repo/build/venv/bin:$PATH"; export PATH; fi # A server killed by the base install leaves a stale .init.pid that makes darling shell fail. python3 build/runtime/tools/ensure-darling-runtime.py @@ -11,8 +12,9 @@ python3 build/runtime/tools/ensure-darling-runtime.py darling shell /usr/bin/true # The prefix has no /private/tmp of its own, so the guest falls through to the root-owned # installed layer and every write there (the Riot installer's temp files) fails with EACCES. -mkdir -p "$HOME/.darling/private/tmp" -chmod 1777 "$HOME/.darling/private/tmp" +prefix=$prefix_dir +mkdir -p "$prefix/private/tmp" +chmod 1777 "$prefix/private/tmp" python3 build/runtime/tools/stop-darling-runtime.py sh build/runtime/tools/install-riot-presentation-runtime.sh sh build/runtime/tools/install-riot-gpu-runtime.sh @@ -23,6 +25,4 @@ if [ -f build/runtime/tools/build-riot-libs-runtime.sh ]; then fi python3 build/runtime/tools/install-darling-system-profiler.py # Every guest process loads these overlays; refuse a set the compiler broke (check-darwin-tlv-calls.py). -find "$HOME/.darling/Users/$(id -un)/RuntimeLibraries" "$HOME/.darling/Users/$(id -un)/RuntimeFrameworks" \ - -type f ! -name '*.before-*' ! -name '*.rt-*' ! -name '*.llvm*' -size +4k -print0 | - xargs -0 python3 build/runtime/tools/check-darwin-tlv-calls.py +python3 build/runtime/tools/validate-riot-runtime.py diff --git a/tests/test_setup_portability.py b/tests/test_setup_portability.py new file mode 100644 index 0000000..47ded2b --- /dev/null +++ b/tests/test_setup_portability.py @@ -0,0 +1,222 @@ +"""Filesystem/subprocess fixtures: never invoke a host runtime or compiler.""" +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] + + +VALIDATION_INPUTS = ( + 'RuntimeFrameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib', + 'libDarlingXShm.dylib', + *(f'RuntimeLibraries/{name}.dylib' for name in ( + 'libsystem_kernel', 'libsystem_c', 'libsystem_platform', 'libsystem_malloc', + 'libsystem_pthread', 'libobjc.A', 'libc++abi', 'libc++.1', + 'libGL', 'libindium', 'libiridium')), + *(f'RuntimeFrameworks/{name}.framework/Versions/{version}/{name}' for name, version in ( + ('AppKit', 'C'), ('QuartzCore', 'A'), ('CoreFoundation', 'A'), + ('SystemConfiguration', 'A'), ('CFNetwork', 'A'), ('IOSurface', 'A'), + ('OpenGL', 'A'), ('CoreVideo', 'A'), ('CoreAudio', 'A'), ('AudioToolbox', 'A'), + ('CoreGraphics', 'A'), ('CoreText', 'A'), ('Metal', 'A'), ('CarbonCore', 'A'), + ('LaunchServices', 'A'))), + 'RuntimeFrameworks/AppKit.framework/Versions/C/Resources/Backends/X11.backend/Contents/MacOS/X11', + 'RuntimeFrameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib', + 'Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component/Contents/MacOS/CoreAudio', +) + + +class SetupFixture(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='setup fixture ', dir=os.environ.get('TMPDIR')) + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) / 'project space' + self.root.mkdir() + for name in ('scripts', 'tools'): + shutil.copytree(ROOT / name, self.root / name) + self.home = Path(self.tmp.name) / 'host home' + self.home.mkdir() + self.prefix = Path(self.tmp.name) / 'custom prefix' + self.guest = self.prefix / 'Users/Guest Account' + self.bin = Path(self.tmp.name) / 'bin' + self.bin.mkdir() + self.env = dict(os.environ, HOME=str(self.home), TMPDIR=self.tmp.name, + DPREFIX=str(self.prefix), PYTHONDONTWRITEBYTECODE='1', + PATH=str(self.bin) + ':' + os.environ['PATH']) + self.config = dict(project_root=str(self.root), host_home=str(self.home), + prefix=str(self.prefix), guest_user='Guest Account', + guest_home='/Users/Guest Account', sdk=str(self.root / 'sdk'), jobs=1) + (self.root / 'build').mkdir() + (self.root / 'build/config.json').write_text(json.dumps(self.config)) + self.log = Path(self.tmp.name) / 'events' + self.env['FIXTURE_LOG'] = str(self.log) + + + def run_script(self, path, *args): + return subprocess.run([sys.executable if path.endswith('.py') else 'sh', + str(self.root / path), *args], env=self.env, + text=True, capture_output=True, timeout=20) + + + def configure(self): + result = self.run_script('scripts/configure.py') + self.assertEqual(result.returncode, 0, result.stderr) + + + def stub(self, name, body): + path = self.bin / name + path.write_text('#!/bin/sh\nset -eu\n' + body + '\n') + path.chmod(0o755) + + + def runtime_stubs(self): + # Nested Python is a strict allowlist: no accidental live helper execution. + self.stub('python3', '''printf 'python %s\\n' "$*" >> "$FIXTURE_LOG" +case "$1" in + -|*runtime_paths.py) exec REAL_PYTHON "$@";; + *stop-darling-runtime.py) [ "${STOP_FAIL:-0}" = 0 ] || exit 9; printf stopped\\n >> "$FIXTURE_LOG";; + *build-*.py|*ensure-darling-runtime.py|*install-darling-system-profiler.py|*install-riot-*.py) :;; + *) exit 90;; +esac'''.replace('REAL_PYTHON', sys.executable)) + self.stub('darling', '''printf 'darling %s\\n' "$*" >> "$FIXTURE_LOG" +if [ "$1" = shutdown ]; then sleep "${SHUTDOWN_DELAY:-0}"; exit "${SHUTDOWN_FAIL:-0}"; fi''') + self.stub('clang', '''last=; for arg do if [ "$last" = -o ]; then printf binary > "$arg"; fi; last=$arg; done''') + self.stub('sudo', 'exit 1') + self.stub('make', ':') + self.stub('cc', 'exec clang "$@"') + self.stub('pkg-config', ':') + self.stub('install', '''case "$(cat "$FIXTURE_LOG")" in *stopped*) :;; *) exit 88;; esac +exec REAL_INSTALL "$@"'''.replace('REAL_INSTALL', shutil.which('install'))) + + + def test_configure_replaces_readonly_and_repeats(self): + self.configure() + generated = self.root / 'build/runtime/tools/run-riot-client.sh' + generated.chmod(0o444) + self.configure() + self.configure() + self.assertTrue(generated.stat().st_mode & 0o200) + + + def test_configure_unlinks_dangling_symlink_without_writing_victim(self): + self.configure() + target = self.root / 'build/runtime/tools/run-riot-client.sh' + target.unlink() + victim = self.home / 'must not be created' + target.symlink_to(victim) + self.configure() + self.assertFalse(victim.exists(), 'configure followed the dangling generated-file symlink') + self.assertFalse(target.is_symlink()) + + + def test_presentation_creates_prerequisites_and_repeat_install(self): + self.configure() + self.runtime_stubs() + self.presentation_inputs() + for _ in range(2): + result = self.run_script('build/runtime/tools/install-riot-presentation-runtime.sh') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((self.guest / 'libDarlingXShm.dylib').is_file()) + self.assertTrue((self.guest / 'RuntimeLibraries/libsystem_kernel.dylib').is_file()) + self.assertIn('stopped', self.log.read_text()) + + + def presentation_inputs(self): + for name in ('external/cocotron/AppKit/AppKit', 'external/cocotron/AppKit/X11', + 'external/cocotron/QuartzCore/QuartzCore', + 'external/corefoundation/CoreFoundation', + 'external/configd/SystemConfiguration.fproj/SystemConfiguration', + 'external/cfnetwork/src/CFNetwork', + 'external/libplatform/libsystem_platform.dylib', + 'external/xnu/darling/src/libsystem_kernel/libsystem_kernel.dylib'): + path = self.root / 'build/darling-src/build/src' / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('library') + for framework, version in (('AppKit', 'C'), ('QuartzCore', 'A'), + ('CoreFoundation', 'A'), ('SystemConfiguration', 'A'), + ('CFNetwork', 'A')): + path = self.root / 'sdk/System/Library/Frameworks' / (framework + '.framework') + binary = path / 'Versions' / version / framework + binary.parent.mkdir(parents=True) + binary.write_text('sdk binary') + binary.chmod(0o555) + (path / 'Versions/Current').symlink_to(version) + (path / framework).symlink_to('Versions/Current/' + framework) + if framework == 'AppKit': + backend = binary.parent / 'Resources/Backends/X11.backend/Contents/MacOS/X11' + backend.parent.mkdir(parents=True) + backend.write_text('sdk backend') + p = self.root / 'build/libcxx-install/lib' + p.mkdir(parents=True) + (p / 'libc++.1.0.dylib').write_text('library') + + + def prepare_validation(self): + self.configure() + for relative in VALIDATION_INPUTS: + path = self.guest / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'\xcf\xfa\xed\xfe' + b'\0' * 5000) + self.stub('llvm-objdump', '''printf '%s\\n' "$*" >> "$FIXTURE_LOG" +exit "${CHECK_FAIL:-0}"''') + + + def test_launcher_selects_paths_before_prefix_helpers(self): + source = (ROOT / 'tools/run-riot-client.sh').read_text() + self.assertLess(source.index('runtime-paths.sh'), source.index('ensure-darling-runtime.py')) + + def test_paths_reject_symlink_escape(self): + self.configure() + outside = self.home / 'outside' + outside.mkdir() + self.prefix.mkdir(parents=True, exist_ok=True) + (self.prefix / 'escape').symlink_to(outside, target_is_directory=True) + self.env['RIOT_GUEST_HOME'] = str(self.prefix / 'escape/new-user') + result = self.run_script('build/runtime/tools/validate-riot-runtime.py') + self.assertNotEqual(result.returncode, 0) + self.assertIn('inside DPREFIX', result.stderr) + result = self.run_script('build/runtime/tools/runtime-paths.sh') + self.assertNotEqual(result.returncode, 0) + + def test_validation_missing_empty_and_checker_failure(self): + self.prepare_validation() + self.env['CHECK_FAIL'] = '1' + self.assertNotEqual(self.run_script('build/runtime/tools/validate-riot-runtime.py').returncode, 0) + self.env.pop('CHECK_FAIL') + vdsp = self.guest / 'RuntimeFrameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib' + vdsp.unlink(missing_ok=True) + self.assertNotEqual(self.run_script('build/runtime/tools/validate-riot-runtime.py').returncode, 0) + shutil.rmtree(self.guest) + self.assertNotEqual(self.run_script('build/runtime/tools/validate-riot-runtime.py').returncode, 0) + + def test_validation_uses_host_guest_override(self): + self.prepare_validation() + override = self.prefix / 'Users/Override Guest' + self.guest.rename(override) + self.env['RIOT_GUEST_HOME'] = str(override) + result = self.run_script('build/runtime/tools/validate-riot-runtime.py') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(str(override), self.log.read_text()) + + + def test_failed_stop_with_all_compile_outputs_available(self): + self.configure() + self.runtime_stubs() + self.presentation_inputs() + self.guest.mkdir(parents=True) + self.env['RIOT_GUEST_HOME'] = str(self.guest) + self.env.update(STOP_FAIL='1', SHUTDOWN_FAIL='1') + result = self.run_script('build/runtime/tools/install-riot-presentation-runtime.sh') + self.assertNotEqual(result.returncode, 0) + self.assertIn('stop-darling-runtime.py', self.log.read_text()) + self.assertFalse((self.guest / 'libDarlingXShm.dylib').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/check-darwin-tlv-calls.py b/tools/check-darwin-tlv-calls.py index c23a4f2..74cca28 100644 --- a/tools/check-darwin-tlv-calls.py +++ b/tools/check-darwin-tlv-calls.py @@ -51,6 +51,9 @@ def bad_accesses(path): return found +if len(sys.argv) < 2: + sys.exit('No runtime binaries supplied for validation') + status = 0 for binary in sys.argv[1:]: problems = bad_accesses(binary) diff --git a/tools/install-darling-system-profiler.py b/tools/install-darling-system-profiler.py index c6b6fbe..9ba07b9 100644 --- a/tools/install-darling-system-profiler.py +++ b/tools/install-darling-system-profiler.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 """Install the host-backed system_profiler into the Darling prefix (no sudo).""" -import os +import json from pathlib import Path import shutil +from runtime_paths import runtime_paths + root = Path(__file__).resolve().parent -prefix = Path(os.environ.get("DPREFIX", str(Path.home() / ".darling"))) +prefix, _ = runtime_paths(json.loads((root.parent / "build/config.json").read_text())) target = prefix / "usr/sbin/system_profiler" target.parent.mkdir(parents=True, exist_ok=True) staged = target.with_name(target.name + ".new") diff --git a/tools/install-riot-appkit-runtime.sh b/tools/install-riot-appkit-runtime.sh index 18ecc50..376af0c 100755 --- a/tools/install-riot-appkit-runtime.sh +++ b/tools/install-riot-appkit-runtime.sh @@ -3,7 +3,7 @@ # Manually exit the game and both clients, then shut down Darling before use. set -eu project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -guest_home=${RIOT_GUEST_HOME:-$HOME/.darling@GUEST_HOME@} +. "$project_dir/tools/runtime-paths.sh" framework="$guest_home/RuntimeFrameworks/AppKit.framework/Versions/C" backup_dir=${RIOT_APPKIT_BACKUP_DIR:-$project_dir/build/appkit-runtime-backups} # Preflight both artifacts and destinations before replacing either binary. diff --git a/tools/install-riot-carboncore-runtime.sh b/tools/install-riot-carboncore-runtime.sh index de9d6b0..f15cdcc 100755 --- a/tools/install-riot-carboncore-runtime.sh +++ b/tools/install-riot-carboncore-runtime.sh @@ -3,8 +3,7 @@ # Exit the game and both clients, then run darling shutdown before this script. set -eu project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -prefix_dir=${DPREFIX:-@DPREFIX@} -guest_home=${RIOT_GUEST_HOME:-$prefix_dir@GUEST_HOME@} +. "$project_dir/tools/runtime-paths.sh" artifact=${RIOT_CARBONCORE_ARTIFACT:-$project_dir/build/riot-CarbonCore} backup_dir=${RIOT_CARBONCORE_BACKUP_DIR:-$project_dir/build/carboncore-runtime-backups} sdk_framework="@DARLING_SDK@/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/CarbonCore.framework" @@ -12,6 +11,7 @@ framework="$guest_home/RuntimeFrameworks/CarbonCore.framework" prefix_binary="$prefix_dir/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/CarbonCore.framework/Versions/A/CarbonCore" [ -f "$artifact" ] || { echo "Build CarbonCore first: $artifact" >&2; exit 1; } [ -f "$sdk_framework/Versions/A/CarbonCore" ] || { echo "Missing SDK CarbonCore framework" >&2; exit 1; } +python3 "$project_dir/tools/stop-darling-runtime.py" mkdir -p "$guest_home/RuntimeFrameworks" "$backup_dir" if [ ! -d "$framework" ]; then cp -a "$sdk_framework" "$framework" diff --git a/tools/install-riot-foundation-runtime.py b/tools/install-riot-foundation-runtime.py index 4f803ed..2b5a2b1 100644 --- a/tools/install-riot-foundation-runtime.py +++ b/tools/install-riot-foundation-runtime.py @@ -17,7 +17,9 @@ import shutil import stat import tempfile -ROOT = Path(__file__).resolve().parent.parent +from runtime_paths import runtime_paths + +ROOT = Path(__file__).resolve().parents[1] DEFAULT_ARTIFACT = ROOT / "build/riot-Foundation" @@ -110,9 +112,8 @@ def main(): parser.exit(1, "The library to install does not exist: " + str(args.binary) + "; build it first: " "python3 build/runtime/tools/build-riot-foundation-runtime.py\n") config = json.loads((ROOT / "build/config.json").read_text()) - prefix = Path(config["prefix"]) - guest_home = config["guest_home"].lstrip("/") - framework = prefix / guest_home / "RuntimeFrameworks/Foundation.framework" + _, guest_home = runtime_paths(config) + framework = guest_home / "RuntimeFrameworks/Foundation.framework" sdk_framework = Path(config["sdk"]) / "System/Library/Frameworks/Foundation.framework" if not sdk_framework.exists(): parser.exit(1, "The SDK framework does not exist: " + str(sdk_framework) + "\n") diff --git a/tools/install-riot-gpu-runtime.sh b/tools/install-riot-gpu-runtime.sh index d7baf18..03057bd 100755 --- a/tools/install-riot-gpu-runtime.sh +++ b/tools/install-riot-gpu-runtime.sh @@ -2,8 +2,9 @@ # Build/install the paired GPU, NV12 and display-clock runtime overrides. set -eu project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -guest_home=${RIOT_GUEST_HOME:-$HOME/.darling@GUEST_HOME@} +. "$project_dir/tools/runtime-paths.sh" cd "$project_dir" +mkdir -p build python3 tools/build-m58f333-iosurface.py # Rebuild the native wrapper with floating-point-safe symbol resolvers. make -C build/darling-src/build/src/native GL -j4 @@ -31,6 +32,7 @@ python3 tools/build-riot-server-runtime.py build/riot-host-message-check cc -shared -fPIC -O2 -Wall -Wextra -Wl,-soname,"$project_dir/build/libdarling-xshm.so" tools/m58f299-xshm.c -o build/libdarling-xshm.so.next -pthread $(pkg-config --cflags --libs x11 x11-xcb xcb-shm xext) python3 tools/stop-darling-runtime.py +mkdir -p "$guest_home/RuntimeFrameworks" # Host fileports own SCM_RIGHTS descriptors and must die with their last sender. if sudo -n true 2>/dev/null; then sudo make -C "$project_dir/build/darling-src/build/src/external/darlingserver" install -j12 @@ -70,7 +72,7 @@ mkdir -p "$guest_home/RuntimeLibraries" gl_wrapper="$guest_home/RuntimeLibraries/libGL.dylib" if [ ! -f "$gl_wrapper.before-float-resolver" ]; then if [ -f "$gl_wrapper" ]; then cp -p "$gl_wrapper" "$gl_wrapper.before-float-resolver" - else cp -p @DARLING_SDK@/System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib "$gl_wrapper.before-float-resolver"; fi + else cp -p "@DARLING_SDK@"/System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib "$gl_wrapper.before-float-resolver"; fi fi install -m755 build/darling-src/build/src/native/libGL.dylib "$gl_wrapper.next" mv "$gl_wrapper.next" "$gl_wrapper" @@ -78,7 +80,7 @@ install -m755 build/darling-src/build/src/native/libGL.dylib "$guest_home/Runtim mv "$guest_home/RuntimeFrameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib.next" "$guest_home/RuntimeFrameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib" launchservices="$guest_home/RuntimeFrameworks/LaunchServices.framework" if [ ! -d "$launchservices" ]; then - cp -a @DARLING_SDK@/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework "$launchservices" + cp -a "@DARLING_SDK@"/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework "$launchservices" fi launchservices_binary="$launchservices/Versions/A/LaunchServices" if [ ! -f "$launchservices_binary.before-null-error" ]; then cp -p "$launchservices_binary" "$launchservices_binary.before-null-error"; fi @@ -86,12 +88,11 @@ install -m755 build/darling-src/build/src/frameworks/CoreServices/src/LaunchServ mv "$launchservices_binary.next" "$launchservices_binary" # Electron opens this subframework by its absolute path, bypassing the # framework search override. Replace the same binary in the stopped prefix. -prefix_dir="${DPREFIX:-$HOME/.darling}" launchservices_prefix="$prefix_dir/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/LaunchServices" mkdir -p "$(dirname "$launchservices_prefix")" if [ ! -f "$launchservices_prefix.before-null-error" ]; then if [ -f "$launchservices_prefix" ]; then cp -p "$launchservices_prefix" "$launchservices_prefix.before-null-error" - else cp -p @DARLING_SDK@/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/LaunchServices "$launchservices_prefix.before-null-error"; fi + else cp -p "@DARLING_SDK@"/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/LaunchServices "$launchservices_prefix.before-null-error"; fi fi install -m755 build/darling-src/build/src/frameworks/CoreServices/src/LaunchServices/LaunchServices "$launchservices_prefix.next" mv "$launchservices_prefix.next" "$launchservices_prefix" @@ -100,7 +101,7 @@ vdsp_prefix="$prefix_dir/System/Library/Frameworks/Accelerate.framework/Versions mkdir -p "$(dirname "$vdsp_prefix")" if [ ! -f "$vdsp_prefix.before-vdsp" ]; then if [ -f "$vdsp_prefix" ]; then cp -p "$vdsp_prefix" "$vdsp_prefix.before-vdsp" - else cp -p @DARLING_SDK@/System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib "$vdsp_prefix.before-vdsp"; fi + else cp -p "@DARLING_SDK@"/System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib "$vdsp_prefix.before-vdsp"; fi fi install -m755 build/darling-src/build/src/frameworks/Accelerate/vecLib/vDSP/libvDSP.dylib "$vdsp_prefix.next" mv "$vdsp_prefix.next" "$vdsp_prefix" @@ -108,7 +109,7 @@ mv "$vdsp_prefix.next" "$vdsp_prefix" component="$guest_home/Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component" if [ ! -d "$component" ]; then mkdir -p "$(dirname "$component")" - cp -a @DARLING_SDK@/System/Library/Components/CoreAudio.component "$component" + cp -a "@DARLING_SDK@"/System/Library/Components/CoreAudio.component "$component" fi component_binary="$component/Contents/MacOS/CoreAudio" if [ ! -f "$component_binary.before-gpu-direct" ]; then cp -p "$component_binary" "$component_binary.before-gpu-direct"; fi @@ -139,7 +140,7 @@ mv "$kernel.next" "$kernel" allocator="$guest_home/RuntimeLibraries/libsystem_malloc.dylib" if [ ! -f "$allocator.before-gpu-direct" ]; then if [ -f "$allocator" ]; then cp -p "$allocator" "$allocator.before-gpu-direct" - else cp -p @DARLING_SDK@/usr/lib/system/libsystem_malloc.dylib "$allocator.before-gpu-direct"; fi + else cp -p "@DARLING_SDK@"/usr/lib/system/libsystem_malloc.dylib "$allocator.before-gpu-direct"; fi fi install -m755 build/riot-libsystem_malloc.dylib "$allocator.next" mv "$allocator.next" "$allocator" @@ -149,7 +150,7 @@ mv build/libdarling-xshm.so.next build/libdarling-xshm.so objc_library="$guest_home/RuntimeLibraries/libobjc.A.dylib" if [ ! -f "$objc_library.before-m761" ]; then if [ -f "$objc_library" ]; then cp -p "$objc_library" "$objc_library.before-m761" - else cp -p @DARLING_SDK@/usr/lib/libobjc.A.dylib "$objc_library.before-m761"; fi + else cp -p "@DARLING_SDK@"/usr/lib/libobjc.A.dylib "$objc_library.before-m761"; fi fi install -m755 build/riot-libobjc.A.dylib "$objc_library.next" mv "$objc_library.next" "$objc_library" @@ -157,7 +158,7 @@ mv "$objc_library.next" "$objc_library" cxxabi_library="$guest_home/RuntimeLibraries/libc++abi.dylib" if [ ! -f "$cxxabi_library.before-m765" ]; then if [ -f "$cxxabi_library" ]; then cp -p "$cxxabi_library" "$cxxabi_library.before-m765" - else cp -p @DARLING_SDK@/usr/lib/libc++abi.dylib "$cxxabi_library.before-m765"; fi + else cp -p "@DARLING_SDK@"/usr/lib/libc++abi.dylib "$cxxabi_library.before-m765"; fi fi install -m755 build/riot-libc++abi.dylib "$cxxabi_library.next" mv "$cxxabi_library.next" "$cxxabi_library" @@ -165,7 +166,7 @@ mv "$cxxabi_library.next" "$cxxabi_library" indium_library="$guest_home/RuntimeLibraries/libindium.dylib" if [ ! -f "$indium_library.before-m766" ]; then if [ -f "$indium_library" ]; then cp -p "$indium_library" "$indium_library.before-m766" - else cp -p @DARLING_SDK@/usr/lib/darling/libindium.dylib "$indium_library.before-m766"; fi + else cp -p "@DARLING_SDK@"/usr/lib/darling/libindium.dylib "$indium_library.before-m766"; fi fi install -m755 build/riot-libindium.dylib "$indium_library.next" mv "$indium_library.next" "$indium_library" @@ -174,7 +175,7 @@ mv "$indium_library.next" "$indium_library" iridium_library="$guest_home/RuntimeLibraries/libiridium.dylib" if [ ! -f "$iridium_library.before-m767" ]; then if [ -f "$iridium_library" ]; then cp -p "$iridium_library" "$iridium_library.before-m767" - else cp -p @DARLING_SDK@/usr/lib/darling/libiridium.dylib "$iridium_library.before-m767"; fi + else cp -p "@DARLING_SDK@"/usr/lib/darling/libiridium.dylib "$iridium_library.before-m767"; fi fi install -m755 build/riot-libiridium.dylib "$iridium_library.next" mv "$iridium_library.next" "$iridium_library" @@ -182,7 +183,7 @@ mv "$iridium_library.next" "$iridium_library" pthread_library="$guest_home/RuntimeLibraries/libsystem_pthread.dylib" if [ ! -f "$pthread_library.before-gpu-direct" ]; then if [ -f "$pthread_library" ]; then cp -p "$pthread_library" "$pthread_library.before-gpu-direct" - else cp -p @DARLING_SDK@/usr/lib/system/libsystem_pthread.dylib "$pthread_library.before-gpu-direct"; fi + else cp -p "@DARLING_SDK@"/usr/lib/system/libsystem_pthread.dylib "$pthread_library.before-gpu-direct"; fi fi install -m755 build/riot-libsystem_pthread.dylib "$pthread_library.next" mv "$pthread_library.next" "$pthread_library" diff --git a/tools/install-riot-libs-runtime.py b/tools/install-riot-libs-runtime.py index bf495d0..e50cbba 100644 --- a/tools/install-riot-libs-runtime.py +++ b/tools/install-riot-libs-runtime.py @@ -23,7 +23,9 @@ import shutil import stat import tempfile -ROOT = Path(__file__).resolve().parent.parent +from runtime_paths import runtime_paths + +ROOT = Path(__file__).resolve().parents[1] # (built artifact, file name in the guest's RuntimeLibraries directory). The # artifacts are the ones tools/build-riot-libs-runtime.sh builds. @@ -91,6 +93,7 @@ def install(libraries, backup_folder): else: shutil.copy2(destination, backup) metadata = destination.stat() if destination.is_file() else None + destination.parent.mkdir(parents=True, exist_ok=True) descriptor, temporary = tempfile.mkstemp(prefix="." + destination.name + "-staged-", dir=destination.parent) staged.append((Path(temporary), destination)) @@ -186,9 +189,13 @@ def main(argv=None): parser.exit(1, str(error) + "\n") return config = json.loads((ROOT / "build/config.json").read_text()) - prefix = arguments.prefix or config["prefix"] - guest_home = arguments.guest_home or config["guest_home"] - libraries = destinations(prefix, guest_home) + prefix, host_guest_home = runtime_paths(config) + prefix = arguments.prefix or prefix + if arguments.prefix and not os.environ.get("RIOT_GUEST_HOME"): + host_guest_home = prefix / config["guest_home"].lstrip("/") + if arguments.guest_home: + host_guest_home = prefix / arguments.guest_home.lstrip("/") + libraries = destinations(host_guest_home, "") if arguments.only: wanted = set(arguments.only.split(",")) unknown = wanted - {name for _, name in LIBRARIES} diff --git a/tools/install-riot-memory-runtime.py b/tools/install-riot-memory-runtime.py index c71e232..55f3fb4 100644 --- a/tools/install-riot-memory-runtime.py +++ b/tools/install-riot-memory-runtime.py @@ -10,7 +10,9 @@ import shutil import stat import tempfile -ROOT = Path(__file__).resolve().parent.parent +from runtime_paths import runtime_paths + +ROOT = Path(__file__).resolve().parents[1] # The optimized relink from tools/build-riot-kqueue-runtime.py is the library the # client runtime is supposed to run; the plain CMake build (no -O flag) is not. DEFAULT_ARTIFACT = ROOT / "build/riot-libsystem_kernel.dylib" @@ -84,10 +86,11 @@ def prefix_destinations(config, binary, artifact): install the artifact itself leaves it untouched. That is what keeps the optimized relink from being silently replaced by the plain CMake build. """ + prefix, guest_home = runtime_paths(config) destinations = [ - Path(config["prefix"]) / config["guest_home"].lstrip("/") / "RuntimeLibraries/libsystem_kernel.dylib", + guest_home / "RuntimeLibraries/libsystem_kernel.dylib", # Keep the per-prefix bootstrap copy consistent; leave the system SDK alone. - Path(config["prefix"]) / "usr/lib/system/libsystem_kernel.dylib", + prefix / "usr/lib/system/libsystem_kernel.dylib", ] if not artifact.exists(): return destinations, "the prepared artifact " + str(artifact) + " does not exist yet" diff --git a/tools/install-riot-presentation-runtime.sh b/tools/install-riot-presentation-runtime.sh index d0078f8..220f394 100644 --- a/tools/install-riot-presentation-runtime.sh +++ b/tools/install-riot-presentation-runtime.sh @@ -4,7 +4,8 @@ set -eu project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) source_dir="$project_dir/build/darling-src" build_dir="$source_dir/build" -guest_home="${RIOT_GUEST_HOME:-$HOME/.darling@GUEST_HOME@}" +. "$project_dir/tools/runtime-paths.sh" +mkdir -p "$project_dir/build" make -C "$build_dir/src/external/xnu/darling/src/libsystem_kernel" system_kernel -j8 make -C "$build_dir/src/external/libplatform" platform -j8 make -C "$build_dir/src/external/corefoundation" CoreFoundation_x86_64 -j8 @@ -17,14 +18,16 @@ cc -shared -fPIC -O3 -march=native -mtune=generic -Wall -Wextra \ "$project_dir/tools/m58f299-xshm.c" -o "$project_dir/build/libdarling-xshm.next.so" \ -pthread $(pkg-config --cflags --libs x11 x11-xcb xcb-shm xext) clang -target x86_64-apple-macosx11.0 -fuse-ld=lld -dynamiclib -nostdlib -O3 -march=native -mtune=generic \ - -isysroot @DARLING_SDK@ \ + -isysroot "@DARLING_SDK@" \ -I"$source_dir/src/startup/mldr/elfcalls" \ "-DDARLING_XSHM_HOST_LIBRARY=\"$project_dir/build/libdarling-xshm.so\"" \ - -Wl,-undefined,dynamic_lookup -Wl,-install_name,@GUEST_HOME@/libDarlingXShm.dylib \ - "$project_dir/tools/m58f299-native-wrapper.c" -o "$guest_home/libDarlingXShm.next.dylib" + -Wl,-undefined,dynamic_lookup "-Wl,-install_name,@GUEST_HOME@/libDarlingXShm.dylib" \ + "$project_dir/tools/m58f299-native-wrapper.c" -o "$project_dir/build/libDarlingXShm.next.dylib" # Stop before changing the overlay's upper directory, even with atomic writes. -darling shutdown || true +python3 "$project_dir/tools/stop-darling-runtime.py" +mkdir -p "$guest_home" mv "$project_dir/build/libdarling-xshm.next.so" "$project_dir/build/libdarling-xshm.so" +install -m755 "$project_dir/build/libDarlingXShm.next.dylib" "$guest_home/libDarlingXShm.next.dylib" mv "$guest_home/libDarlingXShm.next.dylib" "$guest_home/libDarlingXShm.dylib" mkdir -p "$guest_home/RuntimeFrameworks" for framework in AppKit QuartzCore CoreFoundation SystemConfiguration CFNetwork; do @@ -52,7 +55,6 @@ atomic_install "$build_dir/src/external/xnu/darling/src/libsystem_kernel/libsyst # Bootstrap services can start before the app's DYLD_LIBRARY_PATH is applied. # The prefix overlay is user-owned; install the same corrected kernel there. -prefix_dir="${DPREFIX:-$HOME/.darling}" mkdir -p "$prefix_dir/usr/lib/system" atomic_install "$build_dir/src/external/xnu/darling/src/libsystem_kernel/libsystem_kernel.dylib" "$prefix_dir/usr/lib/system/libsystem_kernel.dylib" diff --git a/tools/riot-launcher-offline-check.py b/tools/riot-launcher-offline-check.py index 248444d..1f08239 100644 --- a/tools/riot-launcher-offline-check.py +++ b/tools/riot-launcher-offline-check.py @@ -39,6 +39,10 @@ with open({str(log)!r}, "a") as output: prefix = folder / "prefix" (prefix / "Applications/Riot Client.app").mkdir(parents=True) env["DPREFIX"] = str(prefix) + env.pop("RIOT_GUEST_HOME", None) + (folder / "runtime-paths.sh").write_text( + (ROOT / "tools/runtime-paths.sh").read_text() + .replace("@DPREFIX@", str(prefix)).replace("@GUEST_HOME@", "/Users/offline-check")) env["DYLD_INSERT_LIBRARIES"] = "/Users/offline-check/zzlogdir.dylib:/tmp/inherited.dylib" env["RCLOL_APP_ARGS"] = "--inherited-shim-argument" env["RCLOL_APP_ARG2"] = "--inherited-shim-argument-2" @@ -60,7 +64,7 @@ with open({str(log)!r}, "a") as output: records = [json.loads(line) for line in log.read_text().splitlines()] maintenance = [r for r in records if r["command"] == "python3"] assert [Path(r["args"][0]).name for r in maintenance] == [ - "ensure-darling-runtime.py", "ensure-darling-service-accounts.py", "ensure-league-runtime-layout.py" + "runtime_paths.py", "ensure-darling-runtime.py", "ensure-darling-service-accounts.py", "ensure-league-runtime-layout.py" ], "unexpected automatic helper" launches = [r for r in records if r["command"] == "darling"] assert len(launches) == 1, "unexpected launch count" diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 74b480c..da66ec0 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -8,6 +8,7 @@ if [ "${RCLOL_CORE_DUMPS:-0}" != 1 ]; then ulimit -c 0 fi runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$runner_dir/runtime-paths.sh" python3 "$runner_dir/ensure-darling-runtime.py" python3 "$runner_dir/ensure-darling-service-accounts.py" python3 "$runner_dir/ensure-league-runtime-layout.py" @@ -21,7 +22,12 @@ for option in RCLOL_GPU_DIRECT_PRESENT RCLOL_GPU_DIRECT_ASYNC RCLOL_GPU_DIRECT_V eval 'value=${'"$option"'-1}' case "$value" in 0|'') unset "$option";; *) export "$option=$value";; esac done -prefix=${DPREFIX:-$HOME/.darling} +prefix=$prefix_dir +# RIOT_GUEST_HOME is host-side; DYLD paths must be relative to the guest root. +case "$guest_home" in + "$prefix"/*) guest_path=/${guest_home#"$prefix"/} ;; + *) echo "RIOT_GUEST_HOME must be inside DPREFIX" >&2; exit 1 ;; +esac # The first-time installer puts the client in /Users/Shared/Riot Games and then # hands off through LaunchServices, which does not start it under Darling. Link # the installed client where the normal launch expects it. @@ -85,9 +91,9 @@ exec env -u ELECTRON_RUN_AS_NODE -u DYLD_INSERT_LIBRARIES EGL_PLATFORM=x11 \ LANG="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" LC_ALL="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" \ mesa_glthread="${RCLOL_MESA_GLTHREAD-${mesa_glthread-false}}" \ XMODIFIERS="${RCLOL_XMODIFIERS:-@im=none}" DISPLAY="${DISPLAY:-:1}" \ - DYLD_LIBRARY_PATH="${DYLD_LIBRARY_PATH:-@GUEST_HOME@/RuntimeLibraries}" \ - DYLD_FRAMEWORK_PATH="${DYLD_FRAMEWORK_PATH:-@GUEST_HOME@/RuntimeFrameworks}" \ - DARLING_XSHM_LIBRARY="${DARLING_XSHM_LIBRARY:-@GUEST_HOME@/libDarlingXShm.dylib}" \ + DYLD_LIBRARY_PATH="${DYLD_LIBRARY_PATH:-$guest_path/RuntimeLibraries}" \ + DYLD_FRAMEWORK_PATH="${DYLD_FRAMEWORK_PATH:-$guest_path/RuntimeFrameworks}" \ + DARLING_XSHM_LIBRARY="${DARLING_XSHM_LIBRARY:-$guest_path/libDarlingXShm.dylib}" \ darling "$A" --region="${RCLOL_REGION:-EUW}" --locale="${RCLOL_LOCALE:-en_GB}" \ "$@" \ - --log-dir="@GUEST_HOME@/Library/Logs/Riot Games/Riot Client" + --log-dir="$guest_path/Library/Logs/Riot Games/Riot Client" diff --git a/tools/runtime-paths.sh b/tools/runtime-paths.sh new file mode 100644 index 0000000..74c40d0 --- /dev/null +++ b/tools/runtime-paths.sh @@ -0,0 +1,18 @@ +# Sourced by configured runtime installers; RIOT_GUEST_HOME is a host path. +# Keep these defaults aligned with runtime_paths.py. +prefix_dir=${DPREFIX:-"@DPREFIX@"} +guest_home=${RIOT_GUEST_HOME:-"$prefix_dir@GUEST_HOME@"} +case "$prefix_dir" in + /*) : ;; + *) echo "DPREFIX must be an absolute host path" >&2; exit 1 ;; +esac +prefix_dir=${prefix_dir%/} +case "$guest_home" in + "$prefix_dir"/*) : ;; + *) echo "RIOT_GUEST_HOME must be inside DPREFIX" >&2; exit 1 ;; +esac +case "$guest_home/" in + */../*|*/./*) echo "RIOT_GUEST_HOME must not contain dot components" >&2; exit 1 ;; +esac +export DPREFIX="$prefix_dir" RIOT_GUEST_HOME="$guest_home" +python3 "@PROJECT_ROOT@/build/runtime/tools/runtime_paths.py" "$prefix_dir" || exit 1 diff --git a/tools/runtime_paths.py b/tools/runtime_paths.py new file mode 100644 index 0000000..d50e4d4 --- /dev/null +++ b/tools/runtime_paths.py @@ -0,0 +1,24 @@ +"""Shared host-side destinations for configured runtime installers.""" +import os +from pathlib import Path + + +def runtime_paths(config): + prefix = Path(os.environ.get("DPREFIX") or config["prefix"]) + guest_home = Path(os.environ.get("RIOT_GUEST_HOME") or + str(prefix / config["guest_home"].lstrip("/"))) + if not prefix.is_absolute() or not guest_home.is_absolute(): + raise ValueError("Runtime paths must be absolute host paths") + if ".." in guest_home.parts or not guest_home.is_relative_to(prefix) or guest_home == prefix: + raise ValueError("RIOT_GUEST_HOME must be inside DPREFIX") + if not guest_home.resolve().is_relative_to(prefix.resolve()) or guest_home.resolve() == prefix.resolve(): + raise ValueError("RIOT_GUEST_HOME must resolve inside DPREFIX") + return prefix, guest_home + + +if __name__ == '__main__': + import sys + try: + runtime_paths({'prefix': sys.argv[1], 'guest_home': '/'},) + except (ValueError, OSError) as error: + raise SystemExit(str(error)) diff --git a/tools/validate-riot-runtime.py b/tools/validate-riot-runtime.py new file mode 100644 index 0000000..1e5da1a --- /dev/null +++ b/tools/validate-riot-runtime.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Validate installed overlays, refusing missing, empty or unreadable inputs.""" +import json +import os +from pathlib import Path +import subprocess +import sys + +from runtime_paths import runtime_paths + +ROOT = Path(__file__).resolve().parents[1] +REQUIRED = ( + 'RuntimeFrameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib', + 'libDarlingXShm.dylib', + *(f'RuntimeLibraries/{name}' for name in ( + 'libsystem_kernel.dylib', 'libsystem_c.dylib', 'libsystem_platform.dylib', + 'libsystem_malloc.dylib', 'libsystem_pthread.dylib', + 'libobjc.A.dylib', 'libc++abi.dylib', 'libc++.1.dylib', + 'libGL.dylib', 'libindium.dylib', 'libiridium.dylib')), + *(f'RuntimeFrameworks/{name}.framework/Versions/{version}/{name}' + for name, version in ( + ('AppKit', 'C'), ('QuartzCore', 'A'), ('CoreFoundation', 'A'), + ('SystemConfiguration', 'A'), ('CFNetwork', 'A'), ('IOSurface', 'A'), + ('OpenGL', 'A'), ('CoreVideo', 'A'), ('CoreAudio', 'A'), + ('AudioToolbox', 'A'), ('CoreGraphics', 'A'), ('CoreText', 'A'), + ('Metal', 'A'), ('CarbonCore', 'A'), ('LaunchServices', 'A'))), + 'RuntimeFrameworks/AppKit.framework/Versions/C/Resources/Backends/X11.backend/Contents/MacOS/X11', + 'RuntimeFrameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib', + 'Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component/Contents/MacOS/CoreAudio', +) +MACHO = (b'\xcf\xfa\xed\xfe', b'\xca\xfe\xba\xbe') + + +def inputs(guest_home): + def enumeration_error(error): + raise error + + def is_macho(path): + with path.open('rb') as source: + return source.read(4) in MACHO + + required = [guest_home / relative for relative in REQUIRED] + for path in required: + if not is_macho(path): + raise ValueError('Expected installed Mach-O runtime binary: ' + str(path)) + binaries = set(required) + for name in ('RuntimeLibraries', 'RuntimeFrameworks'): + folder = guest_home / name + if not folder.is_dir(): + raise ValueError('Missing runtime directory: ' + str(folder)) + found = [] + for directory, _, files in os.walk(folder, onerror=enumeration_error): + for name in files: + if any(marker in name for marker in ('.before-', '.rt-', '.llvm')): + continue + path = Path(directory) / name + if is_macho(path): + found.append(path) + if not found: + raise ValueError('No Mach-O runtime binaries found in ' + str(folder)) + binaries.update(found) + return sorted(binaries) + + +def main(): + try: + _, guest_home = runtime_paths(json.loads((ROOT / 'build/config.json').read_text())) + binaries = inputs(guest_home) + subprocess.run([sys.executable, str(ROOT / 'tools/check-darwin-tlv-calls.py'), + *map(str, binaries)], check=True) + except (OSError, ValueError, subprocess.CalledProcessError) as error: + print('Runtime validation failed: ' + str(error), file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) From 57b1df9e0b9d114ea976b6d88a04b710cf16d882 Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Sat, 17 Oct 2026 22:24:06 +0200 Subject: [PATCH 10/23] fix(setup): validate final runtime destinations --- docs/SETUP.md | 2 +- tests/test_setup_portability.py | 47 +++++++++++++++++++++++++----- tools/install-riot-libs-runtime.py | 15 ++++++---- tools/install-riot-metal-pair.sh | 2 +- tools/runtime_paths.py | 14 ++++++--- tools/validate-riot-runtime.py | 21 +++++++++---- 6 files changed, 77 insertions(+), 24 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 10d22b9..7c669a2 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -39,7 +39,7 @@ Linux 5.14 or newer is required for `MADV_POPULATE_WRITE`, used when Darwin's al The runtime installer requires native `pkg-config` entries for `x11`, `x11-xcb`, `xcb-shm` and `xext`, plus the base Darling SDK. -Run configuration, builds and the per-user runtime installation as your ordinary account. Use sudo only for the base CMake installation and the incremental server installer. Running the whole build or per-user install under sudo can create root-owned files and configure the wrong guest home. `scripts/install-runtime.sh` initializes the default user prefix and creates `~/.darling/private/tmp` with mode `1777`, so the installer can write guest temporary files. It then shuts down Darling before replacing the paired runtime: first exit the game and both clients manually. +Run configuration, builds and the per-user runtime installation as your ordinary account. Use sudo only for the base CMake installation and the incremental server installer. Running the whole build or per-user install under sudo can create root-owned files and configure the wrong guest home. `scripts/install-runtime.sh` initializes the selected user prefix and creates its `private/tmp` directory with mode `1777`, so the installer can write guest temporary files. It then shuts down Darling before replacing the paired runtime: first exit the game and both clients manually. The normal launcher starts neither an Electron inspector nor client API/UI helpers. It clears inherited `DYLD_INSERT_LIBRARIES`, retains Darling runtime library/framework overrides, supplies an absolute log directory to the launcher, and selects the League product. The log-directory/Chromium argument shim, empty-autofill hook and optional native VPX bridge are no longer injected or built by runtime installation. Existing copies on disk are inactive and may remain. Child-process log-directory rewriting and Chromium feature overrides previously supplied by the shim are removed; client startup without those workarounds needs manual validation. The first-time installer uses `--skip-to-install` and omits the installed client's `--app-root`, avoiding an immediate exit or inaccessible intro screen. The project ships no inspector hooks, client API helpers, input automation or client binary patches. Keep account data, logs and captures private. diff --git a/tests/test_setup_portability.py b/tests/test_setup_portability.py index 47ded2b..4b5a70a 100644 --- a/tests/test_setup_portability.py +++ b/tests/test_setup_portability.py @@ -13,8 +13,7 @@ from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] -VALIDATION_INPUTS = ( - 'RuntimeFrameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib', +GUEST_VALIDATION_INPUTS = ( 'libDarlingXShm.dylib', *(f'RuntimeLibraries/{name}.dylib' for name in ( 'libsystem_kernel', 'libsystem_c', 'libsystem_platform', 'libsystem_malloc', @@ -30,6 +29,12 @@ VALIDATION_INPUTS = ( 'RuntimeFrameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib', 'Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component/Contents/MacOS/CoreAudio', ) +PREFIX_VALIDATION_INPUTS = ( + 'usr/lib/system/libsystem_kernel.dylib', + 'System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib', + 'System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/LaunchServices', + 'System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/CarbonCore.framework/Versions/A/CarbonCore', +) class SetupFixture(unittest.TestCase): @@ -159,10 +164,12 @@ exec REAL_INSTALL "$@"'''.replace('REAL_INSTALL', shutil.which('install'))) def prepare_validation(self): self.configure() - for relative in VALIDATION_INPUTS: - path = self.guest / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(b'\xcf\xfa\xed\xfe' + b'\0' * 5000) + for root, inputs in ((self.guest, GUEST_VALIDATION_INPUTS), + (self.prefix, PREFIX_VALIDATION_INPUTS)): + for relative in inputs: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'\xcf\xfa\xed\xfe' + b'\0' * 5000) self.stub('llvm-objdump', '''printf '%s\\n' "$*" >> "$FIXTURE_LOG" exit "${CHECK_FAIL:-0}"''') @@ -171,6 +178,11 @@ exit "${CHECK_FAIL:-0}"''') source = (ROOT / 'tools/run-riot-client.sh').read_text() self.assertLess(source.index('runtime-paths.sh'), source.index('ensure-darling-runtime.py')) + def test_metal_pair_uses_shared_runtime_paths(self): + source = (ROOT / 'tools/install-riot-metal-pair.sh').read_text() + self.assertIn('runtime-paths.sh', source) + self.assertNotIn('$HOME/.darling', source) + def test_paths_reject_symlink_escape(self): self.configure() outside = self.home / 'outside' @@ -189,7 +201,7 @@ exit "${CHECK_FAIL:-0}"''') self.env['CHECK_FAIL'] = '1' self.assertNotEqual(self.run_script('build/runtime/tools/validate-riot-runtime.py').returncode, 0) self.env.pop('CHECK_FAIL') - vdsp = self.guest / 'RuntimeFrameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib' + vdsp = self.prefix / 'System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib' vdsp.unlink(missing_ok=True) self.assertNotEqual(self.run_script('build/runtime/tools/validate-riot-runtime.py').returncode, 0) shutil.rmtree(self.guest) @@ -204,6 +216,27 @@ exit "${CHECK_FAIL:-0}"''') self.assertEqual(result.returncode, 0, result.stderr) self.assertIn(str(override), self.log.read_text()) + def test_libs_cli_prefix_revalidates_environment_guest_home(self): + self.configure() + self.env['RIOT_GUEST_HOME'] = str(self.guest) + other_prefix = Path(self.tmp.name) / 'other prefix' + result = self.run_script('build/runtime/tools/install-riot-libs-runtime.py', + '--prefix', str(other_prefix), '--only', 'libsystem_c.dylib') + self.assertNotEqual(result.returncode, 0) + self.assertIn('inside DPREFIX', result.stderr) + + def test_libs_cli_guest_home_overrides_invalid_environment(self): + self.configure() + self.env['RIOT_GUEST_HOME'] = str(self.home / 'outside prefix') + other_prefix = Path(self.tmp.name) / 'other prefix' + artifact = self.root / 'build/riot-libsystem_c.dylib' + artifact.write_bytes(b'\xcf\xfa\xed\xfe' + b'library') + result = self.run_script('build/runtime/tools/install-riot-libs-runtime.py', + '--prefix', str(other_prefix), '--guest-home', '/Users/New Guest', + '--only', 'libsystem_c.dylib') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((other_prefix / 'Users/New Guest/RuntimeLibraries/libsystem_c.dylib').is_file()) + def test_failed_stop_with_all_compile_outputs_available(self): self.configure() diff --git a/tools/install-riot-libs-runtime.py b/tools/install-riot-libs-runtime.py index e50cbba..4a2a6b6 100644 --- a/tools/install-riot-libs-runtime.py +++ b/tools/install-riot-libs-runtime.py @@ -23,7 +23,7 @@ import shutil import stat import tempfile -from runtime_paths import runtime_paths +from runtime_paths import validate_runtime_paths ROOT = Path(__file__).resolve().parents[1] @@ -189,12 +189,17 @@ def main(argv=None): parser.exit(1, str(error) + "\n") return config = json.loads((ROOT / "build/config.json").read_text()) - prefix, host_guest_home = runtime_paths(config) - prefix = arguments.prefix or prefix - if arguments.prefix and not os.environ.get("RIOT_GUEST_HOME"): - host_guest_home = prefix / config["guest_home"].lstrip("/") + prefix = arguments.prefix or Path(os.environ.get("DPREFIX") or config["prefix"]) if arguments.guest_home: host_guest_home = prefix / arguments.guest_home.lstrip("/") + elif os.environ.get("RIOT_GUEST_HOME"): + host_guest_home = Path(os.environ["RIOT_GUEST_HOME"]) + else: + host_guest_home = prefix / config["guest_home"].lstrip("/") + try: + prefix, host_guest_home = validate_runtime_paths(prefix, host_guest_home) + except (ValueError, OSError) as error: + parser.exit(1, str(error) + "\n") libraries = destinations(host_guest_home, "") if arguments.only: wanted = set(arguments.only.split(",")) diff --git a/tools/install-riot-metal-pair.sh b/tools/install-riot-metal-pair.sh index 115525b..733b376 100755 --- a/tools/install-riot-metal-pair.sh +++ b/tools/install-riot-metal-pair.sh @@ -5,7 +5,7 @@ # `darling shutdown` and `sh scripts/run.sh` to activate. set -eu project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -guest_home=${RIOT_GUEST_HOME:-$HOME/.darling@GUEST_HOME@} +. "$project_dir/tools/runtime-paths.sh" metal="$guest_home/RuntimeFrameworks/Metal.framework/Versions/A/Metal" indium="$guest_home/RuntimeLibraries/libindium.dylib" backups="$project_dir/build/metal-pair-backups" diff --git a/tools/runtime_paths.py b/tools/runtime_paths.py index d50e4d4..2efc413 100644 --- a/tools/runtime_paths.py +++ b/tools/runtime_paths.py @@ -3,10 +3,9 @@ import os from pathlib import Path -def runtime_paths(config): - prefix = Path(os.environ.get("DPREFIX") or config["prefix"]) - guest_home = Path(os.environ.get("RIOT_GUEST_HOME") or - str(prefix / config["guest_home"].lstrip("/"))) +def validate_runtime_paths(prefix, guest_home): + """Return an absolute prefix/guest-home pair that cannot escape the prefix.""" + prefix, guest_home = Path(prefix), Path(guest_home) if not prefix.is_absolute() or not guest_home.is_absolute(): raise ValueError("Runtime paths must be absolute host paths") if ".." in guest_home.parts or not guest_home.is_relative_to(prefix) or guest_home == prefix: @@ -16,6 +15,13 @@ def runtime_paths(config): return prefix, guest_home +def runtime_paths(config): + prefix = Path(os.environ.get("DPREFIX") or config["prefix"]) + guest_home = Path(os.environ.get("RIOT_GUEST_HOME") or + str(prefix / config["guest_home"].lstrip("/"))) + return validate_runtime_paths(prefix, guest_home) + + if __name__ == '__main__': import sys try: diff --git a/tools/validate-riot-runtime.py b/tools/validate-riot-runtime.py index 1e5da1a..5176b46 100644 --- a/tools/validate-riot-runtime.py +++ b/tools/validate-riot-runtime.py @@ -9,8 +9,7 @@ import sys from runtime_paths import runtime_paths ROOT = Path(__file__).resolve().parents[1] -REQUIRED = ( - 'RuntimeFrameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib', +GUEST_REQUIRED = ( 'libDarlingXShm.dylib', *(f'RuntimeLibraries/{name}' for name in ( 'libsystem_kernel.dylib', 'libsystem_c.dylib', 'libsystem_platform.dylib', @@ -28,10 +27,19 @@ REQUIRED = ( 'RuntimeFrameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib', 'Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component/Contents/MacOS/CoreAudio', ) +PREFIX_REQUIRED = ( + 'usr/lib/system/libsystem_kernel.dylib', + 'System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/' + 'vecLib.framework/Versions/A/libvDSP.dylib', + 'System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/' + 'LaunchServices.framework/Versions/A/LaunchServices', + 'System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/' + 'CarbonCore.framework/Versions/A/CarbonCore', +) MACHO = (b'\xcf\xfa\xed\xfe', b'\xca\xfe\xba\xbe') -def inputs(guest_home): +def inputs(prefix, guest_home): def enumeration_error(error): raise error @@ -39,7 +47,8 @@ def inputs(guest_home): with path.open('rb') as source: return source.read(4) in MACHO - required = [guest_home / relative for relative in REQUIRED] + required = ([guest_home / relative for relative in GUEST_REQUIRED] + + [prefix / relative for relative in PREFIX_REQUIRED]) for path in required: if not is_macho(path): raise ValueError('Expected installed Mach-O runtime binary: ' + str(path)) @@ -64,8 +73,8 @@ def inputs(guest_home): def main(): try: - _, guest_home = runtime_paths(json.loads((ROOT / 'build/config.json').read_text())) - binaries = inputs(guest_home) + prefix, guest_home = runtime_paths(json.loads((ROOT / 'build/config.json').read_text())) + binaries = inputs(prefix, guest_home) subprocess.run([sys.executable, str(ROOT / 'tools/check-darwin-tlv-calls.py'), *map(str, binaries)], check=True) except (OSError, ValueError, subprocess.CalledProcessError) as error: From a2d4125e4ddecabd3f438177ecdbb80a9512de3e Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Sat, 17 Oct 2026 22:40:53 +0200 Subject: [PATCH 11/23] test(setup): isolate missing guest validation --- tests/test_setup_portability.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_setup_portability.py b/tests/test_setup_portability.py index 4b5a70a..6522e12 100644 --- a/tests/test_setup_portability.py +++ b/tests/test_setup_portability.py @@ -204,6 +204,7 @@ exit "${CHECK_FAIL:-0}"''') vdsp = self.prefix / 'System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib' vdsp.unlink(missing_ok=True) self.assertNotEqual(self.run_script('build/runtime/tools/validate-riot-runtime.py').returncode, 0) + vdsp.write_bytes(b'\xcf\xfa\xed\xfe' + b'\0' * 5000) shutil.rmtree(self.guest) self.assertNotEqual(self.run_script('build/runtime/tools/validate-riot-runtime.py').returncode, 0) From f1dc596c5b6ff6ac082dae005ec8bdaae8c899b7 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:54:50 +0200 Subject: [PATCH 12/23] Bound submit-worker drains to work already queued --- patches/metal--deps--indium.patch | 28 +++++++++--- tools/run-deferred-handoff-tests.sh | 5 ++- tools/test-submit-drain.cpp | 66 +++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 tools/test-submit-drain.cpp diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index 63b9a2c..09cd9a4 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -2447,7 +2447,7 @@ index 43f4a84..aaa1acb 100644 + if ((calls.fetch_add(1,std::memory_order_relaxed) & 63)==0) + bindingReuse.store(access("/tmp/metal-no-binding-reuse",F_OK)!=0,std::memory_order_relaxed); +} -+// OFF by default (round-3 behaviour: a presenting commit waits for the whole worker queue with drain()). Guest flag file ++// Frame-end drain waits for a snapshot of queued work. The opt-in guest flag file +// /tmp/metal-present-dispatch-wait (checked once per process) makes the frame-end sync narrower: the caller waits only +// until this commit's own task has run (its present dispatch, and by FIFO order every replay queued before it), not for +// tasks queued after it. Round 4 had this on by default; that round regressed in matches, so it is opt-in now. @@ -2487,7 +2487,7 @@ index 43f4a84..aaa1acb 100644 + token.wait("presentDispatchWait"); + return; + } -+ // Default (round 3): post first, then check the flag file and wait for the whole worker queue. ++ // Post first, then wait for the work already queued; later producers cannot extend this wait. + SubmitThread::post([self]() { + AsyncEncode::PoolScope pool; + self->commit(); @@ -8050,7 +8050,7 @@ diff --git a/private-include/indium/submit-thread.private.hpp b/private-include/ new file mode 100644 --- /dev/null +++ b/private-include/indium/submit-thread.private.hpp -@@ -0,0 +1,159 @@ +@@ -0,0 +1,175 @@ +#pragma once + +// Ordered queue-submission worker. @@ -8067,6 +8067,8 @@ new file mode 100644 + +#include +#include ++#include ++#include +#include +#include +#include @@ -8080,6 +8082,9 @@ new file mode 100644 + std::condition_variable idle; + std::deque> queue; + size_t active = 0; ++ uint64_t posted = 0, completed = 0; ++ size_t drainWaiters = 0; ++ uint64_t drainTarget = std::numeric_limits::max(); + bool started = false; + const bool enabled = access("/tmp/metal-submit-thread", F_OK) == 0; + }; @@ -8095,8 +8100,10 @@ new file mode 100644 + { + std::lock_guard lock(mutex); + done = true; ++ // The waiter may destroy this stack token as soon as it sees done. ++ // Finish notifying while the token's mutex still protects its lifetime. ++ condvar.notify_all(); + } -+ condvar.notify_all(); + } + // blocks until the task carrying this token has finished (counting the block under `site`) + void wait(const char* site) { @@ -8133,7 +8140,8 @@ new file mode 100644 + { + std::lock_guard lock(s.mutex); + --s.active; -+ if (s.queue.empty() && s.active == 0) s.idle.notify_all(); ++ ++s.completed; ++ if (s.drainWaiters && s.completed >= s.drainTarget) s.idle.notify_all(); + } + } + } @@ -8158,6 +8166,7 @@ new file mode 100644 + // mutex as the worker's wait to avoid losing that transition. + needsWake = s.active == 0 && s.queue.empty(); + s.queue.push_back(std::move(task)); ++ ++s.posted; + } + if (needsWake) s.wake.notify_one(); + } @@ -8190,6 +8199,7 @@ new file mode 100644 + // mutex as the worker's wait to avoid losing that transition. + needsWake = s.active == 0 && s.queue.empty(); + s.queue.push_back(std::move(task)); ++ ++s.posted; + } + if (needsWake) s.wake.notify_one(); + } @@ -8206,7 +8216,13 @@ new file mode 100644 + auto& s = state(); + if (onWorker()) return; + std::unique_lock lock(s.mutex); -+ Waits::blockOn(site, s.idle, lock, [&] { return s.queue.empty() && s.active == 0; }); ++ const uint64_t target = s.posted; ++ if (s.completed >= target) return; ++ ++s.drainWaiters; ++ if (target < s.drainTarget) s.drainTarget = target; ++ // FIFO completion is sufficient: later producers must not extend this wait. ++ Waits::blockOn(site, s.idle, lock, [&] { return s.completed >= target; }); ++ if (--s.drainWaiters == 0) s.drainTarget = std::numeric_limits::max(); + } + } +} diff --git a/tools/run-deferred-handoff-tests.sh b/tools/run-deferred-handoff-tests.sh index 431832e..2928e10 100755 --- a/tools/run-deferred-handoff-tests.sh +++ b/tools/run-deferred-handoff-tests.sh @@ -3,8 +3,9 @@ # # tools/test-deferred-handoff.cpp the ordered worker's FIFO, the present-dispatch token, the wait counters # tools/test-deferred-encoder-model.cpp the deferred blit/render op hand-off: order, resource lifetime, drop safety +# tools/test-submit-drain.cpp overlapping waits complete despite blocked later producers # -# Both compile the real headers from deps/indium/private-include with the host compiler. The counter test writes its +# These compile the real headers from deps/indium/private-include with the host compiler. The counter test writes its # summary to /tmp/opencode/waits-test.log (DARLING_WAITS_LOG) and needs /tmp/metal-debug-waits for the length of the # run; it removes the flag again if it created it (and first checks, in a forked child, that the counters stay off and # write nothing without it). @@ -17,7 +18,7 @@ project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) out_dir=${RIOT_TEST_OUT:-/tmp/opencode} include="$project_dir/build/darling-src/src/external/metal/deps/indium/private-include" mkdir -p "$out_dir" -for name in test-deferred-handoff test-deferred-encoder-model; do +for name in test-deferred-handoff test-deferred-encoder-model test-submit-drain; do c++ -std=c++17 -O1 -pthread -I "$include" "$project_dir/tools/$name.cpp" -o "$out_dir/$name" echo "== $name" "$out_dir/$name" diff --git a/tools/test-submit-drain.cpp b/tools/test-submit-drain.cpp new file mode 100644 index 0000000..2706a7e --- /dev/null +++ b/tools/test-submit-drain.cpp @@ -0,0 +1,66 @@ +// Exercise the real worker with overlapping drains and producers. A frame's +// drain must finish even while later work is blocked indefinitely. +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; +using namespace Indium; + +static void awaitDrains(size_t count) { + const auto deadline = std::chrono::steady_clock::now() + 5s; + for (;;) { + { + auto& s = SubmitThread::state(); + std::lock_guard lock(s.mutex); + if (s.drainWaiters == count) return; + } + assert(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } +} + +int main() { + std::promise releaseFirst, releaseSecond, releaseThird; + auto first = releaseFirst.get_future().share(); + auto second = releaseSecond.get_future().share(); + auto third = releaseThird.get_future().share(); + std::atomic completed{0}; + SubmitThread::post([&] { first.wait(); ++completed; }); + auto drain1 = std::async(std::launch::async, [] { SubmitThread::drain(); }); + awaitDrains(1); // drain1 has taken its snapshot before the next post + SubmitThread::post([&] { second.wait(); ++completed; }); + auto drain2 = std::async(std::launch::async, [] { SubmitThread::drain(); }); + awaitDrains(2); + SubmitThread::post([&] { third.wait(); ++completed; }); + releaseFirst.set_value(); + assert(drain1.wait_for(2s) == std::future_status::ready); + drain1.get(); + assert(completed == 1); + assert(drain2.wait_for(0s) == std::future_status::timeout); + releaseSecond.set_value(); + assert(drain2.wait_for(2s) == std::future_status::ready); + drain2.get(); + assert(completed == 2); + assert(SubmitThread::pending()); + releaseThird.set_value(); + SubmitThread::drain(); + assert(completed == 3); + // The worker itself cannot wait for a snapshot containing its active task. + SubmitThread::post([] { SubmitThread::drain(); }); + SubmitThread::drain(); + // Repeated idle/active transitions exercise notification registration races. + for (unsigned i=0; i<100000; ++i) { + SubmitThread::post([&] { ++completed; }); + SubmitThread::drain(); + } + assert(completed == 100003); + std::puts("PASS submit drain: overlapping snapshots, blocked later producer, reentrant drain, 100000 handoffs"); + std::fflush(stdout); + // The production worker is detached and process-lived. + std::_Exit(0); +} From de3bbe94b14aab7cce3ec0de013eca9babb88fa7 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:04:23 +0200 Subject: [PATCH 13/23] Extract runtime compatibility fixes from PR 41 --- docs/reviews/pr41-adaptation.md | 82 +++++++++++++++ patches/darlingserver.patch | 96 ++++++++++++++++-- patches/foundation.patch | 59 ++++++++++- patches/xnu.patch | 11 ++- tests/test_runtime_compatibility.py | 132 +++++++++++++++++++++++++ tools/build-riot-process-info-check.py | 24 +++++ tools/riot-process-info-check.m | 44 +++++++++ 7 files changed, 436 insertions(+), 12 deletions(-) create mode 100644 docs/reviews/pr41-adaptation.md create mode 100644 tests/test_runtime_compatibility.py create mode 100644 tools/build-riot-process-info-check.py create mode 100644 tools/riot-process-info-check.m diff --git a/docs/reviews/pr41-adaptation.md b/docs/reviews/pr41-adaptation.md new file mode 100644 index 0000000..1483097 --- /dev/null +++ b/docs/reviews/pr41-adaptation.md @@ -0,0 +1,82 @@ +# PR #41 compatibility adaptation + +[Original PR](https://github.com/lucannez64/darling-lol/pull/41) + +This adaptation keeps the compatibility corrections that can be implemented and +checked independently of a fabricated machine or security identity. It starts +from main after PRs #43 and #42. It does not claim to resolve account bans. + +## Included + +- Foundation returns the installed system's full version/build description, + falling back to ProductVersion and then Unknown. Numeric version comparisons + read ProductVersion directly, independently of the localized or overridden + description. No macOS version or build number is invented. +- physicalMemory queries 64-bit hw.memsize, then retries hw.physmem with its + correct 32-bit buffer width. The existing failure fallback is preserved. +- Unknown ptrace requests return EINVAL instead of an uninitialized value. + Existing handled requests retain their behavior. +- Unsupported page-accounting and task-policy requests return + KERN_NOT_SUPPORTED, initializing output counts where appropriate. Unsupported + VM-region and thread-info flavors return KERN_INVALID_ARGUMENT. These calls + no longer terminate darlingserver through unsafe stubs. +- The kernel-debugger capability query returns false and initializes its output. +- The 32-bit debug-state conversion passes the converted structure instead of + the address of its input pointer. Hardware debug-state support remains + unsupported. + +## Work left outside this adaptation + +The original PR's rendered source changes were inspected against its merge base, +so patch regeneration noise was excluded from this review. + +- Its darling.patch deletes the existing vDSP arith.c and fft.c implementations. + Those working routines must be preserved. +- GPU reporting is already implemented using actual Vulkan device properties. + A fixed Radeon Pro name and 4 GB budget would replace that behavior. +- The per-host platform ID fix is already merged. A separate configurable + identity feature would need an explicit schema, stable defaults, import + validation, and migration tests before replacing it. Automatic Apple serial, + NVRAM, OS-version, and model fabrication is excluded. +- CPUID trapping changes the SIGSEGV path and advertises a specific Intel CPU + on every host. It needs a separate design around actual host capabilities, + instruction safety, signal chaining, and performance; it is not necessary + for the compatibility corrections above. +- csops, AMFI, SIP, and debugger APIs must reflect implemented checks and state. + Fixed signing identities, fabricated trust-cache membership, and successful + setters that do not apply their settings cannot serve as those implementations. +- IOKit registry lookup/property routines should be extracted with bounds, + lifetime, traversal, and serialization tests, independent of synthetic NVRAM. +- Launcher environment and descriptor changes need a defined ownership boundary. + Blanket removal of driver variables or closing descriptors 3 through 256 can + remove intentional configuration and live descriptors. Keep #42's path setup. +- Log verbosity, descriptor leaks in shellspawn, CPU-frequency units, and other + independent correctness fixes can be separate changes with focused checks. + +## Validation + +The patch-level C regression tests compile the actual patched function bodies +with inert RPC substitutes and exercise error returns, output initialization, +null output pointers, unknown ptrace requests, and existing ptrace branches: + +```sh +python3 -m unittest discover -s tests +python3 scripts/prepare.py --check-only +python3 scripts/validate.py +python3 tools/riot-launcher-offline-check.py +python3 tools/riot-input-offline-check.py +python3 tools/riot-runtime-cleanup-offline-check.py +``` + +With the adapted patches prepared and Foundation rebuilt, the guest probe checks +full version/build reporting, numeric version independence from an overridden +description, and physicalMemory against 64-bit hw.memsize: + +```sh +python3 build/runtime/tools/build-riot-process-info-check.py +darling shell /Volumes/SystemRoot/ABSOLUTE_PROJECT_PATH/build/riot-process-info-check +``` + +The probe must load the rebuilt Foundation using the configured runtime or an +isolated DYLD_FRAMEWORK_PATH. The adaptation was checked with an isolated +Foundation build; the installed runtime and running server were not replaced. diff --git a/patches/darlingserver.patch b/patches/darlingserver.patch index 93505c0..5a5e0c3 100644 --- a/patches/darlingserver.patch +++ b/patches/darlingserver.patch @@ -445,7 +445,7 @@ index 458e659..2e33f20 100644 // wait until it's read if (!dtape_semaphore_down_simple(kqchan->waiter_read_semaphore)) { diff --git a/duct-tape/src/memory.c b/duct-tape/src/memory.c -index 3114947..fcff461 100644 +index 3114947..e2a8d5e 100644 --- a/duct-tape/src/memory.c +++ b/duct-tape/src/memory.c @@ -11,6 +11,7 @@ @@ -807,7 +807,43 @@ index 3114947..fcff461 100644 }; kern_return_t mach_vm_msync(vm_map_t map, mach_vm_offset_t address, mach_vm_size_t size, vm_sync_t sync_flags) { -@@ -856,7 +1163,7 @@ static kern_return_t mach_vm_remap_external_shared(vm_map_t target_map, mach_vm_ +@@ -620,15 +927,22 @@ kern_return_t mach_vm_msync(vm_map_t map, mach_vm_offset_t address, mach_vm_size + }; + + kern_return_t mach_vm_page_info(vm_map_t map, mach_vm_address_t address, vm_page_info_flavor_t flavor, vm_page_info_t info, mach_msg_type_number_t* count) { +- dtape_stub_unsafe(); ++ // Page accounting is not implemented; let the caller handle unsupported queries. ++ if (count) *count = 0; ++ return KERN_NOT_SUPPORTED; + }; + + kern_return_t mach_vm_page_query(vm_map_t map, mach_vm_offset_t offset, int* disposition, int* ref_count) { +- dtape_stub_unsafe(); ++ // Page accounting is not implemented; let the caller handle unsupported queries. ++ if (disposition) *disposition = 0; ++ if (ref_count) *ref_count = 0; ++ return KERN_NOT_SUPPORTED; + }; + + kern_return_t mach_vm_page_range_query(vm_map_t map, mach_vm_offset_t address, mach_vm_size_t size, mach_vm_address_t dispositions_addr, mach_vm_size_t* dispositions_count) { +- dtape_stub_unsafe(); ++ // Page accounting is not implemented; let the caller handle unsupported queries. ++ if (dispositions_count) *dispositions_count = 0; ++ return KERN_NOT_SUPPORTED; + }; + + kern_return_t mach_vm_protect(vm_map_t map, mach_vm_offset_t start, mach_vm_size_t size, boolean_t set_maximum, vm_prot_t new_protection) { +@@ -756,7 +1070,8 @@ region_info_out: + return kr; + }; + default: +- dtape_stub_unsafe("Unimplemented flavor"); ++ if (object_name) *object_name = IP_NULL; ++ return KERN_INVALID_ARGUMENT; + } + }; + +@@ -856,7 +1171,7 @@ static kern_return_t mach_vm_remap_external_shared(vm_map_t target_map, mach_vm_ src_existing_entry_count = dtape_map_find_shared_entries_locked(src_map, start_memaddr, map_size, src_existing_entries, sizeof(src_existing_entries) / sizeof(*src_existing_entries)); @@ -816,7 +852,7 @@ index 3114947..fcff461 100644 // special case for what LLDB does with dyld info dtape_map_shared_descriptor_retain(src_existing_entries[0]->descriptor); descriptor = src_existing_entries[0]->descriptor; -@@ -1441,6 +1748,10 @@ mach_destroy_memory_entry( +@@ -1441,6 +1756,10 @@ mach_destroy_memory_entry( named_entry->ref_count -= 1; if (named_entry->ref_count == 0) { @@ -827,7 +863,7 @@ index 3114947..fcff461 100644 if (named_entry->is_sub_map) { vm_map_deallocate(named_entry->backing.map); } else if (named_entry->is_copy) { -@@ -1456,6 +1767,9 @@ mach_destroy_memory_entry( +@@ -1456,6 +1775,9 @@ mach_destroy_memory_entry( named_entry_unlock(named_entry); named_entry_lock_destroy(named_entry); @@ -882,7 +918,7 @@ index afe60fc..02968b2 100644 void Assert(const char* file, int line, const char* expression) { diff --git a/duct-tape/src/stubs.c b/duct-tape/src/stubs.c -index dc1cfa5..242c6da 100644 +index dc1cfa5..39a8c8d 100644 --- a/duct-tape/src/stubs.c +++ b/duct-tape/src/stubs.c @@ -37,9 +37,18 @@ extern char* getenv(const char* name); @@ -913,7 +949,7 @@ index dc1cfa5..242c6da 100644 kind_info = " (unsafe)"; } else { if (!log_safe_stubs) { -@@ -210,7 +219,9 @@ kern_return_t kmod_get_info(host_t host, kmod_info_array_t* kmod_list, mach_msg_ +@@ -210,11 +219,15 @@ kern_return_t kmod_get_info(host_t host, kmod_info_array_t* kmod_list, mach_msg_ }; kern_return_t kext_request(host_priv_t hostPriv, uint32_t clientLogSpec, vm_offset_t requestIn, mach_msg_type_number_t requestLengthIn, vm_offset_t* responseOut, mach_msg_type_number_t* responseLengthOut, vm_offset_t* logDataOut, mach_msg_type_number_t* logDataLengthOut, kern_return_t* op_result) { @@ -924,8 +960,15 @@ index dc1cfa5..242c6da 100644 }; uint32_t PE_i_can_has_debugger(uint32_t* something) { +- dtape_stub_unsafe(); ++ // No kernel debugger capability is provided by this runtime. ++ if (something) *something = 0; ++ return 0; + }; + + bool work_interval_port_type_render_server(mach_port_name_t port_name) { diff --git a/duct-tape/src/task.c b/duct-tape/src/task.c -index a9eb4bc..b77187d 100644 +index a9eb4bc..7ef2800 100644 --- a/duct-tape/src/task.c +++ b/duct-tape/src/task.c @@ -521,7 +521,9 @@ kern_return_t task_info(task_t xtask, task_flavor_t flavor, task_info_t task_inf @@ -939,8 +982,27 @@ index a9eb4bc..b77187d 100644 } }; +@@ -585,7 +587,7 @@ kern_return_t task_restartable_ranges_synchronize(task_t task) { + }; + + kern_return_t task_set_exc_guard_behavior(task_t task, task_exc_guard_behavior_t behavior) { +- dtape_stub_unsafe(); ++ return KERN_NOT_SUPPORTED; + }; + + kern_return_t task_set_info(task_t task, task_flavor_t flavor, task_info_t task_info_in, mach_msg_type_number_t task_info_count) { +@@ -593,7 +595,8 @@ kern_return_t task_set_info(task_t task, task_flavor_t flavor, task_info_t task_ + }; + + kern_return_t task_set_phys_footprint_limit(task_t task, int new_limit_mb, int* old_limit_mb) { +- dtape_stub_unsafe(); ++ if (old_limit_mb) *old_limit_mb = 0; ++ return KERN_NOT_SUPPORTED; + }; + + kern_return_t task_set_state(task_t task, int flavor, thread_state_t state, mach_msg_type_number_t state_count) { diff --git a/duct-tape/src/thread.c b/duct-tape/src/thread.c -index f4ce909..e14fe79 100644 +index f4ce909..a6cdb2c 100644 --- a/duct-tape/src/thread.c +++ b/duct-tape/src/thread.c @@ -53,6 +53,7 @@ dtape_thread_t* dtape_thread_create(dtape_task_t* task, uint64_t nsid, void* con @@ -990,6 +1052,24 @@ index f4ce909..e14fe79 100644 return TRUE; }; +@@ -782,7 +800,7 @@ thread_set_state( + s64.dr6 = s->dr6; + s64.dr7 = s->dr7; + +- return thread_set_state(thread, x86_DEBUG_STATE64, (thread_state_t) &s, ++ return thread_set_state(thread, x86_DEBUG_STATE64, (thread_state_t) &s64, + x86_DEBUG_STATE64_COUNT); + } + case x86_DEBUG_STATE64: +@@ -1293,7 +1311,7 @@ kern_return_t thread_info(thread_t xthread, thread_flavor_t flavor, thread_info_ + }; + + default: +- dtape_stub_unsafe("Unimplemented flavor"); ++ return KERN_INVALID_ARGUMENT; + } + }; + @@ -1324,11 +1342,11 @@ kern_return_t thread_wire(host_priv_t host_priv, thread_t thread, boolean_t wire }; diff --git a/patches/foundation.patch b/patches/foundation.patch index 701827e..4fdb8b6 100644 --- a/patches/foundation.patch +++ b/patches/foundation.patch @@ -34,10 +34,51 @@ index 80c92bc07..2a80df649 100644 + +FOUNDATION_EXPORT NSString *const NSUserActivityTypeBrowsingWeb; diff --git a/src/NSProcessInfo.m b/src/NSProcessInfo.m -index 1c21f176d..f9f354c80 100644 +index 1c21f17..420955a 100644 --- a/src/NSProcessInfo.m +++ b/src/NSProcessInfo.m -@@ -305,6 +305,13 @@ SINGLETON_RR() +@@ -251,12 +251,13 @@ SINGLETON_RR() + } + #else + NSDictionary* dict = (NSDictionary*) _CFCopySystemVersionDictionary(); +- NSString* string = [dict objectForKey: @"ProductVersion"]; ++ NSString* string = [dict objectForKey: @"FullVersionString"]; ++ if (![string length]) string = [dict objectForKey: @"ProductVersion"]; + + [string retain]; + [dict release]; + +- return [string autorelease]; ++ return [string autorelease] ?: @"Unknown"; + #endif + } + +@@ -285,13 +286,17 @@ SINGLETON_RR() + + - (unsigned long long)physicalMemory + { +- unsigned long long amt = 1; +- size_t len = sizeof(unsigned long long); +- if (sysctlbyname("hw.physmem", &amt, &len, 0, 0) != 0) +- { +- amt = 1; +- } +- return amt; ++ uint64_t memory = 0; ++ size_t length = sizeof(memory); ++ if (sysctlbyname("hw.memsize", &memory, &length, NULL, 0) == 0) ++ return memory; ++ ++ // hw.physmem is a legacy 32-bit value; reset the buffer width on fallback. ++ uint32_t legacyMemory = 0; ++ length = sizeof(legacyMemory); ++ if (sysctlbyname("hw.physmem", &legacyMemory, &length, NULL, 0) == 0) ++ return legacyMemory; ++ return 1; + } + + - (NSTimeInterval)systemUptime +@@ -305,12 +310,23 @@ SINGLETON_RR() return t.tv_sec + t.tv_usec / (NSTimeInterval)USEC_PER_SEC; } @@ -51,7 +92,19 @@ index 1c21f176d..f9f354c80 100644 - (NSOperatingSystemVersion)operatingSystemVersion { NSOperatingSystemVersion version = { 0, 0, 0 }; -@@ -396,3 +403,5 @@ void __NSInitializeProcess(int argc,const char *argv[]) { + +- NSString* string = [self operatingSystemVersionString]; +- NSArray* arr = [string componentsSeparatedByString:@"."]; ++ // Parse the numeric product version independently of its localized display string. ++ NSDictionary* dict = (NSDictionary*) _CFCopySystemVersionDictionary(); ++ NSArray* arr = [[dict objectForKey: @"ProductVersion"] componentsSeparatedByString:@"."]; ++ [arr retain]; ++ [dict release]; ++ [arr autorelease]; + + if ([arr count] >= 1) + { +@@ -396,3 +412,5 @@ void __NSInitializeProcess(int argc,const char *argv[]) { } } #endif diff --git a/patches/xnu.patch b/patches/xnu.patch index 2132025..265cef6 100644 --- a/patches/xnu.patch +++ b/patches/xnu.patch @@ -3276,9 +3276,18 @@ index c2f70fb..af904ed 100644 default: __simple_printf("sys_proc_info(): Unsupported callnum: %d\n", diff --git a/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/ptrace.c b/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/ptrace.c -index 0a8f1e4..55aadbe 100644 +index 0a8f1e4..a19f12e 100644 --- a/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/ptrace.c +++ b/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/ptrace.c +@@ -22,7 +22,7 @@ extern int getpid(void); + long sys_ptrace(int request, int pid, void* addr, int data) + { + const char* cmd = NULL; +- int ret; ++ int ret = -EINVAL; + + switch (request) + { @@ -171,6 +171,17 @@ long sys_ptrace(int request, int pid, void* addr, int data) // return ret; cmd = "PT_THUPDATE"; break; diff --git a/tests/test_runtime_compatibility.py b/tests/test_runtime_compatibility.py new file mode 100644 index 0000000..721c4ae --- /dev/null +++ b/tests/test_runtime_compatibility.py @@ -0,0 +1,132 @@ +"""Compile actual patched syscall bodies with inert RPC stubs; never start a server.""" +import json +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +def function(source, name): + start = re.search(r'^[\w* ]+\b' + re.escape(name) + r'\([^;]*?\)\s*\{', source, re.M).start() + opening = source.index('{', start) + depth = 1 + end = opening + 1 + while depth: + depth += (source[end] == '{') - (source[end] == '}') + end += 1 + return source[start:end] + '\n' + + +@unittest.skipUnless(shutil.which('cc') and shutil.which('patch'), 'C compiler and patch required') +class CompatibilityChecks(unittest.TestCase): + def patched(self, name, path): + component = next(c for c in json.loads((ROOT / 'upstream/manifest.json').read_text())['components'] + if c['patch'] == 'patches/' + name + '.patch') + repo = ROOT / 'build/darling-src' / component['path'] + if not (repo / '.git').exists(): + self.skipTest('prepared Darling sources required') + original = subprocess.check_output(['git', '-C', str(repo), 'show', component['revision'] + ':' + path]) + marker = 'diff --git a/' + path + ' b/' + path + '\n' + patch = (ROOT / component['patch']).read_text().split(marker, 1)[1].split('\ndiff --git ', 1)[0] + with tempfile.TemporaryDirectory() as folder: + target = Path(folder) / path + target.parent.mkdir(parents=True) + target.write_bytes(original) + subprocess.run(['patch', '--silent', '-p1', '-d', folder], input=marker + patch, + text=True, check=True, capture_output=True) + return target.read_text() + + def run_c(self, source): + with tempfile.TemporaryDirectory() as folder: + file = Path(folder) / 'check.c' + file.write_text(source) + binary = Path(folder) / 'check' + result = subprocess.run(['cc', '-std=c11', '-O0', str(file), '-o', str(binary)], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_unsupported_mach_calls_return_errors_and_initialize_outputs(self): + bodies = [] + for path, names in { + 'duct-tape/src/memory.c': ['mach_vm_page_info', 'mach_vm_page_query', 'mach_vm_page_range_query'], + 'duct-tape/src/task.c': ['task_set_exc_guard_behavior', 'task_set_phys_footprint_limit'], + 'duct-tape/src/stubs.c': ['PE_i_can_has_debugger'], + }.items(): + source = self.patched('darlingserver', path) + bodies.extend(function(source, name) for name in names) + self.run_c(''' +#include +#include +#include +#include +typedef int kern_return_t; +typedef void *vm_map_t; +typedef void *vm_page_info_t; +typedef void *task_t; +typedef uint64_t mach_vm_address_t; +typedef uint64_t mach_vm_offset_t; +typedef uint64_t mach_vm_size_t; +typedef unsigned mach_msg_type_number_t; +typedef int vm_page_info_flavor_t; +typedef unsigned task_exc_guard_behavior_t; +#define KERN_NOT_SUPPORTED 46 +#define dtape_stub_unsafe(...) abort() +''' + ''.join(bodies) + ''' +int main(void) { + mach_msg_type_number_t count = 123; + assert(mach_vm_page_info(NULL, 0, 0, NULL, &count) == KERN_NOT_SUPPORTED && count == 0); + assert(mach_vm_page_info(NULL, 0, 0, NULL, NULL) == KERN_NOT_SUPPORTED); + int disposition = 123, refs = 123, old = 123; + assert(mach_vm_page_query(NULL, 0, &disposition, &refs) == KERN_NOT_SUPPORTED); + assert(disposition == 0 && refs == 0); + assert(mach_vm_page_query(NULL, 0, NULL, NULL) == KERN_NOT_SUPPORTED); + mach_vm_size_t rangeCount = 123; + assert(mach_vm_page_range_query(NULL, 0, 4096, 0, &rangeCount) == KERN_NOT_SUPPORTED && rangeCount == 0); + assert(mach_vm_page_range_query(NULL, 0, 4096, 0, NULL) == KERN_NOT_SUPPORTED); + assert(task_set_exc_guard_behavior(NULL, 0) == KERN_NOT_SUPPORTED); + assert(task_set_phys_footprint_limit(NULL, 100, &old) == KERN_NOT_SUPPORTED && old == 0); + assert(task_set_phys_footprint_limit(NULL, 100, NULL) == KERN_NOT_SUPPORTED); + uint32_t debugger = 123; + assert(PE_i_can_has_debugger(&debugger) == 0 && debugger == 0); + assert(PE_i_can_has_debugger(NULL) == 0); +} +''') + + def test_ptrace_unknown_request_and_existing_requests(self): + base = 'darling/src/libsystem_kernel/emulation/' + source = self.patched('xnu', base + 'src/xnu_syscall/bsd/impl/misc/ptrace.c') + header = self.patched('xnu', base + 'include/xnu_syscall/bsd/impl/misc/ptrace.h') + self.run_c(''' +#include +#include +#include +#include +#include +#include +#include +#define __simple_printf(...) ((void)0) +#define __simple_kprintf(...) ((void)0) +#define __simple_abort() abort() +static int calls; +static int sys_getppid(void) { return 1; } +static int errno_linux_to_bsd(int value) { return value; } +static int dserver_rpc_set_tracer(int a, int b) { ++calls; return 0; } +static int sys_kill(int a, int b, int c) { ++calls; return 0; } +static int dserver_rpc_ptrace_sigexc(int a, bool b) { ++calls; return 0; } +static int dserver_rpc_tid_for_thread(void *a, int *b) { ++calls; *b = 1; return 0; } +static int dserver_rpc_ptrace_thupdate(int a, int b) { ++calls; return 0; } +''' + header + function(source, 'sys_ptrace') + ''' +int main(void) { + for (int i = 0; i < 1000; ++i) assert(sys_ptrace(0x7fffffff, 0, NULL, 0) == -EINVAL); + assert(calls == 0); + assert(sys_ptrace(XNU_PT_ATTACH, 0, NULL, 0) == -ENOSYS && calls == 0); + assert(sys_ptrace(XNU_PT_DENY_ATTACH, 0, NULL, 0) == 0 && calls == 0); + assert(sys_ptrace(XNU_PT_PTRACE_ME, 0, NULL, 0) == 0 && calls == 1); +} +''') diff --git a/tools/build-riot-process-info-check.py b/tools/build-riot-process-info-check.py new file mode 100644 index 0000000..62ce739 --- /dev/null +++ b/tools/build-riot-process-info-check.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Build the Foundation version/memory probe against the prepared Darling SDK.""" +from pathlib import Path +import json +import re +import shlex +import subprocess + +root = Path(__file__).resolve().parent.parent +folder = root / 'build/darling-src/build/src/frameworks/CoreVideo' +sdk = json.loads((root / 'build/config.json').read_text())['sdk'] +flags = {} +resource = subprocess.check_output(['clang', '-print-resource-dir'], text=True).strip() +for line in (folder / 'CMakeFiles/CoreVideo.dir/flags.make').read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = [re.sub(r'/usr/lib/clang/\d+/include', resource + '/include', v) for v in shlex.split(value)] +obj = root / 'build/riot-process-info-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O2', '-c', str(root / 'tools/riot-process-info-check.m'), '-o', str(obj)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', sdk, str(obj), '-lSystem', '-lobjc', + '-framework', 'Foundation', '-framework', 'CoreFoundation', + '-o', str(root / 'build/riot-process-info-check')], check=True) diff --git a/tools/riot-process-info-check.m b/tools/riot-process-info-check.m new file mode 100644 index 0000000..7fb3160 --- /dev/null +++ b/tools/riot-process-info-check.m @@ -0,0 +1,44 @@ +#import +#import +#include +#include +#include + +// Numeric version queries must not depend on an overridable, localized string. +@interface LocalizedProcessInfo : NSProcessInfo +@end +@implementation LocalizedProcessInfo +- (NSString *)operatingSystemVersionString { return @"Localized operating system description"; } +@end + +int main(void) { + @autoreleasepool { + NSDictionary *dictionary = (NSDictionary *)_CFCopySystemVersionDictionary(); + NSString *expected = [dictionary objectForKey:@"FullVersionString"]; + if (![expected length]) expected = [dictionary objectForKey:@"ProductVersion"]; + if (!expected) expected = @"Unknown"; + NSProcessInfo *process = [NSProcessInfo processInfo]; + if (![[process operatingSystemVersionString] isEqualToString:expected]) { + fprintf(stderr, "FAIL: process version string omits the system version/build description\n"); + return 1; + } + NSOperatingSystemVersion version = [process operatingSystemVersion]; + NSOperatingSystemVersion localized = [[[LocalizedProcessInfo alloc] init] operatingSystemVersion]; + if (version.majorVersion != localized.majorVersion || + version.minorVersion != localized.minorVersion || version.patchVersion != localized.patchVersion) { + fprintf(stderr, "FAIL: numeric version depends on the localized description\n"); + return 2; + } + uint64_t memory = 0; + size_t length = sizeof(memory); + if (sysctlbyname("hw.memsize", &memory, &length, NULL, 0) || memory == 0 || + [process physicalMemory] != memory) { + fprintf(stderr, "FAIL: physicalMemory differs from 64-bit hw.memsize\n"); + return 3; + } + [dictionary release]; + printf("PROCESS_INFO version_build=1 localized_numeric=1 memory64=%llu pass=1\n", + (unsigned long long)memory); + } + return 0; +} From 9aee21212a069c68dcf266bf9abd55552af1f76b Mon Sep 17 00:00:00 2001 From: Kalah <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:17:35 +0200 Subject: [PATCH 14/23] Add warning about 3-Day Suspension and project takedown --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8d06d41..77babf8 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ > - don't play ranked; > - don't use your main account. > +> 3-Day Suspension ("UNSUPPORTED_THIRD_PARTY_TOOLS") DON'T USE IT RN > Even with all of that, you will probably still get permabanned. > > **3. Takedown.** Riot may well shut this project down. If that happens, I will publish a fork of Darling containing only the changes made here (none of them breach anything), and release the small script as a gist or similar. From 9fe7ce1f3c8b7814e66ce3ed97ea4bfe77fef654 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:31:23 +0200 Subject: [PATCH 15/23] Fix shell stream ownership and CPU frequency units --- patches/darling.patch | 171 +++++++++++++++++++++++- patches/xnu.patch | 118 +++++++++++++--- tests/test_runtime_compatibility.py | 13 +- tests/test_shell_cpu_compatibility.py | 86 ++++++++++++ tools/build-riot-cpu-frequency-check.py | 21 +++ tools/riot-cpu-frequency-check.c | 38 ++++++ upstream/manifest.json | 6 +- 7 files changed, 425 insertions(+), 28 deletions(-) create mode 100644 tests/test_shell_cpu_compatibility.py create mode 100644 tools/build-riot-cpu-frequency-check.py create mode 100644 tools/riot-cpu-frequency-check.c diff --git a/patches/darling.patch b/patches/darling.patch index 87c850a..00f4482 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -7597,19 +7597,22 @@ index 6fa2d088c..655554284 100644 int sandbox_issue_fs_extension(const char *path, uint64_t flags, char **ext_token); int sandbox_issue_fs_rw_extension(const char *path, char **ext_token); diff --git a/src/shellspawn/shellspawn.c b/src/shellspawn/shellspawn.c -index f5ba615bb..fea23d638 100644 +index f5ba615..53d04ad 100644 --- a/src/shellspawn/shellspawn.c +++ b/src/shellspawn/shellspawn.c -@@ -36,7 +36,7 @@ along with Darling. If not, see . +@@ -34,9 +34,10 @@ along with Darling. If not, see . + #include + #include #include "shellspawn.h" ++#include "shellspawn-fds.h" #include "duct_signals.h" -#define DBG 0 -+#define DBG 1 ++#define DBG (getenv("DARLING_SHELLSPAWN_DEBUG") && strcmp(getenv("DARLING_SHELLSPAWN_DEBUG"), "1") == 0) int g_serverSocket = -1; struct sigaction sigchld_oldaction; -@@ -50,6 +50,17 @@ void reapAll(void); +@@ -50,6 +51,17 @@ void reapAll(void); int main(int argc, const char** argv) { @@ -7627,6 +7630,123 @@ index f5ba615bb..fea23d638 100644 // shellspawn (daemon) --fork()--> shellspawn (child) --fork()--> exec /bin/bash // in order to read the exit status of the shell process, // we have to allow it to become a zombie, therefore we need to +@@ -125,7 +137,7 @@ void spawnShell(int fd) + { + pid_t shell_pid = -1; + int shellfd[3] = { -1, -1, -1 }; +- int pipefd[2]; ++ int pipefd[2] = { -1, -1 }; + int rv; + struct pollfd pfd[2]; + char** argv = NULL; +@@ -143,6 +155,14 @@ void spawnShell(int fd) + + char* alloc_exec = NULL; + ++ // Keep the control socket out of the stdio slots before receiving streams. ++ if (fd <= STDERR_FILENO) { ++ int moved = fcntl(fd, F_DUPFD_CLOEXEC, 3); ++ if (moved < 0) goto err; ++ close(fd); ++ fd = moved; ++ } ++ + // Read commands from client + while (read_cmds) + { +@@ -262,25 +282,20 @@ void spawnShell(int fd) + argv = (char**) realloc(argv, sizeof(char*) * (argc + 1)); + argv[argc] = NULL; + +- if (pipe(pipefd) == -1) +- goto err; ++ if (shellspawn_remap_stdio(shellfd) < 0) goto err; ++ if (pipe(pipefd) == -1) goto err; + + setsid(); + setpgrp(); + +- close(STDIN_FILENO); +- close(STDOUT_FILENO); +- close(STDERR_FILENO); +- +- dup2(shellfd[0], STDIN_FILENO); +- dup2(shellfd[1], STDOUT_FILENO); +- dup2(shellfd[2], STDERR_FILENO); +- + ioctl(STDIN_FILENO, TIOCSCTTY, STDIN_FILENO); + + shell_pid = fork(); ++ if (shell_pid < 0) goto err; + if (shell_pid == 0) + { ++ shellspawn_close_streams(shellfd); ++ close(pipefd[0]); + close(fd); + + fcntl(pipefd[1], F_SETFD, FD_CLOEXEC); +@@ -296,6 +311,7 @@ void spawnShell(int fd) + exit(EXIT_FAILURE); + } + ++ shellspawn_close_streams(shellfd); + if (alloc_exec) + { + free(alloc_exec); +@@ -304,12 +320,14 @@ void spawnShell(int fd) + + // Check that exec succeeded + close(pipefd[1]); // close the write end ++ pipefd[1] = -1; + if (read(pipefd[0], &rv, sizeof(rv)) == sizeof(rv)) + { + errno = rv; + goto err; + } + close(pipefd[0]); ++ pipefd[0] = -1; + + // Now we start passing signals + // and check for child process exit +@@ -372,7 +390,7 @@ void spawnShell(int fd) + + if (darwin_signal != 0) + { +- int fg_pid = tcgetpgrp(shellfd[0]); ++ int fg_pid = tcgetpgrp(STDIN_FILENO); + if (fg_pid != -1) + { + if (DBG) printf("fg_pid = %d\n", fg_pid); +@@ -394,11 +412,7 @@ void spawnShell(int fd) + kill(shell_pid, SIGKILL); + + // Close shell fds +- for (int i = 0; i < 3; i++) +- { +- if (shellfd[i] != -1) +- close(shellfd[0]); +- } ++ shellspawn_close_streams(shellfd); + + // Reap the child + int wstatus; +@@ -415,13 +429,11 @@ void spawnShell(int fd) + reapAll(); + return; + err: ++ if (pipefd[0] >= 0) close(pipefd[0]); ++ if (pipefd[1] >= 0) close(pipefd[1]); + if (DBG) fprintf(stderr, "Error spawning shell: %s\n", strerror(errno)); + +- for (int i = 0; i < 3; i++) +- { +- if (shellfd[i] != -1) +- close(shellfd[0]); +- } ++ shellspawn_close_streams(shellfd); + + if (shell_pid != -1) + kill(shell_pid, SIGKILL); diff --git a/src/startup/mldr/commpage.c b/src/startup/mldr/commpage.c index d9a7aea96..cc76f7a85 100644 --- a/src/startup/mldr/commpage.c @@ -10537,3 +10657,46 @@ index 8077b7e..0556080 100644 endif(NOT DEBIAN_PACKAGING) add_custom_target(uninstall +diff --git a/src/shellspawn/shellspawn-fds.h b/src/shellspawn/shellspawn-fds.h +new file mode 100644 +index 0000000..1f3c473 +--- /dev/null ++++ b/src/shellspawn/shellspawn-fds.h +@@ -0,0 +1,37 @@ ++#ifndef SHELLSPAWN_FDS_H ++#define SHELLSPAWN_FDS_H ++#include ++#include ++#include ++ ++// Preserve every source before replacing any standard descriptor: sources can ++// themselves be 0, 1 or 2 when the daemon was launched with closed stdio. ++static int shellspawn_remap_stdio(const int streams[3]) ++{ ++ int saved[3] = { -1, -1, -1 }; ++ for (int i = 0; i < 3; ++i) { ++ saved[i] = fcntl(streams[i], F_DUPFD_CLOEXEC, 3); ++ if (saved[i] < 0) goto failure; ++ } ++ for (int i = 0; i < 3; ++i) ++ if (dup2(saved[i], i) < 0) goto failure; ++ for (int i = 0; i < 3; ++i) close(saved[i]); ++ return 0; ++failure: { ++ int error = errno; ++ for (int i = 0; i < 3; ++i) if (saved[i] >= 0) close(saved[i]); ++ errno = error; ++ return -1; ++}} ++ ++static void shellspawn_close_streams(int streams[3]) ++{ ++ for (int i = 0; i < 3; ++i) { ++ int fd = streams[i]; ++ streams[i] = -1; ++ for (int j = i + 1; j < 3; ++j) ++ if (streams[j] == fd) streams[j] = -1; ++ if (fd > STDERR_FILENO) close(fd); ++ } ++} ++#endif diff --git a/patches/xnu.patch b/patches/xnu.patch index 265cef6..be0c967 100644 --- a/patches/xnu.patch +++ b/patches/xnu.patch @@ -1851,18 +1851,19 @@ index 7d5ef80..46debb8 100644 [521] = sys_abort_with_payload, [524] = sys_setattrlistat, diff --git a/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/helper/misc/sysctl_hw.c b/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/helper/misc/sysctl_hw.c -index f273989..1093a3a 100644 +index f273989..058b985 100644 --- a/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/helper/misc/sysctl_hw.c +++ b/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/helper/misc/sysctl_hw.c -@@ -9,6 +9,7 @@ +@@ -9,6 +9,8 @@ #include #include #include +#include ++#include #include extern kern_return_t mach_port_deallocate(ipc_space_t task, mach_port_name_t name); -@@ -16,6 +17,7 @@ extern kern_return_t host_info(mach_port_name_t host, int itype, void* hinfo, ma +@@ -16,6 +18,7 @@ extern kern_return_t host_info(mach_port_name_t host, int itype, void* hinfo, ma /* Darling specific */ enum { @@ -1870,10 +1871,13 @@ index f273989..1093a3a 100644 _HW_PHYSICAL_CPU = 1000, _HW_PHYSICAL_CPU_MAX, _HW_LOGICAL_CPU, -@@ -25,9 +27,12 @@ enum { +@@ -24,10 +27,14 @@ enum { + _HW_CPUSUBTYPE, _HW_CPUTHREADTYPE, _HW_64BITCAPABLE, - _HW_CPUFREQUENCY = 15, +- _HW_CPUFREQUENCY = 15, ++ _HW_CPUFREQUENCY_COMPAT = 15, ++ _HW_CPUFREQUENCY = 1500, + _HW_CACHECONFIG = 1200, + _HW_CACHESIZE, }; @@ -1883,12 +1887,13 @@ index f273989..1093a3a 100644 static sysctl_handler(handle_physicalcpu); static sysctl_handler(handle_physicalcpu_max); static sysctl_handler(handle_logicalcpu); -@@ -39,11 +44,77 @@ static sysctl_handler(handle_cpusubtype); +@@ -39,11 +46,78 @@ static sysctl_handler(handle_cpusubtype); static sysctl_handler(handle_cputhreadtype); static sysctl_handler(handle_cpu64bitcapable); static sysctl_handler(handle_machine); +static sysctl_handler(handle_model); static sysctl_handler(handle_cpufrequency); ++static sysctl_handler(handle_cpufrequency_compat); +static sysctl_handler(handle_cacheconfig); +static sysctl_handler(handle_cachesize); + @@ -1962,19 +1967,21 @@ index f273989..1093a3a 100644 { .oid = _HW_PHYSICAL_CPU, .type = CTLTYPE_INT, .exttype = "", .name = "physicalcpu", .handler = handle_physicalcpu }, { .oid = _HW_PHYSICAL_CPU_MAX, .type = CTLTYPE_INT, .exttype = "", .name = "physicalcpu_max", .handler = handle_physicalcpu_max }, { .oid = _HW_LOGICAL_CPU, .type = CTLTYPE_INT, .exttype = "", .name = "logicalcpu", .handler = handle_logicalcpu }, -@@ -55,7 +126,11 @@ const struct known_sysctl sysctls_hw[] = { +@@ -55,7 +129,12 @@ const struct known_sysctl sysctls_hw[] = { { .oid = _HW_CPUTHREADTYPE, .type = CTLTYPE_INT, .exttype = "", .name = "cputhreadtype", .handler = handle_cputhreadtype }, { .oid = _HW_64BITCAPABLE, .type = CTLTYPE_INT, .exttype = "", .name = "cpu64bit_capable", .handler = handle_cpu64bitcapable }, { .oid = HW_MACHINE, .type = CTLTYPE_STRING, .exttype = "S", .name = "machine", .handler = handle_machine }, +- { .oid = _HW_CPUFREQUENCY, .type = CTLTYPE_INT, .exttype = "", .name = "cpufrequency", .handler = handle_cpufrequency }, + { .oid = _HW_OPTIONAL, .type = CTLTYPE_NODE, .exttype = "", .name = "optional", .subctls = sysctls_hw_optional }, + { .oid = HW_MODEL, .type = CTLTYPE_STRING, .exttype = "S", .name = "model", .handler = handle_model }, - { .oid = _HW_CPUFREQUENCY, .type = CTLTYPE_INT, .exttype = "", .name = "cpufrequency", .handler = handle_cpufrequency }, ++ { .oid = _HW_CPUFREQUENCY, .type = CTLTYPE_QUAD, .exttype = "Q", .name = "cpufrequency", .handler = handle_cpufrequency }, ++ { .oid = _HW_CPUFREQUENCY_COMPAT, .type = CTLTYPE_INT, .exttype = "I", .name = "cpufrequency_compat", .handler = handle_cpufrequency_compat }, + { .oid = _HW_CACHECONFIG, .type = CTLTYPE_OPAQUE, .exttype = "Q", .name = "cacheconfig", .handler = handle_cacheconfig }, + { .oid = _HW_CACHESIZE, .type = CTLTYPE_OPAQUE, .exttype = "Q", .name = "cachesize", .handler = handle_cachesize }, { .oid = -1 } }; -@@ -71,6 +146,32 @@ static const struct host_basic_info* gethostinfo(void) +@@ -71,6 +150,32 @@ static const struct host_basic_info* gethostinfo(void) hself = mach_host_self(); host_info(hself, HOST_BASIC_INFO, &hinfo, &hcount); mach_port_deallocate(mach_task_self(), hself); @@ -2007,7 +2014,7 @@ index f273989..1093a3a 100644 } return &hinfo; -@@ -83,6 +184,13 @@ sysctl_handler(handle_availcpu) +@@ -83,6 +188,13 @@ sysctl_handler(handle_availcpu) return 0; } @@ -2021,16 +2028,64 @@ index f273989..1093a3a 100644 sysctl_handler(handle_physicalcpu) { sysctl_handle_size(sizeof(int)); -@@ -174,6 +282,8 @@ sysctl_handler(handle_cpufrequency) - int freq = 2400000; - char buf[16]; +@@ -169,16 +281,49 @@ sysctl_handler(handle_cputhreadtype) + return 0; + } -+ sysctl_handle_size(sizeof(int)); ++static long cpu_frequency(uint64_t* frequency) ++{ ++ char buffer[32]; ++ const char* paths[] = { ++ "/Volumes/SystemRoot/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq", ++ "/Volumes/SystemRoot/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq", ++ "/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq", ++ "/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq" ++ }; ++ for (unsigned i = 0; i < sizeof(paths)/sizeof(paths[0]); ++i) ++ if (read_string(paths[i], buffer, sizeof(buffer)) && darling_frequency_hz(buffer, frequency)) return 0; ++#if defined(__i386__) || defined(__x86_64__) ++ unsigned int a, b, c, d; ++ __asm__("cpuid" : "=a"(a), "=b"(b), "=c"(c), "=d"(d) : "0"(0), "2"(0)); ++ if (a >= 0x16) { ++ __asm__("cpuid" : "=a"(a), "=b"(b), "=c"(c), "=d"(d) : "0"(0x16), "2"(0)); ++ if (a & 0xffff) { ++ *frequency = (uint64_t)(a & 0xffff) * 1000000; ++ return 0; ++ } ++ } ++#endif ++ return -ENOENT; ++} + - if (read_string("/sys/bus/cpu/devices/cpu0/cpufreq/cpuinfo_max_freq", buf, sizeof(buf))) - freq = __simple_atoi(buf, NULL); - *((int*) old) = freq; -@@ -193,3 +303,104 @@ sysctl_handler(handle_machine) + sysctl_handler(handle_cpufrequency) + { +- int freq = 2400000; +- char buf[16]; ++ uint64_t frequency; ++ sysctl_handle_size(sizeof(frequency)); ++ long result = cpu_frequency(&frequency); ++ if (!result) *((uint64_t*)old) = frequency; ++ return result; ++} + +- if (read_string("/sys/bus/cpu/devices/cpu0/cpufreq/cpuinfo_max_freq", buf, sizeof(buf))) +- freq = __simple_atoi(buf, NULL); +- *((int*) old) = freq; +- +- return 0; ++sysctl_handler(handle_cpufrequency_compat) ++{ ++ // Numeric HW_CPU_FREQ retains XNU's legacy 32-bit ABI. Modern by-name ++ // queries use the separate 64-bit OID above, as in kern_mib.c. ++ uint64_t frequency; ++ sysctl_handle_size(sizeof(uint32_t)); ++ long result = cpu_frequency(&frequency); ++ if (!result) *((uint32_t*)old) = (uint32_t)frequency; ++ return result; + } + + sysctl_handler(handle_cpu64bitcapable) +@@ -193,3 +338,104 @@ sysctl_handler(handle_machine) copyout_string(need_uname()->machine, (char*) old, oldlen); return 0; } @@ -6083,3 +6138,30 @@ index 0000000..f185f6c +__sysctlbyname: + /* Just jump to the syscall stub */ + jmp ___sysctlbyname +diff --git a/darling/src/libsystem_kernel/emulation/include/common/cpu_frequency.h b/darling/src/libsystem_kernel/emulation/include/common/cpu_frequency.h +new file mode 100644 +index 0000000..eb90f43 +--- /dev/null ++++ b/darling/src/libsystem_kernel/emulation/include/common/cpu_frequency.h +@@ -0,0 +1,21 @@ ++#ifndef DARLING_CPU_FREQUENCY_H ++#define DARLING_CPU_FREQUENCY_H ++#include ++#include ++ ++// Linux cpufreq exposes kHz; Darwin's hw.cpufrequency is an unsigned 64-bit Hz value. ++static inline bool darling_frequency_hz(const char* text, uint64_t* result) ++{ ++ uint64_t value = 0; ++ if (!text || !result || *text < '0' || *text > '9') return false; ++ do { ++ unsigned digit = (unsigned)(*text++ - '0'); ++ if (value > (UINT64_MAX - digit) / 10) return false; ++ value = value * 10 + digit; ++ } while (*text >= '0' && *text <= '9'); ++ while (*text == ' ' || *text == '\t' || *text == '\r' || *text == '\n') ++text; ++ if (*text || !value || value > UINT64_MAX / 1000) return false; ++ *result = value * 1000; ++ return true; ++} ++#endif diff --git a/tests/test_runtime_compatibility.py b/tests/test_runtime_compatibility.py index 721c4ae..741917e 100644 --- a/tests/test_runtime_compatibility.py +++ b/tests/test_runtime_compatibility.py @@ -29,14 +29,19 @@ class CompatibilityChecks(unittest.TestCase): repo = ROOT / 'build/darling-src' / component['path'] if not (repo / '.git').exists(): self.skipTest('prepared Darling sources required') - original = subprocess.check_output(['git', '-C', str(repo), 'show', component['revision'] + ':' + path]) + original = (None if path in component['new_files'] else + subprocess.check_output(['git', '-C', str(repo), 'show', component['revision'] + ':' + path])) marker = 'diff --git a/' + path + ' b/' + path + '\n' - patch = (ROOT / component['patch']).read_text().split(marker, 1)[1].split('\ndiff --git ', 1)[0] + patch_text = (ROOT / component['patch']).read_text() + if marker not in patch_text: + return original.decode() + patch = patch_text.split(marker, 1)[1].split('\ndiff --git ', 1)[0] with tempfile.TemporaryDirectory() as folder: target = Path(folder) / path target.parent.mkdir(parents=True) - target.write_bytes(original) - subprocess.run(['patch', '--silent', '-p1', '-d', folder], input=marker + patch, + if original is not None: + target.write_bytes(original) + subprocess.run(['patch', '--silent', '-p1', '-d', folder], input=marker + patch + ("" if patch.endswith("\n") else "\n"), text=True, check=True, capture_output=True) return target.read_text() diff --git a/tests/test_shell_cpu_compatibility.py b/tests/test_shell_cpu_compatibility.py new file mode 100644 index 0000000..305ab44 --- /dev/null +++ b/tests/test_shell_cpu_compatibility.py @@ -0,0 +1,86 @@ +"""Exercise the real patched descriptor helper and frequency conversion on the host.""" +import unittest +import test_runtime_compatibility as sources + + +class ShellCPUChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + run_c = sources.CompatibilityChecks.run_c + + def test_frequency_units_width_and_invalid_inputs(self): + root = 'darling/src/libsystem_kernel/emulation/' + header = self.patched('xnu', root + 'include/common/cpu_frequency.h') + original = self.patched('xnu', root + 'include/xnu_syscall/bsd/impl/misc/sysctl.h') + size_macro = original[original.index('#define sysctl_handle_size'):original.index('typedef long')] + source = self.patched('xnu', root + 'src/xnu_syscall/bsd/helper/misc/sysctl_hw.c') + start = source.index('static long cpu_frequency(') + handler = source[start:source.index('\nsysctl_handler(handle_cpu64bitcapable)', start)] + self.run_c(header + ''' +#include +#include +#include +#include +static unsigned reads; +static bool read_string(const char* path, char* dst, int size) { + ++reads; strcpy(dst, "5600000\\n"); return true; +} +#define sysctl_handler(name) long name(void* old, unsigned long* oldlen) +''' + size_macro + handler + ''' +int main(void) { + uint64_t hz = 17; + assert(darling_frequency_hz("2400000\\n", &hz) && hz == 2400000000ULL); + assert(darling_frequency_hz("5600000", &hz) && hz == 5600000000ULL); + const char *bad[] = {"", "0", "-1", " 123", "12MHz", "18446744073709551616", "18446744073709552"}; + for (unsigned i=0; i +#include +#include +static unsigned open_count(void) { + unsigned n = 0; for (int fd=3; fd<256; ++fd) if (fcntl(fd, F_GETFD) >= 0) ++n; return n; +} +int main(void) { + pid_t child = fork(); assert(child >= 0); + if (!child) { + int p[3][2], sentinel[2]; + for (int i=0; i<3; ++i) assert(pipe(p[i]) == 0); + assert(pipe(sentinel) == 0); + for (int i=0; i<3; ++i) assert(dup2(p[i][0], i) == i); + struct stat expected[3], actual; + int alias[3] = {2,0,1}; + for (int i=0; i<3; ++i) assert(fstat(alias[i], &expected[i]) == 0); + assert(shellspawn_remap_stdio(alias) == 0); + for (int i=0; i<3; ++i) { + assert(fstat(i, &actual) == 0 && actual.st_ino == expected[i].st_ino); + assert(!(fcntl(i, F_GETFD) & FD_CLOEXEC)); + } + int streams[3] = {p[0][0],p[1][0],p[2][0]}; + shellspawn_close_streams(streams); + for (int i=0; i<3; ++i) assert(fcntl(p[i][0], F_GETFD) == -1 && streams[i] == -1); + assert(fcntl(sentinel[0], F_GETFD) >= 0); + streams[0] = streams[1] = streams[2] = p[0][1]; + shellspawn_close_streams(streams); shellspawn_close_streams(streams); + unsigned before = open_count(); + int invalid[3] = {sentinel[0],-1,sentinel[1]}; + for (int i=0; i<1000; ++i) assert(shellspawn_remap_stdio(invalid) == -1 && errno == EBADF); + assert(open_count() == before && fcntl(sentinel[0], F_GETFD) >= 0); + _exit(0); + } + int status; assert(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 0); +} +''') diff --git a/tools/build-riot-cpu-frequency-check.py b/tools/build-riot-cpu-frequency-check.py new file mode 100644 index 0000000..ec7ca44 --- /dev/null +++ b/tools/build-riot-cpu-frequency-check.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Build the frequency units and legacy ABI check against the prepared SDK.""" +import json +from pathlib import Path +import re +import shlex +import subprocess + +root = Path(__file__).resolve().parent.parent +sdk = json.loads((root / 'build/config.json').read_text())['sdk'] +flags = {} +resource = subprocess.check_output(['clang', '-print-resource-dir'], text=True).strip() +for line in (root / 'build/darling-src/build/src/frameworks/CoreVideo/CMakeFiles/CoreVideo.dir/flags.make').read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = [re.sub(r'/usr/lib/clang/\d+/include', resource + '/include', v) for v in shlex.split(value)] +obj = root / 'build/riot-cpu-frequency-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O2', '-c', str(root / 'tools/riot-cpu-frequency-check.c'), '-o', str(obj)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', sdk, str(obj), '-lSystem', '-o', str(root / 'build/riot-cpu-frequency-check')], check=True) diff --git a/tools/riot-cpu-frequency-check.c b/tools/riot-cpu-frequency-check.c new file mode 100644 index 0000000..a6c01da --- /dev/null +++ b/tools/riot-cpu-frequency-check.c @@ -0,0 +1,38 @@ +#include +#include +#include +#include + +int main(void) { + size_t length = 0; + if (sysctlbyname("hw.cpufrequency", NULL, &length, NULL, 0) || length != sizeof(uint64_t)) return 1; + const char *paths[] = { + "/Volumes/SystemRoot/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq", + "/Volumes/SystemRoot/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq", + "/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq", + "/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq" + }; + uint64_t expected = 0; + for (unsigned i=0; i<4; ++i) { + FILE* source = fopen(paths[i], "r"); + unsigned long long khz = 0; + if (!source) continue; + int found = fscanf(source, "%llu", &khz); fclose(source); + if (found == 1 && khz > 0 && khz <= UINT64_MAX/1000) { expected = khz * 1000; break; } + } + uint64_t hz = 0; + length = sizeof(hz); + if (sysctlbyname("hw.cpufrequency", &hz, &length, NULL, 0)) { + if (errno != ENOENT || expected) return 2; + puts("CPU_FREQUENCY unavailable=1 invented_frequency=0 pass=1"); + return 0; + } + if (length != 8 || hz < 1000000) return 3; + if (expected && hz != expected) return 4; + struct { uint32_t before, value, after; } legacy = {0x12345678,0,0x87654321}; + int mib[] = {CTL_HW, HW_CPU_FREQ}; length = sizeof(legacy.value); + if (sysctl(mib, 2, &legacy.value, &length, NULL, 0) || length != 4 || legacy.value != (uint32_t)hz || + legacy.before != 0x12345678 || legacy.after != 0x87654321) return 5; + printf("CPU_FREQUENCY hz=%llu width64=1 legacy_width32=1 pass=1\n", (unsigned long long)hz); + return 0; +} diff --git a/upstream/manifest.json b/upstream/manifest.json index 1fff96e..733502b 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -24,7 +24,8 @@ "src/frameworks/OpenDirectory/src/ODLocalDirectory.m", "src/frameworks/OpenGL/CGLAsyncPresent.inc", "src/frameworks/OpenGL/include/OpenGL/DarlingNativePresent.h", - "src/startup/mldr/commpage-cpu-caps.h" + "src/startup/mldr/commpage-cpu-caps.h", + "src/shellspawn/shellspawn-fds.h" ] }, { @@ -282,7 +283,8 @@ "darling/src/libsystem_kernel/emulation/include/xnu_syscall/bsd/impl/misc/mac_syscall.h", "darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/guarded/change_fdguard_np.c", "darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/mac_syscall.c", - "libsyscall/wrappers/__sysctlbyname.S" + "libsyscall/wrappers/__sysctlbyname.S", + "darling/src/libsystem_kernel/emulation/include/common/cpu_frequency.h" ] }, { From b6302bb2c6a270433c7dc7d2619478d1372d6b7d Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:32:01 +0200 Subject: [PATCH 16/23] Implement bounded registry APIs and opt-in platform configuration --- docs/PLATFORM_IDENTITY.md | 73 +++ docs/reviews/pr41-adaptation.md | 2 + docs/reviews/pr41-deferred-implementation.md | 98 ++++ patches/iokitd.patch | 523 +++++++++++++++++- tests/test_platform_identity.py | 157 ++++++ ...build-riot-registry-compatibility-check.py | 47 ++ tools/configure-darling-platform.py | 204 +++++++ tools/riot-registry-compatibility-check.mm | 194 +++++++ upstream/manifest.json | 5 +- 9 files changed, 1284 insertions(+), 19 deletions(-) create mode 100644 docs/PLATFORM_IDENTITY.md create mode 100644 docs/reviews/pr41-deferred-implementation.md create mode 100644 tests/test_platform_identity.py create mode 100644 tools/build-riot-registry-compatibility-check.py create mode 100644 tools/configure-darling-platform.py create mode 100644 tools/riot-registry-compatibility-check.mm diff --git a/docs/PLATFORM_IDENTITY.md b/docs/PLATFORM_IDENTITY.md new file mode 100644 index 0000000..c1d4b85 --- /dev/null +++ b/docs/PLATFORM_IDENTITY.md @@ -0,0 +1,73 @@ +# Explicit platform identity overrides + +By default, Darling keeps the existing host-derived UUID and `DARLING-` serial. +No override file is generated by installation or launch. This tool imports +values supplied by the user; it does not generate Apple serials or replace OS, +CPU, GPU, or security metadata. + +Preview an OpenCore `config.plist`, a flat GenSMBIOS JSON file, or an existing +`darling-smbios.conf` without changing the selected prefix: + +```sh +python3 tools/configure-darling-platform.py --prefix /absolute/prefix --import /path/to/config.plist +``` + +After reviewing the preview, shut down that prefix and explicitly save: + +```sh +DPREFIX=/absolute/prefix python3 build/runtime/tools/stop-darling-runtime.py +python3 tools/configure-darling-platform.py --prefix /absolute/prefix --import /path/to/config.plist --write +``` + +The default prefix selection is `--prefix`, then `DPREFIX`, then saved +`build/config.json`, then `~/.darling`. Writing refuses a live prefix. The +configuration is activated when that prefix's iokitd starts again; it is an +immutable startup snapshot. An absent or invalid configuration preserves the +existing defaults. Invalid daemon-side configuration is reported once. + +The file is `/private/etc/darling-platform.plist`. It uses this schema: + +```json +{ + "schema": 1, + "properties": { + "IOPlatformSerialNumber": "USER-SUPPLIED", + "model": "UserSuppliedModel" + } +} +``` + +Supported properties: + +| Property | Configuration value | IOKit value | +|---|---|---| +| `IOPlatformSerialNumber` | Printable ASCII string, 1–64 characters | NSString | +| `IOPlatformUUID` | Nonzero UUID; normalized to uppercase | NSString | +| `manufacturer`, `model`, `product-name`, `board-id` | Printable ASCII string, 1–128 characters | NUL-terminated NSData | +| `mlb-serial-number` | Printable ASCII string, 1–64 characters | NUL-terminated NSData | +| `ROM` | Exactly 6 bytes; JSON accepts hexadecimal text | NSData | +| `system-id` | Exactly 16 bytes; JSON accepts hexadecimal text | NSData | + +Supplying a model also supplies the matching product name unless one is +explicitly provided. Supplying a UUID derives its EFI little-endian `system-id`; +a conflicting explicit system ID is rejected. No other missing fields are +invented. These properties belong to the existing platform service; the tool +does not manufacture a firmware/NVRAM registry hierarchy. + +For OpenCore, the importer reads `PlatformInfo/Generic` (or a standalone Generic +dictionary): `SystemProductName`, `SystemSerialNumber`, `SystemUUID`, `MLB`, +and `ROM`. It ignores unrelated boot and security settings. GenSMBIOS aliases +include `Type`, `Serial`, `Board Serial`, and `SmUUID`. Legacy `SMBIOS_*` identity +keys are accepted from `.conf`; parsing never executes shell expressions. + +Existing configured fields survive a partial import. A new explicit UUID +replaces its previously derived system ID. `--set KEY=VALUE` can update an +individual field. Unknown schema versions, unsupported properties, malformed +values, and duplicate JSON fields are rejected. + +Writes are atomic with mode 0600. Repeating the same write changes nothing. The +first previous configuration is kept as +`darling-platform.plist.before-platform-change`, also with mode 0600. Importing +an older `.conf` never edits that source. Symlinks escaping the prefix are +rejected. To restore the original defaults, remove the override file while the +prefix is stopped, then restart it. diff --git a/docs/reviews/pr41-adaptation.md b/docs/reviews/pr41-adaptation.md index 1483097..8748518 100644 --- a/docs/reviews/pr41-adaptation.md +++ b/docs/reviews/pr41-adaptation.md @@ -25,6 +25,8 @@ from main after PRs #43 and #42. It does not claim to resolve account bans. the address of its input pointer. Hardware debug-state support remains unsupported. +The deferred compatibility groups are implemented in the [following stack layer](pr41-deferred-implementation.md). + ## Work left outside this adaptation The original PR's rendered source changes were inspected against its merge base, diff --git a/docs/reviews/pr41-deferred-implementation.md b/docs/reviews/pr41-deferred-implementation.md new file mode 100644 index 0000000..9c2347d --- /dev/null +++ b/docs/reviews/pr41-deferred-implementation.md @@ -0,0 +1,98 @@ +# PR #41 deferred compatibility work + +This follow-up is stacked on the compatibility extraction in PR #44. It +implements the four groups deferred in that review, with explicit configuration +and observable errors rather than automatic identity changes. + +## Shell spawning + +Diagnostics are quiet by default; `DARLING_SHELLSPAWN_DEBUG=1` enables the +existing diagnostic output. The environment and arguments are no longer dumped +on normal launches. + +Stdio sources are duplicated before any standard descriptor is replaced, so +aliases involving descriptors 0–2 are safe. Stream cleanup closes each owned +descriptor once instead of repeatedly closing stream 0. Both fork branches drop +unneeded descriptors, and error paths clean up the exec-status pipe. The +control socket is kept out of the stdio slots. Terminal signal forwarding uses +the remapped stdin. Unrelated descriptors and the configured shell selection +are preserved. + +## CPU frequency + +`hw.cpufrequency` is an unsigned 64-bit frequency in Hz, matching XNU's named +API. Linux cpufreq kHz values are checked for invalid input and overflow before +conversion. The exported host sysfs location is tried as well as `/sys`; an +available native CPUID base-frequency leaf is a fallback. No fixed CPU speed is +invented when no source exists. + +Numeric `HW_CPU_FREQ` retains the legacy 32-bit representation through a +separate compatibility entry, matching XNU's distinction between the modern +named API and the numeric compatibility API. Consumers needing frequencies +above the 32-bit range should use the named 64-bit API. + +## IOKit registry + +- Inline and out-of-line path lookup validate termination, path shape, plane + membership, and length. Paths resolve registered entries with case-sensitive + names; no synthetic firmware nodes are inserted. +- Path output uses inline storage when possible and a bounded sender buffer for + long physical-copy replies. Incoming OOL mappings are released on success and + error paths. The daemon's main queue serializes request processing and reply + copying, so reply storage remains live through the send. +- Plane membership follows registered ancestry. Deleting a parent or child + removes both graph links. Plane-name keys own their strings, and cyclic + registration is refused. +- Raw property access handles strings, data, integer numbers at their reported + widths, and XNU's `Yes`/`No` representation of booleans. It bounds writes by + both the requested count and the fixed MIG array. Missing and unsupported + values return errors; small buffers return IPC errors without writes. + +## Platform configuration + +[Platform identity configuration](../PLATFORM_IDENTITY.md) documents the schema, +OpenCore/GenSMBIOS/legacy imports, startup snapshot, defaults, and rollback. +Neither installation nor launch generates an override file. Existing host-based +identifiers remain stable unless the user explicitly supplies replacements. + +## Validation + +Portable regression checks: + +```sh +python3 -m unittest discover -s tests +python3 scripts/prepare.py --check-only +python3 scripts/validate.py +python3 tools/riot-launcher-offline-check.py +python3 tools/riot-input-offline-check.py +python3 tools/riot-runtime-cleanup-offline-check.py +``` + +The unit tests compile the actual patched fd helper and frequency code. They +exercise fd aliases, unrelated descriptors, repeated failed remaps, conversion +overflow, buffer widths, schema validation, imports, idempotency, backups, +per-prefix isolation, malformed input, and preservation of existing identities. + +With the source patches prepared, build an isolated iokitd candidate and probe: + +```sh +python3 build/runtime/tools/build-riot-registry-compatibility-check.py +darling shell /Volumes/SystemRoot/ABSOLUTE_PROJECT_PATH/build/riot-registry-compatibility-check +``` + +The probe uses the actual daemon objects and MIG handlers, and creates its own +Mach ports. It does not register a daemon or replace the running iokitd. It +checks traversal, deletion, cycles, malformed OOL input, 1,000 direct OOL +lookups, 100 real IPC path round trips, property widths/bounds, and binary +serialization round trips. It also checks daemon-side identity validation. + +For a rebuilt kernel containing the frequency patch: + +```sh +python3 build/runtime/tools/build-riot-cpu-frequency-check.py +darling shell /Volumes/SystemRoot/ABSOLUTE_PROJECT_PATH/build/riot-cpu-frequency-check +``` + +Local validation also ran the fd helper inside Darling and used an isolated +kernel override for the frequency probe. The configured production runtime was +not replaced, no real identity was changed, and no match capture is required. diff --git a/patches/iokitd.patch b/patches/iokitd.patch index 96d4963..c3333e1 100644 --- a/patches/iokitd.patch +++ b/patches/iokitd.patch @@ -26,10 +26,227 @@ index 84161fd..5b23944 100644 src/IOSurface.mm src/IOUserClient.mm diff --git a/src/IORegistryEntry.mm b/src/IORegistryEntry.mm -index 50c10fe..bd501cf 100644 +index 50c10fe..7026e1c 100644 --- a/src/IORegistryEntry.mm +++ b/src/IORegistryEntry.mm -@@ -513,3 +513,86 @@ kern_return_t is_io_registry_entry_get_property_bin +@@ -1,11 +1,15 @@ + #include "IORegistryEntry.h" + #include + #include ++#include + #include + #include + #include + #include + #include ++#include ++#import ++#import + #include "IOIterator.h" + extern "C" { + #include "iokitmigServer.h" +@@ -29,6 +33,12 @@ IORegistryEntry::~IORegistryEntry() + { + std::unique_lock l(m_entriesByIdMutex); + m_entriesById.erase(m_id); ++ // Registry requests and object teardown run on iokitd's main dispatch queue. ++ // Detach both directions so a later traversal cannot follow a deleted entry. ++ for (auto& plane : m_parents) ++ for (auto* parent : plane.second) parent->m_children[plane.first].erase(this); ++ for (auto& plane : m_children) ++ for (auto* child : plane.second) child->m_parents[plane.first].erase(this); + } + + IORegistryEntry* IORegistryEntry::findById(uint64_t id) +@@ -79,29 +89,42 @@ std::set IORegistryEntry::getChildren(const char* plane) + + void IORegistryEntry::registerInPlane(const char* plane, const char* pathName, IORegistryEntry* parent) + { ++ if (!plane || !*plane || !pathName || !*pathName || !parent || parent == this) return; ++ std::set visited; ++ std::vector pending { parent }; ++ while (!pending.empty()) { ++ auto* current = pending.back(); pending.pop_back(); ++ if (current == this) return; // refuse cycles, including indirect ones ++ if (!visited.insert(current).second) continue; ++ for (auto* ancestor : current->getParents(plane)) pending.push_back(ancestor); ++ } + m_name[plane] = pathName; ++ if (parent == root()) parent->m_name.emplace(plane, "Root"); + + auto it = m_parents.find(plane); + + if (it == m_parents.end()) +- it = m_parents.insert(std::make_pair(std::string_view(plane), std::set())).first; ++ it = m_parents.insert(std::make_pair(std::string(plane), std::set())).first; + + it->second.insert(parent); + + it = parent->m_children.find(plane); + if (it == parent->m_children.end()) +- it = parent->m_children.insert(std::make_pair(std::string_view(plane), std::set())).first; ++ it = parent->m_children.insert(std::make_pair(std::string(plane), std::set())).first; + + it->second.insert(this); + } + + std::string IORegistryEntry::getPath(const char* plane) + { ++ if (!inPlane(plane)) return "?unsup_plane?"; + std::stack components; ++ std::set visited; + IORegistryEntry* cur = this; + + while (true) + { ++ if (!visited.insert(cur).second) return "?unsup_plane?"; + std::set parents = cur->getParents(plane); + if (parents.empty()) + { +@@ -113,18 +136,22 @@ std::string IORegistryEntry::getPath(const char* plane) + + components.push(cur->getName(plane)); + +- IORegistryEntry* parent = *parents.begin(); ++ IORegistryEntry* parent = nullptr; ++ for (auto* candidate : parents) if (candidate->inPlane(plane)) { parent = candidate; break; } ++ if (!parent) return "?unsup_plane?"; + cur = parent; + } + + std::string path = plane; + path += ":"; ++ if (components.empty()) path += "/"; + + while (!components.empty()) + { + path += "/"; + path += components.top(); + components.pop(); ++ if (path.size() > 65535) return "?unsup_plane?"; + } + + return path; +@@ -166,11 +193,15 @@ kern_return_t is_io_registry_entry_get_path + io_string_t path + ) + { ++ if (!path) return kIOReturnBadArgument; + IORegistryEntry* e = dynamic_cast(IOObject::lookup(registry_entry)); + if (!e) + return kIOReturnBadArgument; + ++ if (!plane || !memchr(plane, 0, sizeof(io_name_t)) || !e->inPlane(plane)) return kIOReturnBadArgument; + std::string mypath = e->getPath(plane); ++ if (mypath == "?unsup_plane?") return kIOReturnBadArgument; ++ if (mypath.size() >= sizeof(io_string_t)) { path[0] = 0; return kIOReturnNoSpace; } + strlcpy(path, mypath.c_str(), sizeof(io_string_t)); + + return kIOReturnSuccess; +@@ -386,55 +417,59 @@ kern_return_t is_io_registry_entry_get_location_in_plane + return KERN_NOT_SUPPORTED; + } + +-kern_return_t is_io_registry_entry_from_path +-( +- mach_port_t master_port, +- io_string_t path, +- mach_port_t *registry_entry +-) ++bool IORegistryEntry::inPlane(const char* plane) const + { +- *registry_entry = 0; +- +- std::vector components; +- std::istringstream iss(path); +- +- for (std::string token; std::getline(iss, token, '/'); ) +- { +- if (!token.empty()) +- components.push_back(std::move(token)); ++ if (!plane || !*plane || m_name.find(plane) == m_name.end()) return false; ++ std::set visited; ++ std::vector pending { this }; ++ while (!pending.empty()) { ++ auto* current = pending.back(); pending.pop_back(); ++ if (current == root()) return true; ++ if (!visited.insert(current).second) continue; ++ auto parents = current->m_parents.find(plane); ++ if (parents != current->m_parents.end()) ++ for (auto* parent : parents->second) pending.push_back(parent); + } +- +- if (components.empty()) +- return kIOReturnBadArgument; +- +- std::string& planeName = components[0]; +- if (planeName[planeName.length()-1] != ':') +- return kIOReturnBadArgument; +- else +- planeName.resize(planeName.length()-1); // remove the final colon +- +- IORegistryEntry* current = IORegistryEntry::root(); +- for (size_t i = 1; i < components.size(); i++) +- { +- auto children = current->getChildren(planeName.c_str()); +- IORegistryEntry* next = nullptr; ++ return false; ++} + +- for (IORegistryEntry* e : children) +- { +- if (strcasecmp(e->getName(planeName.c_str()).c_str(), components[i].c_str()) == 0) +- { +- next = e; +- break; +- } ++IORegistryEntry* IORegistryEntry::fromPath(std::string_view path) ++{ ++ if (path.empty() || path.size() > 65535 || path.find('\0') != std::string_view::npos) return nullptr; ++ auto colon = path.find(':'); ++ if (colon == 0 || colon == std::string_view::npos || colon >= sizeof(io_name_t)) return nullptr; ++ std::string plane(path.substr(0, colon)); ++ if (plane.find('/') != std::string::npos) return nullptr; ++ auto* current = root(); ++ if (!current->inPlane(plane.c_str())) return nullptr; ++ path.remove_prefix(colon + 1); ++ if (!path.empty() && path.front() != '/') return nullptr; ++ while (!path.empty()) { ++ path.remove_prefix(1); ++ auto slash = path.find('/'); ++ auto name = path.substr(0, slash); ++ if (!name.empty()) { ++ if (name == "." || name == "..") return nullptr; ++ IORegistryEntry* next = nullptr; ++ for (auto* child : current->getChildren(plane.c_str())) ++ if (child->getName(plane.c_str()) == name) { next = child; break; } ++ if (!next) return nullptr; ++ current = next; + } +- +- if (!next) +- return kIOReturnNoDevice; +- current = next; ++ if (slash == std::string_view::npos) break; ++ path.remove_prefix(slash); + } ++ return current; // borrowed until this main-queue request completes ++} + +- *registry_entry = current->port(); +- ++kern_return_t is_io_registry_entry_from_path(mach_port_t master_port, io_string_t path, mach_port_t* registry_entry) ++{ ++ if (!registry_entry) return kIOReturnBadArgument; ++ *registry_entry = MACH_PORT_NULL; ++ if (!path || !memchr(path, 0, sizeof(io_string_t))) return kIOReturnBadArgument; ++ auto* entry = IORegistryEntry::fromPath(path); ++ if (!entry) return kIOReturnNoDevice; ++ *registry_entry = entry->port(); + return kIOReturnSuccess; + } + +@@ -513,3 +548,189 @@ kern_return_t is_io_registry_entry_get_property_bin return kr; } @@ -116,6 +333,109 @@ index 50c10fe..bd501cf 100644 + CFRelease(data); + return kr; +} ++ ++// OOL input ownership transfers to this server; release it on every exit path. ++kern_return_t is_io_registry_entry_from_path_ool(mach_port_t master_port, io_string_inband_t path, ++ io_buf_ptr_t path_ool, mach_msg_type_number_t path_oolCnt, kern_return_t* result, mach_port_t* registry_entry) ++{ ++ struct Input { ++ io_buf_ptr_t bytes; mach_msg_type_number_t size; ++ ~Input() { if (bytes && size) vm_deallocate(mach_task_self(), (vm_address_t)bytes, size); } ++ } input {path_ool, path_oolCnt}; ++ if (!result || !registry_entry) return kIOReturnBadArgument; ++ *registry_entry = MACH_PORT_NULL; ++ *result = kIOReturnBadArgument; ++ std::string_view fullPath; ++ if (path_oolCnt || path_ool) { ++ if (!path_ool || !path_oolCnt || path_oolCnt > 65536 || !path || path[0] || ++ strnlen(path_ool, path_oolCnt) != path_oolCnt - 1) return kIOReturnSuccess; ++ fullPath = std::string_view(path_ool, path_oolCnt - 1); ++ } else { ++ if (!path || !memchr(path, 0, sizeof(io_string_inband_t))) return kIOReturnSuccess; ++ fullPath = path; ++ } ++ auto* entry = IORegistryEntry::fromPath(fullPath); ++ if (entry) { *registry_entry = entry->port(); *result = kIOReturnSuccess; } ++ else *result = kIOReturnNoDevice; ++ return kIOReturnSuccess; ++} ++ ++kern_return_t is_io_registry_entry_get_path_ool(mach_port_t registry_entry, io_name_t plane, ++ io_string_inband_t path, io_buf_ptr_t* path_ool, mach_msg_type_number_t* path_oolCnt) ++{ ++ if (!path || !path_ool || !path_oolCnt) return kIOReturnBadArgument; ++ path[0] = 0; *path_ool = nullptr; *path_oolCnt = 0; ++ auto* entry = dynamic_cast(IOObject::lookup(registry_entry)); ++ if (!entry || !plane || !memchr(plane, 0, sizeof(io_name_t)) || !entry->inPlane(plane)) return kIOReturnBadArgument; ++ auto value = entry->getPath(plane); ++ if (value == "?unsup_plane?") return kIOReturnBadArgument; ++ if (value.size() < sizeof(io_string_inband_t)) { ++ strlcpy(path, value.c_str(), sizeof(io_string_inband_t)); ++ } else { ++ // MIG uses physicalcopy with deallocate=false. The main queue completes ++ // each reply before its next request, so one bounded reusable buffer ++ // keeps the sender's bytes alive without leaking one VM allocation/call. ++ static std::string reply; ++ reply.assign(value); ++ *path_ool = const_cast(reply.data()); ++ *path_oolCnt = reply.size(); // IOKit creates a CFString from bytes, excluding NUL ++ } ++ return kIOReturnSuccess; ++} ++ ++kern_return_t is_io_registry_entry_in_plane(mach_port_t registry_entry, io_name_t plane, boolean_t* inPlane) ++{ ++ if (!inPlane) return kIOReturnBadArgument; ++ *inPlane = FALSE; ++ auto* entry = dynamic_cast(IOObject::lookup(registry_entry)); ++ if (!entry || !plane || !memchr(plane, 0, sizeof(io_name_t))) return kIOReturnBadArgument; ++ *inPlane = entry->inPlane(plane); ++ return kIOReturnSuccess; ++} ++ ++kern_return_t is_io_registry_entry_get_property_bytes(mach_port_t registry_entry, io_name_t property_name, ++ io_struct_inband_t data, mach_msg_type_number_t* dataCnt) ++{ ++ if (!dataCnt) return kIOReturnBadArgument; ++ size_t capacity = std::min(*dataCnt, sizeof(io_struct_inband_t)); ++ *dataCnt = 0; ++ auto* entry = dynamic_cast(IOObject::lookup(registry_entry)); ++ if (!entry || !property_name || !memchr(property_name, 0, sizeof(io_name_t))) return kIOReturnBadArgument; ++ NSString* key = [NSString stringWithUTF8String:property_name]; ++ if (!key) return kIOReturnBadArgument; ++ id value = [entry->getProperties() objectForKey:key]; ++ if (!value) return kIOReturnNoResources; ++ const void* bytes = nullptr; ++ size_t length = 0; ++ uint64_t number = 0; ++ if ([value isKindOfClass:[NSData class]]) { ++ bytes = [value bytes]; length = [value length]; ++ } else if ([value isKindOfClass:[NSString class]]) { ++ bytes = [value UTF8String]; ++ if (!bytes) return kIOReturnBadArgument; ++ length = strlen((const char*)bytes) + 1; ++ } else if ([value isKindOfClass:[NSNumber class]] && CFGetTypeID((CFTypeRef)value) == CFBooleanGetTypeID()) { ++ bytes = [value boolValue] ? "Yes" : "No"; ++ length = [value boolValue] ? sizeof("Yes") : sizeof("No"); ++ } else if ([value isKindOfClass:[NSNumber class]]) { ++ const char* type = [value objCType]; ++ switch (type ? type[0] : 0) { ++ case 'c': case 'C': case 'B': length = 1; break; ++ case 's': case 'S': length = 2; break; ++ case 'i': case 'I': length = 4; break; ++ case 'l': case 'L': length = sizeof(long); break; ++ case 'q': case 'Q': length = 8; break; ++ default: return kIOReturnBadArgument; ++ } ++ number = [value unsignedLongLongValue]; bytes = &number; ++ } else return kIOReturnBadArgument; ++ if (length > std::numeric_limits::max()) return kIOReturnNoSpace; ++ if (length > capacity) { *dataCnt = capacity; return kIOReturnIPCError; } ++ *dataCnt = length; ++ if (length && !data) { *dataCnt = 0; return kIOReturnBadArgument; } ++ if (length) memcpy(data, bytes, length); ++ return kIOReturnSuccess; ++} diff --git a/src/IOSurfaceRoot.mm b/src/IOSurfaceRoot.mm index d7e6fab..d860582 100644 --- a/src/IOSurfaceRoot.mm @@ -503,13 +823,25 @@ index a568ecc..c2d4063 100644 /* Routine io_ps_copy_chargelog */ diff --git a/src/stubs.c b/src/stubs.c -index 91d11a1..37551e1 100644 +index 91d11a1..5a00091 100644 --- a/src/stubs.c +++ b/src/stubs.c -@@ -45,15 +45,6 @@ kern_return_t is_io_registry_entry_get_property_bytes +@@ -33,26 +33,7 @@ kern_return_t is_io_registry_iterator_exit_entry return KERN_NOT_SUPPORTED; } +-kern_return_t is_io_registry_entry_get_property_bytes +-( +- mach_port_t registry_entry, +- io_name_t property_name, +- io_struct_inband_t data, +- mach_msg_type_number_t *dataCnt +-) +-{ +- STUB(); +- return KERN_NOT_SUPPORTED; +-} + -kern_return_t is_io_service_close -( - mach_port_t connection @@ -518,11 +850,28 @@ index 91d11a1..37551e1 100644 - STUB(); - return KERN_NOT_SUPPORTED; -} -- + kern_return_t is_io_connect_get_service ( - mach_port_t connection, -@@ -325,20 +316,6 @@ kern_return_t is_io_service_add_notification +@@ -124,16 +105,7 @@ kern_return_t is_io_registry_entry_set_properties + return KERN_NOT_SUPPORTED; + } + +-kern_return_t is_io_registry_entry_in_plane +-( +- mach_port_t registry_entry, +- io_name_t plane, +- boolean_t *inPlane +-) +-{ +- STUB(); +- return KERN_NOT_SUPPORTED; +-} ++ + + kern_return_t is_io_service_get_busy_state + ( +@@ -325,20 +297,6 @@ kern_return_t is_io_service_add_notification return KERN_NOT_SUPPORTED; } @@ -543,7 +892,7 @@ index 91d11a1..37551e1 100644 kern_return_t is_io_service_acknowledge_notification ( mach_port_t service, -@@ -439,22 +416,6 @@ kern_return_t is_io_object_get_bundle_identifier +@@ -439,22 +397,6 @@ kern_return_t is_io_object_get_bundle_identifier return KERN_NOT_SUPPORTED; } @@ -566,7 +915,7 @@ index 91d11a1..37551e1 100644 kern_return_t is_io_connect_map_memory_into_task ( mach_port_t connection, -@@ -533,20 +494,6 @@ kern_return_t is_io_service_add_notification_64 +@@ -533,20 +475,6 @@ kern_return_t is_io_service_add_notification_64 return KERN_NOT_SUPPORTED; } @@ -587,7 +936,7 @@ index 91d11a1..37551e1 100644 kern_return_t is_io_service_add_notification_ool_64 ( mach_port_t master_port, -@@ -642,18 +589,6 @@ kern_return_t is_io_server_version +@@ -642,18 +570,6 @@ kern_return_t is_io_server_version } @@ -606,7 +955,7 @@ index 91d11a1..37551e1 100644 kern_return_t is_io_service_match_property_table_bin ( mach_port_t service, -@@ -682,22 +617,6 @@ kern_return_t is_io_service_add_notification_bin +@@ -682,48 +598,9 @@ kern_return_t is_io_service_add_notification_bin return KERN_NOT_SUPPORTED; } @@ -625,11 +974,38 @@ index 91d11a1..37551e1 100644 - STUB(); - return KERN_NOT_SUPPORTED; -} -- - kern_return_t is_io_registry_entry_from_path_ool + +-kern_return_t is_io_registry_entry_from_path_ool +-( +- mach_port_t master_port, +- io_string_inband_t path, +- io_buf_ptr_t path_ool, +- mach_msg_type_number_t path_oolCnt, +- kern_return_t *result, +- mach_port_t *registry_entry +-) +-{ +- STUB(); +- return KERN_NOT_SUPPORTED; +-} + +-kern_return_t is_io_registry_entry_get_path_ool +- ( +- mach_port_t registry_entry, +- io_name_t plane, +- io_string_inband_t path, +- io_buf_ptr_t *path_ool, +- mach_msg_type_number_t *path_oolCnt +-) +-{ +- STUB(); +- return KERN_NOT_SUPPORTED; +-} ++ + + kern_return_t is_io_device_tree_entry_exists_with_name ( - mach_port_t master_port, -@@ -736,31 +655,3 @@ kern_return_t is_io_device_tree_entry_exists_with_name +@@ -736,31 +613,3 @@ kern_return_t is_io_device_tree_entry_exists_with_name return KERN_NOT_SUPPORTED; } @@ -690,11 +1066,12 @@ index 0000000..38ad635 +#endif diff --git a/src/IOPlatformExpertDevice.mm b/src/IOPlatformExpertDevice.mm new file mode 100644 -index 0000000..e330569 +index 0000000..71b9f4d --- /dev/null +++ b/src/IOPlatformExpertDevice.mm -@@ -0,0 +1,93 @@ +@@ -0,0 +1,98 @@ +#include "IOPlatformExpertDevice.h" ++#include "PlatformIdentity.h" +#import +#import +#import @@ -770,6 +1147,10 @@ index 0000000..e330569 + properties[@"IOPlatformUUID"] = uuid; + properties[@"IOPlatformSerialNumber"] = serial; + } ++ // Opt-in, immutable startup snapshot. No configuration preserves the existing ++ // per-host identifiers; daemon restart is the explicit activation boundary. ++ static NSDictionary* overrides = [DarlingPlatformIdentity::load("/private/etc/darling-platform.plist") copy]; ++ if (overrides) [properties addEntriesFromDictionary:overrides]; + return properties; +} + @@ -3115,3 +3496,111 @@ index 7a6fcfa..26a7d68 100644 + m_changedDPMS = false; } } +diff --git a/src/IORegistryEntry.h b/src/IORegistryEntry.h +index 90155ed..e955bd4 100644 +--- a/src/IORegistryEntry.h ++++ b/src/IORegistryEntry.h +@@ -19,6 +19,8 @@ public: + + std::string_view getPathName(const char* plane); + std::string getPath(const char* plane); ++ bool inPlane(const char* plane) const; ++ static IORegistryEntry* fromPath(std::string_view path); + std::set getParents(const char* plane); + std::set getChildren(const char* plane); + std::string getName(const char* plane = nullptr); +@@ -32,8 +34,8 @@ public: + void registerInPlane(const char* plane, const char* pathName, IORegistryEntry* parent); + protected: + // The key is the plane name, e.g. kIOServicePlane (as declared in IOKit/IOKitKeys.h) +- std::unordered_map m_name; +- std::unordered_map> m_parents, m_children; ++ std::unordered_map m_name; ++ std::unordered_map> m_parents, m_children; + uint64_t m_id; + + static std::unordered_map m_entriesById; +diff --git a/src/PlatformIdentity.h b/src/PlatformIdentity.h +new file mode 100644 +index 0000000..1f83b65 +--- /dev/null ++++ b/src/PlatformIdentity.h +@@ -0,0 +1,78 @@ ++#ifndef DARLING_PLATFORM_IDENTITY_H ++#define DARLING_PLATFORM_IDENTITY_H ++#import ++#include ++#include ++#include ++#include ++ ++namespace DarlingPlatformIdentity { ++inline bool printable(id value, size_t maximum) { ++ if (![value isKindOfClass:[NSString class]]) return false; ++ const char* text = [value UTF8String]; ++ if (!text) return false; ++ size_t length = strlen(text); ++ if (!length || length > maximum || length != [value length]) return false; ++ for (size_t i = 0; i < length; ++i) if ((unsigned char)text[i] < 32 || (unsigned char)text[i] > 126) return false; ++ return true; ++} ++ ++// Validate the entire schema before exposing any overrides. Strings on device ++// tree properties are NUL-terminated NSData; serial/UUID retain NSString types. ++inline NSDictionary* properties(id configuration) { ++ if (![configuration isKindOfClass:[NSDictionary class]] || [configuration count] != 2) return nil; ++ id schema = [configuration objectForKey:@"schema"]; ++ if (![schema isKindOfClass:[NSNumber class]] || CFGetTypeID((CFTypeRef)schema) == CFBooleanGetTypeID() || ++ strchr("fd", [schema objCType][0]) || [schema longLongValue] != 1) return nil; ++ id source = [configuration objectForKey:@"properties"]; ++ if (![source isKindOfClass:[NSDictionary class]] || [source count] > 9) return nil; ++ NSMutableDictionary* result = [NSMutableDictionary dictionary]; ++ for (id key in source) { ++ if (![key isKindOfClass:[NSString class]]) return nil; ++ id value = [source objectForKey:key]; ++ if ([key isEqual:@"IOPlatformUUID"]) { ++ if (!printable(value, 36) || [value length] != 36) return nil; ++ uuid_t uuid; ++ if (uuid_parse([value UTF8String], uuid) || uuid_is_null(uuid)) return nil; ++ uuid_string_t text; uuid_unparse_upper(uuid, text); ++ result[key] = [NSString stringWithUTF8String:text]; ++ unsigned char bytes[16]; memcpy(bytes, uuid, sizeof(bytes)); ++ for (int i = 0; i < 4; ++i) bytes[i] = uuid[3-i]; ++ bytes[4] = uuid[5]; bytes[5] = uuid[4]; bytes[6] = uuid[7]; bytes[7] = uuid[6]; ++ NSData* systemID = [NSData dataWithBytes:bytes length:16]; ++ if (source[@"system-id"] && ![source[@"system-id"] isEqual:systemID]) return nil; ++ result[@"system-id"] = systemID; ++ } else if ([key isEqual:@"ROM"] || [key isEqual:@"system-id"]) { ++ if (![value isKindOfClass:[NSData class]] || [value length] != ([key isEqual:@"ROM"] ? 6 : 16)) return nil; ++ result[key] = value; ++ } else if ([key isEqual:@"IOPlatformSerialNumber"]) { ++ if (!printable(value, 64)) return nil; ++ result[key] = value; ++ } else if ([key isEqual:@"manufacturer"] || [key isEqual:@"model"] || [key isEqual:@"product-name"] || ++ [key isEqual:@"board-id"] || [key isEqual:@"mlb-serial-number"]) { ++ if (!printable(value, [key isEqual:@"mlb-serial-number"] ? 64 : 128)) return nil; ++ const char* text = [value UTF8String]; ++ result[key] = [NSData dataWithBytes:text length:strlen(text)+1]; ++ } else return nil; ++ } ++ if (result[@"model"] && !result[@"product-name"]) result[@"product-name"] = result[@"model"]; ++ return result; ++} ++ ++inline NSDictionary* load(const char* path) { ++ FILE* file = fopen(path, "rb"); ++ if (!file) return nil; // no overrides is the normal, unchanged default ++ std::array buffer; ++ size_t size = fread(buffer.data(), 1, buffer.size(), file); ++ bool failed = ferror(file); fclose(file); ++ if (!failed && size <= 65536) { ++ NSData* data = [NSData dataWithBytes:buffer.data() length:size]; ++ id config = [NSPropertyListSerialization propertyListWithData:data options:NSPropertyListImmutable format:NULL error:NULL]; ++ NSDictionary* overrides = properties(config); ++ if (overrides) return overrides; ++ } ++ fprintf(stderr, "iokitd: invalid darling-platform.plist; retaining default platform properties\n"); ++ return nil; ++} ++} ++#endif diff --git a/tests/test_platform_identity.py b/tests/test_platform_identity.py new file mode 100644 index 0000000..45602e9 --- /dev/null +++ b/tests/test_platform_identity.py @@ -0,0 +1,157 @@ +"""Disposable-prefix checks for explicit platform identity configuration.""" +import importlib.util +import json +import os +from pathlib import Path +import plistlib +import stat +import subprocess +import sys +import tempfile +import unittest +import uuid + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / 'tools/configure-darling-platform.py' +spec = importlib.util.spec_from_file_location('platform_config', SCRIPT) +platform = importlib.util.module_from_spec(spec) +spec.loader.exec_module(platform) +UUID = '00112233-4455-4677-8899-AABBCCDDEEFF' + + +class PlatformIdentityChecks(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.prefix = self.root / 'prefix with spaces' + + def source(self, name, data): + path = self.root / name + path.write_bytes(data) + return path + + def test_opencore_import_and_uuid_byte_order(self): + source = self.source('OpenCore.plist', plistlib.dumps({'PlatformInfo': {'Generic': { + 'SystemProductName': 'ExampleModel1,1', 'SystemSerialNumber': 'MY-SERIAL', + 'SystemUUID': UUID, 'MLB': 'MY-BOARD', 'ROM': bytes.fromhex('0123456789AB'), + 'SpoofVendor': True}}})) + result = platform.load(source) + self.assertEqual(result['model'], result['product-name']) + self.assertEqual(result['IOPlatformUUID'], UUID) + self.assertEqual(result['system-id'].hex().upper(), '33221100554477468899AABBCCDDEEFF') + self.assertEqual(result['ROM'], bytes.fromhex('0123456789AB')) + self.assertNotIn('SpoofVendor', result) + + def test_gensmbios_partial_import_does_not_generate_missing_identity(self): + source = self.source('identity.json', json.dumps({'Type': 'ExampleModel1,1', 'Serial': 'SUPPLIED'}).encode()) + result = platform.load(source) + self.assertEqual(set(result), {'model', 'product-name', 'IOPlatformSerialNumber'}) + self.assertNotIn('IOPlatformUUID', result) + + def test_invalid_values_are_rejected(self): + for properties in [ + {'IOPlatformUUID': 'not-a-uuid'}, {'IOPlatformUUID': str(uuid.UUID(int=0))}, + {'IOPlatformUUID': UUID, 'system-id': bytes(16)}, {'ROM': bytes(7)}, + {'ROM': 'xyz'}, {'model': 'bad\x00name'}, {'model': 'bad\nname'}, + {'model': 'é'}, {'model': 'X'*129}, {'IOPlatformSerialNumber': ''}, + {'IOPlatformSerialNumber': 123}, {'unknown': 'value'}, {'ROM': True}, + ]: + with self.subTest(properties=properties), self.assertRaises((ValueError, TypeError)): + platform.validate(properties) + + def test_schema_versions_types_and_unknown_fields(self): + for payload in [{'schema': 2, 'properties': {}}, {'schema': True, 'properties': {}}, + {'schema': 1, 'properties': {}, 'extra': 1}, {'schema': 1, 'properties': []}]: + source = self.source('bad.json', json.dumps(payload).encode()) + with self.subTest(payload=payload), self.assertRaises(ValueError): platform.load(source) + source = self.source('huge.json', b' ' * (platform.MAX_SIZE + 1)) + with self.assertRaises(ValueError): platform.load(source) + + def test_legacy_migration_never_executes_or_rewrites_source(self): + marker = self.root / 'executed' + raw = ('SMBIOS_MODEL="ExampleModel1,1"\nSMBIOS_SERIAL_NUMBER="SUPPLIED"\n' + f'SMBIOS_CPU_SPEED="$(touch {marker})"\n').encode() + source = self.source('darling-smbios.conf', raw) + properties = platform.load(source) + self.assertTrue(platform.save(self.prefix, properties)) + self.assertEqual(source.read_bytes(), raw) + self.assertFalse(marker.exists()) + self.assertNotIn('SMBIOS_CPU_SPEED', properties) + + def test_stable_write_backup_permissions_and_roundtrip(self): + first = {'IOPlatformSerialNumber': 'FIRST', 'IOPlatformUUID': UUID} + self.assertTrue(platform.save(self.prefix, first)) + path = platform.destination(self.prefix) + original = path.read_bytes() + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + self.assertFalse(platform.save(self.prefix, platform.load(path))) + self.assertEqual(path.read_bytes(), original) + self.assertTrue(platform.save(self.prefix, {'IOPlatformSerialNumber': 'SECOND'})) + self.assertEqual(path.with_name(path.name+'.before-platform-change').read_bytes(), original) + self.assertEqual(platform.load(path), {'IOPlatformSerialNumber': 'SECOND'}) + + def test_prefix_isolation(self): + other = self.root / 'other' + platform.save(self.prefix, {'IOPlatformSerialNumber': 'FIRST'}) + platform.save(other, {'IOPlatformSerialNumber': 'SECOND'}) + self.assertEqual(platform.load(platform.destination(self.prefix))['IOPlatformSerialNumber'], 'FIRST') + self.assertEqual(platform.load(platform.destination(other))['IOPlatformSerialNumber'], 'SECOND') + + def test_no_implicit_migration_without_an_override_file(self): + env = dict(os.environ, DPREFIX=str(self.prefix)) + result = subprocess.run([sys.executable, str(SCRIPT)], env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)['properties'], {}) + self.assertFalse(self.prefix.exists()) + + def test_preview_and_uuid_replacement_preserve_other_values(self): + platform.save(self.prefix, {'IOPlatformSerialNumber': 'FIRST', 'IOPlatformUUID': UUID}) + replacement = '11112233-4455-4677-8899-AABBCCDDEEFF' + command = [sys.executable, str(SCRIPT), '--prefix', str(self.prefix), '--set', 'IOPlatformUUID='+replacement] + path = platform.destination(self.prefix) + before = path.read_bytes() + preview = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(preview.returncode, 0, preview.stderr) + self.assertEqual(path.read_bytes(), before) + written = subprocess.run([*command, '--write'], capture_output=True, text=True) + self.assertEqual(written.returncode, 0, written.stderr) + result = platform.load(path) + self.assertEqual(result['IOPlatformSerialNumber'], 'FIRST') + self.assertEqual(result['IOPlatformUUID'], replacement) + self.assertEqual(result['system-id'], uuid.UUID(replacement).bytes_le) + + def test_symlink_escape_is_rejected(self): + outside = self.root / 'outside' + outside.mkdir() + (self.prefix / 'private').mkdir(parents=True) + (self.prefix / 'private/etc').symlink_to(outside, target_is_directory=True) + with self.assertRaises(ValueError): platform.save(self.prefix, {}) + self.assertEqual(list(outside.iterdir()), []) + + def test_live_prefix_and_unknown_existing_schema_are_not_overwritten(self): + self.prefix.mkdir() + marker = self.prefix / '.init.pid' + marker.write_text(str(os.getpid())) + with self.assertRaises(ValueError): platform.save(self.prefix, {}) + marker.unlink() + path = platform.destination(self.prefix) + path.parent.mkdir(parents=True) + bad = plistlib.dumps({'schema': 999, 'properties': {}}) + path.write_bytes(bad) + with self.assertRaises(ValueError): platform.save(self.prefix, {}) + self.assertEqual(path.read_bytes(), bad) + + def test_duplicate_json_and_invalid_live_marker_are_rejected(self): + duplicate = self.source('duplicate.json', b'{"schema":1,"schema":2,"properties":{}}') + with self.assertRaises(ValueError): platform.load(duplicate) + self.prefix.mkdir() + (self.prefix / '.init.pid').write_text('0') + with self.assertRaises(ValueError): platform.save(self.prefix, {}) + + def test_malformed_xml_reports_an_error_without_a_traceback(self): + source = self.source('bad.plist', b'') + result = subprocess.run([sys.executable, str(SCRIPT), '--prefix', str(self.prefix), '--import', str(source)], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn('Traceback', result.stderr) + self.assertFalse(self.prefix.exists()) diff --git a/tools/build-riot-registry-compatibility-check.py b/tools/build-riot-registry-compatibility-check.py new file mode 100644 index 0000000..e20c2f2 --- /dev/null +++ b/tools/build-riot-registry-compatibility-check.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Build an isolated iokitd candidate and a real-object registry/identity probe.""" +import argparse +from pathlib import Path +import re +import shlex +import subprocess + +root = Path(__file__).resolve().parent.parent +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--source', type=Path, default=root / 'build/darling-src/src/external/iokitd', + help='Prepared iokitd source directory') +args = parser.parse_args() +folder = root / 'build/darling-src/build/src/external/iokitd' +flags = {} +resource = subprocess.check_output(['clang', '-print-resource-dir'], text=True).strip() +for line in (folder / 'CMakeFiles/iokitd.dir/flags.make').read_text().splitlines(): + if line.startswith(('C_', 'CXX_')) and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = [re.sub(r'/usr/lib/clang/\d+/include', resource + '/include', value) for value in shlex.split(value)] +link = shlex.split((folder / 'CMakeFiles/iokitd.dir/link.txt').read_text()) + + +def compile(source, output, kind): + subprocess.run(['clang++' if kind == 'CXX' else 'clang', *flags[kind + '_DEFINES'], + '-I' + str(args.source / 'src'), *flags[kind + '_INCLUDES'], *flags[kind + '_FLAGS'], + '-O2', '-c', str(source), '-o', str(output)], check=True) + + +for argument in link[:]: + if argument.startswith('CMakeFiles/iokitd.dir/src/') and argument.endswith('.o'): + relative = argument.split('iokitd.dir/', 1)[1][:-2] + source = args.source / relative + output = root / ('build/registry-candidate-' + source.name + '.o') + compile(source, output, 'CXX' if source.suffix in ('.mm', '.cpp') else 'C') + link[link.index(argument)] = str(output) +link[link.index('-o') + 1] = str(root / 'build/riot-iokitd-candidate') +subprocess.run(link, cwd=folder, check=True) +probe = root / 'build/registry-compatibility-probe.o' +compile(root / 'tools/riot-registry-compatibility-check.mm', probe, 'CXX') +link[link.index(str(root / 'build/registry-candidate-main.mm.o'))] = str(probe) +user = root / 'build/registry-compatibility-client.o' +compile(folder / 'iokitmigUser.c', user, 'C') +link.append(str(user)) +link[link.index('-o') + 1] = str(root / 'build/riot-registry-compatibility-check') +subprocess.run(link, cwd=folder, check=True) +print('Built isolated iokitd candidate and registry compatibility probe') diff --git a/tools/configure-darling-platform.py b/tools/configure-darling-platform.py new file mode 100644 index 0000000..9ba1c87 --- /dev/null +++ b/tools/configure-darling-platform.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Preview or explicitly save per-prefix IOKit platform overrides; defaults stay unchanged.""" +import argparse +import json +import os +from pathlib import Path +import plistlib +import shlex +import tempfile +import uuid +from xml.parsers.expat import ExpatError + +MAX_SIZE = 65536 +STRINGS = {'IOPlatformSerialNumber': 64, 'manufacturer': 128, 'model': 128, + 'product-name': 128, 'board-id': 128, 'mlb-serial-number': 64} +BYTES = {'ROM': 6, 'system-id': 16} +ALIASES = { + 'SystemProductName': 'model', 'Type': 'model', 'SMBIOS_MODEL': 'model', + 'SMBIOS_PRODUCT_NAME': 'product-name', 'SMBIOS_MANUFACTURER': 'manufacturer', + 'SMBIOS_BOARD_ID': 'board-id', 'SystemSerialNumber': 'IOPlatformSerialNumber', + 'Serial': 'IOPlatformSerialNumber', 'SMBIOS_SERIAL_NUMBER': 'IOPlatformSerialNumber', + 'SystemUUID': 'IOPlatformUUID', 'SmUUID': 'IOPlatformUUID', 'SMBIOS_PLATFORM_UUID': 'IOPlatformUUID', + 'MLB': 'mlb-serial-number', 'Board Serial': 'mlb-serial-number', 'SMBIOS_MLB': 'mlb-serial-number', + 'SMBIOS_ROM': 'ROM', 'SMBIOS_SYSTEM_ID': 'system-id', +} + + +def validate(properties): + if not isinstance(properties, dict) or len(properties) > 9: + raise ValueError('properties must be a dictionary of supported identity fields') + result = {} + for key, value in properties.items(): + if key in STRINGS: + if not isinstance(value, str) or not 1 <= len(value) <= STRINGS[key] or any(not 32 <= ord(c) <= 126 for c in value): + raise ValueError('Invalid printable string for ' + key) + elif key == 'IOPlatformUUID': + if not isinstance(value, str): + raise ValueError('IOPlatformUUID must be a UUID string') + parsed = uuid.UUID(value) + if not parsed.int: + raise ValueError('IOPlatformUUID must not be zero') + value = str(parsed).upper() + elif key in BYTES: + if isinstance(value, str): + value = bytes.fromhex(value.replace(':', '').replace('-', '')) + if not isinstance(value, bytes) or len(value) != BYTES[key]: + raise ValueError(key + ' has an invalid byte length') + else: + raise ValueError('Unsupported identity field: ' + str(key)) + result[key] = value + if 'model' in result: + result.setdefault('product-name', result['model']) + if 'IOPlatformUUID' in result: + system_id = uuid.UUID(result['IOPlatformUUID']).bytes_le + if 'system-id' in result and result['system-id'] != system_id: + raise ValueError('system-id does not match IOPlatformUUID') + result['system-id'] = system_id + return result + + +def load(source): + with Path(source).open("rb") as stream: + raw = stream.read(MAX_SIZE + 1) + if len(raw) > MAX_SIZE: + raise ValueError('Identity input exceeds 64 KiB') + if str(source).endswith('.conf'): + payload = {} + for line in raw.decode().splitlines(): + if not line.strip() or line.lstrip().startswith('#'): + continue + key, sep, value = line.partition('=') + if not sep: + raise ValueError('Malformed legacy identity line') + values = shlex.split(value, comments=True) + if len(values) != 1 or key.strip() in payload: + raise ValueError('Malformed or duplicate legacy identity field') + payload[key.strip()] = values[0] + else: + def unique_keys(pairs): + result = {} + for key, value in pairs: + if key in result: raise ValueError('Duplicate JSON identity field: ' + key) + result[key] = value + return result + if raw.lstrip()[:1] in (b'{', b'['): + payload = json.loads(raw, object_pairs_hook=unique_keys) + else: + payload = plistlib.loads(raw) + if not isinstance(payload, dict): + raise ValueError('Identity input must be a dictionary') + if 'schema' in payload: + if set(payload) != {'schema', 'properties'} or type(payload['schema']) is not int or payload['schema'] != 1: + raise ValueError('Unsupported platform identity schema') + return validate(payload['properties']) + if 'PlatformInfo' in payload: + payload = payload['PlatformInfo'].get('Generic') if isinstance(payload['PlatformInfo'], dict) else None + elif 'Generic' in payload: + payload = payload['Generic'] + if not isinstance(payload, dict): + raise ValueError('Missing OpenCore PlatformInfo/Generic dictionary') + result = {} + for key, value in payload.items(): + name = ALIASES.get(key, key) + if name not in {*STRINGS, *BYTES, 'IOPlatformUUID'}: + continue # OpenCore includes unrelated boot, firmware and CPU settings. + if name in result and result[name] != value: + raise ValueError('Conflicting imports for ' + name) + result[name] = value + if not result: + raise ValueError('No supported identity fields found') + return validate(result) + + +def destination(prefix): + prefix = Path(prefix).expanduser() + if not prefix.is_absolute(): + raise ValueError('Prefix must be absolute') + path = prefix / 'private/etc/darling-platform.plist' + if path.is_symlink() or not path.resolve().is_relative_to(prefix.resolve()): + raise ValueError('Identity destination must remain inside the selected prefix') + return path + + +def save(prefix, properties): + path = destination(prefix) + prefix = Path(prefix) + # A host write behind Darling's mounted overlay may not be visible. Never + # stop a user's session implicitly or write a second copy behind its back. + marker = prefix / '.init.pid' + if marker.exists(): + try: + text = marker.read_text().strip() + if not text.isdecimal() or int(text) <= 1: + raise ValueError('Invalid .init.pid; verify the selected prefix is stopped before writing') + pid = int(text) + os.kill(pid, 0) + except ProcessLookupError: + pass + else: + raise ValueError('Shut down the selected Darling prefix before writing platform identity') + encoded = plistlib.dumps({'schema': 1, 'properties': validate(properties)}, sort_keys=True) + if path.exists() and path.read_bytes() == encoded: + return False + path.parent.mkdir(parents=True, exist_ok=True) + # Recheck after creating parents, before opening a temporary file. + path = destination(prefix) + if path.exists(): + load(path) # refuse to silently overwrite an unknown schema + backup = path.with_name(path.name + '.before-platform-change') + if not backup.exists(): + fd = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, 'wb') as stream: + stream.write(path.read_bytes()) + fd, temporary = tempfile.mkstemp(prefix='.darling-platform-', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as stream: + stream.write(encoded) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + return True + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--prefix', type=Path) + parser.add_argument('--import', dest='source', type=Path, help='OpenCore plist, GenSMBIOS JSON, legacy .conf, or schema-1 plist/JSON') + parser.add_argument('--set', action='append', default=[], metavar='KEY=VALUE') + parser.add_argument('--write', action='store_true', help='Write validated overrides to a stopped prefix (otherwise preview only)') + args = parser.parse_args(argv) + config = Path(__file__).resolve().parents[1] / 'build/config.json' + saved = json.loads(config.read_text()) if config.exists() else {} + prefix = args.prefix or Path(os.environ.get('DPREFIX') or saved.get('prefix', str(Path.home() / '.darling'))) + try: + path = destination(prefix) + properties = load(path) if path.exists() else {} + if args.source: + incoming = load(args.source) + # Replacing a UUID also replaces its previously derived byte form. + if 'IOPlatformUUID' in incoming: + properties.pop('system-id', None) + properties.update(incoming) + for assignment in args.set: + key, sep, value = assignment.partition('=') + if not sep: + raise ValueError('--set requires KEY=VALUE') + if key == 'IOPlatformUUID': properties.pop('system-id', None) + if key == 'model': properties.pop('product-name', None) + properties[key] = value + properties = validate(properties) + if args.write: + changed = save(prefix, properties) + print('Platform identity ' + ('saved' if changed else 'unchanged') + '; restart iokitd to apply.') + else: + print(json.dumps({'schema': 1, 'properties': properties}, default=lambda value: value.hex().upper(), indent=2)) + except (OSError, ValueError, TypeError, plistlib.InvalidFileException, ExpatError) as error: + parser.exit(1, str(error) + '\n') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/riot-registry-compatibility-check.mm b/tools/riot-registry-compatibility-check.mm new file mode 100644 index 0000000..ee8d157 --- /dev/null +++ b/tools/riot-registry-compatibility-check.mm @@ -0,0 +1,194 @@ +#import +#include "IORegistryEntry.h" +#include "PlatformIdentity.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +extern "C" { +#include "iokitmigServer.h" +#include "iokitmig.h" +} + +// This probe invokes the real daemon objects and MIG handlers on the main +// thread, without registering a daemon or changing the running iokitd. +mach_port_t g_masterPort, g_deathPort, g_powerManagementPort; +struct Fixture : IORegistryEntry { + NSDictionary* getProperties() override { + return @{@"text": @"hello", @"utf8": @"é", @"small": [NSNumber numberWithUnsignedInt:0x12345678], + @"negative": [NSNumber numberWithInt:-3], @"wide": [NSNumber numberWithUnsignedLongLong:0x123456789abcdef0ULL], + @"flag": @YES, @"float": @1.25, @"array": @[], @"large": [NSMutableData dataWithLength:5000]}; + } +}; +static void checkProperties(Fixture& fixture) { + io_struct_inband_t buffer; mach_msg_type_number_t length; + io_name_t key = "text"; + memset(buffer, 0x7e, sizeof(buffer)); length = 2; + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnIPCError); + assert(length == 2 && buffer[0] == 0x7e); + length = sizeof(buffer); + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnSuccess); + assert(length == 6 && !memcmp(buffer, "hello", 6)); + strcpy(key, "small"); length = sizeof(buffer); + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnSuccess); + uint64_t small = 0; memcpy(&small, buffer, length); assert(length == CFNumberGetByteSize((CFNumberRef)fixture.getProperties()[@"small"]) && small == 0x12345678); + strcpy(key, "negative"); length = sizeof(buffer); + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnSuccess); + int64_t negative = -1; memcpy(&negative, buffer, length); assert(length == CFNumberGetByteSize((CFNumberRef)fixture.getProperties()[@"negative"]) && negative == -3); + strcpy(key, "wide"); length = sizeof(buffer); + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnSuccess); + uint64_t wide; memcpy(&wide, buffer, sizeof(wide)); assert(length == 8 && wide == 0x123456789abcdef0ULL); + strcpy(key, "flag"); length = sizeof(buffer); + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnSuccess); + assert(length == 4 && !memcmp(buffer, "Yes", 4)); + strcpy(key, "utf8"); length = sizeof(buffer); + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnSuccess); + assert(length == 3 && !memcmp(buffer, "\xc3\xa9", 3)); + strcpy(key, "large"); length = UINT32_MAX; memset(buffer, 0x7e, sizeof(buffer)); + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnIPCError); + assert(length == sizeof(buffer) && buffer[0] == 0x7e && buffer[sizeof(buffer)-1] == 0x7e); + for (const char* name : {"float", "array", "missing"}) { + strcpy(key, name); length = sizeof(buffer); + auto expected = !strcmp(name,"missing") ? kIOReturnNoResources : kIOReturnBadArgument; + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == expected && length == 0); + } + memset(key, 'x', sizeof(key)); length = sizeof(buffer); + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnBadArgument && length == 0); +} +static void checkIdentity() { + NSString* uuid = @"00112233-4455-4677-8899-AABBCCDDEEFF"; + auto* valid = DarlingPlatformIdentity::properties(@{@"schema": @1, @"properties": @{@"model": @"ExampleModel1,1", @"IOPlatformUUID": uuid, @"IOPlatformSerialNumber": @"SUPPLIED"}}); + assert(valid && [valid[@"IOPlatformUUID"] isEqual:uuid]); + assert([valid[@"model"] isKindOfClass:[NSData class]] && [valid[@"model"] isEqual:valid[@"product-name"]]); + const unsigned char bytes[] = {0x33,0x22,0x11,0,0x55,0x44,0x77,0x46,0x88,0x99,0xaa,0xbb,0xcc,0xdd,0xee,0xff}; + assert([valid[@"system-id"] isEqual:[NSData dataWithBytes:bytes length:16]]); + for (NSDictionary* invalid in @[ + @{@"schema": @2, @"properties": @{}}, @{@"schema": @YES, @"properties": @{}}, + @{@"schema": @1, @"properties": @{@"unknown": @"value"}}, + @{@"schema": @1, @"properties": @{@"ROM": [NSData data]}}, + @{@"schema": @1, @"properties": @{@"IOPlatformUUID": @"00000000-0000-0000-0000-000000000000"}}, + @{@"schema": @1, @"properties": @{@"IOPlatformUUID": uuid, @"system-id": [NSMutableData dataWithLength:16]}}, + @{@"schema": @1, @"properties": @{@"model": @"bad\nmodel"}} + ]) assert(!DarlingPlatformIdentity::properties(invalid)); + assert(!DarlingPlatformIdentity::load("/private/etc/does-not-exist-platform-test.plist")); + // Exercise the same plist reader used by startup, using only a disposable file. + char filename[] = "/tmp/darling-platform-probe.XXXXXX"; + int descriptor = mkstemp(filename); assert(descriptor >= 0); + NSDictionary* config = @{@"schema": @1, @"properties": @{@"IOPlatformSerialNumber": @"FILE-FIXTURE", @"ROM": [NSData dataWithBytes:"abcdef" length:6]}}; + NSData* encoded = [NSPropertyListSerialization dataWithPropertyList:config format:NSPropertyListXMLFormat_v1_0 options:0 error:NULL]; + assert(encoded); + FILE* output = fdopen(descriptor,"wb"); assert(output); + assert(fwrite([encoded bytes],1,[encoded length],output) == [encoded length]); fclose(output); + NSDictionary* loaded = DarlingPlatformIdentity::load(filename); + assert(loaded && [loaded[@"IOPlatformSerialNumber"] isEqual:@"FILE-FIXTURE"] && [loaded[@"ROM"] length] == 6); + unlink(filename); +} +int main() { + @autoreleasepool { + Fixture fixture; + std::string temporaryPlane = "OwnedPlane"; + fixture.registerInPlane(temporaryPlane.c_str(), "Child", IORegistryEntry::root()); + temporaryPlane.assign(1000, 'x'); + assert(fixture.getPath("OwnedPlane") == "OwnedPlane:/Child"); + assert(IORegistryEntry::fromPath("OwnedPlane:/Child") == &fixture); + assert(IORegistryEntry::fromPath("IOService:/") == IORegistryEntry::root()); + assert(!IORegistryEntry::fromPath("MissingPlane:/")); + assert(!IORegistryEntry::fromPath("OwnedPlane:/child")); + assert(!IORegistryEntry::fromPath("OwnedPlane:/../Child")); + io_name_t plane = "OwnedPlane"; boolean_t inPlane = TRUE; + assert(is_io_registry_entry_in_plane(fixture.port(), plane, &inPlane) == kIOReturnSuccess && inPlane); + strcpy(plane, "Other"); + assert(is_io_registry_entry_in_plane(fixture.port(), plane, &inPlane) == kIOReturnSuccess && !inPlane); + std::vector> entries; + IORegistryEntry* parent = &fixture; + for (unsigned i=0; i<40; ++i) { + auto entry = std::make_unique(); + entry->registerInPlane("OwnedPlane", ("Entry"+std::to_string(i)+std::string(110,'x')).c_str(), parent); + parent = entry.get(); entries.push_back(std::move(entry)); + } + auto longPath = parent->getPath("OwnedPlane"); assert(longPath.size() > sizeof(io_string_inband_t)); + assert(IORegistryEntry::fromPath(longPath) == parent); + // An attempted cycle must not alter an existing root path. + fixture.registerInPlane("OwnedPlane", "cycle", parent); + assert(fixture.getPath("OwnedPlane") == "OwnedPlane:/Child"); + strcpy(plane, "OwnedPlane"); + io_string_t fixed; memset(fixed,'x',sizeof(fixed)); + assert(is_io_registry_entry_get_path(parent->port(), plane, fixed) == kIOReturnNoSpace && fixed[0] == 0); + io_string_inband_t inlinePath = ""; io_buf_ptr_t out = nullptr; mach_msg_type_number_t outCount = 0; + assert(is_io_registry_entry_get_path_ool(parent->port(), plane, inlinePath, &out, &outCount) == kIOReturnSuccess); + assert(!inlinePath[0] && std::string(out,outCount) == longPath && outCount == longPath.size()); + for (unsigned i=0; i<1000; ++i) { + vm_address_t allocation = 0; + assert(vm_allocate(mach_task_self(), &allocation, longPath.size()+1, TRUE) == KERN_SUCCESS); + memcpy((void*)allocation, longPath.c_str(), longPath.size()+1); + kern_return_t result; mach_port_t found; + assert(is_io_registry_entry_from_path_ool(0, inlinePath, (io_buf_ptr_t)allocation, longPath.size()+1, &result, &found) == kIOReturnSuccess); + assert(result == kIOReturnSuccess && found == parent->port()); + } + // Malformed OOL input must fail without publishing a port. + vm_address_t bad = 0; + assert(vm_allocate(mach_task_self(), &bad, 4, TRUE) == KERN_SUCCESS); + memset((void*)bad, 'x', 4); + kern_return_t badResult; mach_port_t badEntry = 123; + assert(is_io_registry_entry_from_path_ool(0, inlinePath, (io_buf_ptr_t)bad, 4, &badResult, &badEntry) == kIOReturnSuccess); + assert(badResult == kIOReturnBadArgument && badEntry == MACH_PORT_NULL); + auto detachedParent = std::make_unique(); + auto detachedChild = std::make_unique(); + detachedParent->registerInPlane("OwnedPlane", "Parent", &fixture); + detachedChild->registerInPlane("OwnedPlane", "Child", detachedParent.get()); + assert(detachedChild->inPlane("OwnedPlane")); + detachedParent.reset(); + assert(!detachedChild->inPlane("OwnedPlane") && detachedChild->getPath("OwnedPlane") == "?unsup_plane?"); + auto deleted = std::make_unique(); + deleted->registerInPlane("OwnedPlane", "Deleted", &fixture); + assert(IORegistryEntry::fromPath("OwnedPlane:/Child/Deleted") == deleted.get()); + deleted.reset(); + assert(!IORegistryEntry::fromPath("OwnedPlane:/Child/Deleted")); + checkProperties(fixture); checkIdentity(); + struct Serializable : IORegistryEntry { + NSDictionary* getProperties() override { return @{@"text": @"value", @"data": [NSData dataWithBytes:"abc" length:3], @"number": @123, @"array": @[@YES, @"x"]}; } + } serializable; + io_buf_ptr_t serialized = nullptr; mach_msg_type_number_t serializedCount = 0; + assert(is_io_registry_entry_get_properties_bin(serializable.port(), &serialized, &serializedCount) == kIOReturnSuccess); + CFStringRef error = nullptr; + CFTypeRef decoded = IOCFUnserializeWithSize(serialized, serializedCount, kCFAllocatorDefault, 0, &error); + assert(decoded && !error && [(NSDictionary*)decoded isEqual:serializable.getProperties()]); + CFRelease(decoded); + vm_deallocate(mach_task_self(), (vm_address_t)serialized, serializedCount); + std::thread([&fixture, parent, &serializable, longPath] { + @autoreleasepool { + io_name_t plane = "OwnedPlane"; + for (unsigned i=0; i<100; ++i) { + io_string_inband_t path = ""; + io_buf_ptr_t bytes = nullptr; mach_msg_type_number_t length = 0; + assert(io_registry_entry_get_path_ool(parent->port(), plane, path, &bytes, &length) == kIOReturnSuccess); + assert(bytes && std::string(bytes,length) == longPath); + vm_deallocate(mach_task_self(), (vm_address_t)bytes, length); + mach_port_t found = 0; kern_return_t result; + assert(io_registry_entry_from_path_ool(fixture.port(), path, const_cast(longPath.c_str()), longPath.size()+1, &result, &found) == kIOReturnSuccess); + assert(result == kIOReturnSuccess && found == parent->port()); + mach_port_deallocate(mach_task_self(), found); + } + io_name_t property = "flag"; io_struct_inband_t raw; mach_msg_type_number_t count = sizeof(raw); + assert(io_registry_entry_get_property_bytes(fixture.port(), property, raw, &count) == kIOReturnSuccess && count == 4 && !memcmp(raw,"Yes",4)); + io_buf_ptr_t bytes = nullptr; count = 0; + assert(io_registry_entry_get_properties_bin(serializable.port(), &bytes, &count) == kIOReturnSuccess); + CFStringRef error = nullptr; + CFTypeRef decoded = IOCFUnserializeWithSize(bytes, count, kCFAllocatorDefault, 0, &error); + assert(decoded && !error && [(NSDictionary*)decoded isEqual:serializable.getProperties()]); + CFRelease(decoded); vm_deallocate(mach_task_self(), (vm_address_t)bytes, count); + puts("REGISTRY_COMPAT paths=1 planes=1 graph_lifetime=1 cycles=1 ool_roundtrips=1000 ipc_roundtrips=100 property_bounds=1 numeric_widths=1 serialization=1 identity_schema=1 pass=1"); + fflush(stdout); + _Exit(0); + } + }).detach(); + dispatch_main(); + } +} diff --git a/upstream/manifest.json b/upstream/manifest.json index 733502b..09e7fa8 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -126,7 +126,7 @@ "revision": "5549ac4cd2db923c256f016a1381b3bfb716730a", "repository": "https://github.com/darlinghq/darling-iokitd.git", "patch": "patches/iokitd.patch", - "modified_files": 16, + "modified_files": 17, "new_files": [ "src/IOInterestNotification.h", "src/IOInterestNotification.mm", @@ -138,7 +138,8 @@ "src/LogindSleep.h", "src/PowerSources.mm", "src/HostPowerInhibit.c", - "src/HostPowerInhibit.h" + "src/HostPowerInhibit.h", + "src/PlatformIdentity.h" ] }, { From 91eaf211752b88df681f302739bfb00c370466af Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:38:49 +0200 Subject: [PATCH 17/23] Add stable opt-in per-prefix identity provisioning --- docs/PLATFORM_IDENTITY.md | 69 +++++- docs/reviews/pr41-deferred-implementation.md | 12 +- patches/iokitd.patch | 11 +- tests/test_platform_provisioning.py | 159 +++++++++++++ tools/configure-darling-platform.py | 228 +++++++++++++++---- tools/riot-registry-compatibility-check.mm | 13 +- tools/run-riot-native-check.py | 1 + 7 files changed, 436 insertions(+), 57 deletions(-) create mode 100644 tests/test_platform_provisioning.py diff --git a/docs/PLATFORM_IDENTITY.md b/docs/PLATFORM_IDENTITY.md index c1d4b85..9e623db 100644 --- a/docs/PLATFORM_IDENTITY.md +++ b/docs/PLATFORM_IDENTITY.md @@ -1,9 +1,9 @@ # Explicit platform identity overrides By default, Darling keeps the existing host-derived UUID and `DARLING-` serial. -No override file is generated by installation or launch. This tool imports -values supplied by the user; it does not generate Apple serials or replace OS, -CPU, GPU, or security metadata. +No override file is generated by installation or launch. Import mode uses values supplied by the user. Explicit `--provision` mode can +generate synthetic identifiers; it does not issue authentic Apple identities +or change CPU, GPU, or security metadata. Preview an OpenCore `config.plist`, a flat GenSMBIOS JSON file, or an existing `darling-smbios.conf` without changing the selected prefix: @@ -42,6 +42,7 @@ Supported properties: | Property | Configuration value | IOKit value | |---|---|---| | `IOPlatformSerialNumber` | Printable ASCII string, 1–64 characters | NSString | +| `provisioning_UDID` | UUID string matching `IOPlatformUUID` | NSString | | `IOPlatformUUID` | Nonzero UUID; normalized to uppercase | NSString | | `manufacturer`, `model`, `product-name`, `board-id` | Printable ASCII string, 1–128 characters | NUL-terminated NSData | | `mlb-serial-number` | Printable ASCII string, 1–64 characters | NUL-terminated NSData | @@ -71,3 +72,65 @@ first previous configuration is kept as an older `.conf` never edits that source. Symlinks escaping the prefix are rejected. To restore the original defaults, remove the override file while the prefix is stopped, then restart it. + +## Opt-in provisioning + +Preview a fresh or repaired identity without writing anything: + +```sh +python3 tools/configure-darling-platform.py --prefix /absolute/prefix --provision +``` + +Stop the prefix, then save the identity and prefix metadata: + +```sh +DPREFIX=/absolute/prefix python3 build/runtime/tools/stop-darling-runtime.py +python3 tools/configure-darling-platform.py --prefix /absolute/prefix --provision --write +``` + +Launch Darling normally afterwards. This requires the updated iokitd from this +PR; the config tool alone cannot update an older running daemon. Provisioning +is never called automatically by installation or launch. + +Missing serials use 12 characters and missing MLBs use 17 characters, sampled +with Python's `secrets` module from `0123456789ABCDEFGHJKLMNPQRSTUVWXYZ` (Base-34). +These are synthetic strings, with no Apple model, factory, date or checksum +validation, allocation, or guarantee of acceptance by Apple services. Missing +UUIDs use RFC4122 v4; `system-id` contains the UUID's 16 little-endian bytes. +For this project `provisioning_UDID` is the uppercase platform UUID alias, not +a separate Apple provisioning credential or a claim about hardware UDIDs. + +Missing ROMs default to six random bytes with the local/unicast bits set. You +can explicitly supply a three-byte OUI using `--rom-oui AA:BB:CC` (replace the +example with your chosen unicast OUI). It only affects a missing ROM. An OUI +does not make a generated identity Apple-issued. + +Provisioning replaces the known shared serial `C02ZL0GVMD6N` and UUID +`6F3B2A1E-8C4D-4E5F-9A7B-1D2C3E4F5A6B`, along with the latter's derived fields. +It preserves other existing custom values, including imported serials and UUIDs +that do not follow the generated formats. A standalone custom `system-id` is +preserved by deriving its UUID instead of generating a different one. Model, +board and manufacturer values are only set when supplied by the user. + +The command reads an existing prefix `darling-smbios.conf` for migration, then +applies the canonical plist, an explicit import, and `--set` values in that +order. It writes: + +- `private/etc/darling-platform.plist`: canonical runtime overrides. +- `private/etc/darling-smbios.conf`: matching shell-quoted `SMBIOS_*` values, + including `SMBIOS_PROVISIONING_UDID`; unrelated legacy settings are not exported. +- `System/Library/CoreServices/SystemVersion.plist`: an exact copy of the selected + runtime's metadata, not a fabricated macOS release. The runtime root defaults + to the saved SDK path or `/usr/local/libexec/darling`; override with + `--runtime-root /path/to/runtime`. + +A valid runtime version file is required. The command checks all destinations +before changing identity files, rejects live prefixes and escaping symlinks, +backs up each previous file once with `.before-platform-change`, and replaces +each file atomically. The set of three files is not a single transaction: the +canonical identity is saved first so rerunning after interruption completes +provisioning with the same identity. Concurrent CLI writers serialize through +a per-prefix lock. Repeated writes preserve identifiers and unchanged files. +Previewing a fresh identity generates a sample; only `--write` makes it stable. +To restore host-derived defaults after provisioning, remove both identity files +while stopped, so a later provisioning run cannot reimport the legacy copy. diff --git a/docs/reviews/pr41-deferred-implementation.md b/docs/reviews/pr41-deferred-implementation.md index 9c2347d..ea0f50c 100644 --- a/docs/reviews/pr41-deferred-implementation.md +++ b/docs/reviews/pr41-deferred-implementation.md @@ -53,7 +53,8 @@ above the 32-bit range should use the named 64-bit API. [Platform identity configuration](../PLATFORM_IDENTITY.md) documents the schema, OpenCore/GenSMBIOS/legacy imports, startup snapshot, defaults, and rollback. Neither installation nor launch generates an override file. Existing host-based -identifiers remain stable unless the user explicitly supplies replacements. +identifiers remain stable unless the user explicitly supplies replacements or +requests synthetic per-prefix provisioning. ## Validation @@ -96,3 +97,12 @@ darling shell /Volumes/SystemRoot/ABSOLUTE_PROJECT_PATH/build/riot-cpu-frequency Local validation also ran the fd helper inside Darling and used an isolated kernel override for the frequency probe. The configured production runtime was not replaced, no real identity was changed, and no match capture is required. + + +Opt-in provisioning now fills missing synthetic serial/MLB, UUID, system-id, +provisioning UUID alias and ROM fields. It repairs the two known shared defaults +while retaining custom values, synchronizes the canonical and legacy config, +and copies actual runtime SystemVersion metadata. Disposable-prefix tests cover +concurrent writers, idempotency, migration, backups, live-prefix refusal, and +preflight failures. The registry probe validates all ten allowed properties. +See [platform identity usage](../PLATFORM_IDENTITY.md#opt-in-provisioning). diff --git a/patches/iokitd.patch b/patches/iokitd.patch index c3333e1..5607ad6 100644 --- a/patches/iokitd.patch +++ b/patches/iokitd.patch @@ -3522,10 +3522,10 @@ index 90155ed..e955bd4 100644 static std::unordered_map m_entriesById; diff --git a/src/PlatformIdentity.h b/src/PlatformIdentity.h new file mode 100644 -index 0000000..1f83b65 +index 0000000..6c87959 --- /dev/null +++ b/src/PlatformIdentity.h -@@ -0,0 +1,78 @@ +@@ -0,0 +1,81 @@ +#ifndef DARLING_PLATFORM_IDENTITY_H +#define DARLING_PLATFORM_IDENTITY_H +#import @@ -3553,17 +3553,18 @@ index 0000000..1f83b65 + if (![schema isKindOfClass:[NSNumber class]] || CFGetTypeID((CFTypeRef)schema) == CFBooleanGetTypeID() || + strchr("fd", [schema objCType][0]) || [schema longLongValue] != 1) return nil; + id source = [configuration objectForKey:@"properties"]; -+ if (![source isKindOfClass:[NSDictionary class]] || [source count] > 9) return nil; ++ if (![source isKindOfClass:[NSDictionary class]] || [source count] > 10) return nil; + NSMutableDictionary* result = [NSMutableDictionary dictionary]; + for (id key in source) { + if (![key isKindOfClass:[NSString class]]) return nil; + id value = [source objectForKey:key]; -+ if ([key isEqual:@"IOPlatformUUID"]) { ++ if ([key isEqual:@"IOPlatformUUID"] || [key isEqual:@"provisioning_UDID"]) { + if (!printable(value, 36) || [value length] != 36) return nil; + uuid_t uuid; + if (uuid_parse([value UTF8String], uuid) || uuid_is_null(uuid)) return nil; + uuid_string_t text; uuid_unparse_upper(uuid, text); + result[key] = [NSString stringWithUTF8String:text]; ++ if ([key isEqual:@"provisioning_UDID"]) continue; + unsigned char bytes[16]; memcpy(bytes, uuid, sizeof(bytes)); + for (int i = 0; i < 4; ++i) bytes[i] = uuid[3-i]; + bytes[4] = uuid[5]; bytes[5] = uuid[4]; bytes[6] = uuid[7]; bytes[7] = uuid[6]; @@ -3583,6 +3584,8 @@ index 0000000..1f83b65 + result[key] = [NSData dataWithBytes:text length:strlen(text)+1]; + } else return nil; + } ++ if (result[@"provisioning_UDID"] && result[@"IOPlatformUUID"] && ++ ![result[@"provisioning_UDID"] isEqual:result[@"IOPlatformUUID"]]) return nil; + if (result[@"model"] && !result[@"product-name"]) result[@"product-name"] = result[@"model"]; + return result; +} diff --git a/tests/test_platform_provisioning.py b/tests/test_platform_provisioning.py new file mode 100644 index 0000000..48bd0c0 --- /dev/null +++ b/tests/test_platform_provisioning.py @@ -0,0 +1,159 @@ +"""Provisioning contracts using disposable prefixes and runtime metadata.""" +import importlib.util +import json +import os +from pathlib import Path +import plistlib +import subprocess +import sys +import tempfile +import unittest +import uuid + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / 'tools/configure-darling-platform.py' +spec = importlib.util.spec_from_file_location('provisioning', SCRIPT) +platform = importlib.util.module_from_spec(spec) +spec.loader.exec_module(platform) + + +class ProvisioningChecks(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.prefix = self.root / 'prefix with spaces' + self.runtime = self.root / 'runtime with spaces' + self.version = self.runtime / 'System/Library/CoreServices/SystemVersion.plist' + self.version.parent.mkdir(parents=True) + self.metadata = {'ProductName': 'Darling', 'ProductVersion': '11.7.4', 'ProductBuildVersion': 'Darling-test'} + self.version.write_bytes(plistlib.dumps(self.metadata)) + self.command = [sys.executable, str(SCRIPT), '--prefix', str(self.prefix), + '--provision', '--runtime-root', str(self.runtime)] + + def run_cli(self, *args): + result = subprocess.run([*self.command, *args], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + return result + + def test_generated_shapes_uuid_byte_order_and_prefix_independence(self): + values = [platform.provision({}) for _ in range(20)] + for value in values: + for key, length in [('IOPlatformSerialNumber', 12), ('mlb-serial-number', 17)]: + self.assertEqual(len(value[key]), length) + self.assertTrue(set(value[key]) <= set(platform.BASE34)) + parsed = uuid.UUID(value['IOPlatformUUID']) + self.assertEqual(parsed.version, 4) + self.assertEqual(parsed.variant, uuid.RFC_4122) + self.assertEqual(value['system-id'], parsed.bytes_le) + self.assertEqual(value['provisioning_UDID'], str(parsed).upper()) + self.assertEqual(len(value['ROM']), 6) + self.assertEqual(value['ROM'][0] & 3, 2) + self.assertEqual(len({v['IOPlatformUUID'] for v in values}), len(values)) + + def test_preview_does_not_create_prefix(self): + preview = json.loads(self.run_cli().stdout) + self.assertIn('provisioning_UDID', preview['properties']) + self.assertFalse(self.prefix.exists()) + + def test_write_three_files_idempotent_and_importable(self): + self.run_cli('--write') + canonical = platform.destination(self.prefix) + legacy = self.prefix / 'private/etc/darling-smbios.conf' + version = self.prefix / 'System/Library/CoreServices/SystemVersion.plist' + self.assertEqual(platform.load(canonical), platform.load(legacy)) + self.assertEqual(version.read_bytes(), self.version.read_bytes()) + files = [canonical, legacy, version] + snapshot = [(f.read_bytes(), f.stat().st_mtime_ns) for f in files] + self.assertIn('unchanged', self.run_cli('--write').stdout) + self.assertEqual(snapshot, [(f.read_bytes(), f.stat().st_mtime_ns) for f in files]) + for f in [canonical, legacy]: + self.assertEqual(f.stat().st_mode & 0o777, 0o600) + + def test_known_defaults_replaced_and_custom_values_preserved(self): + legacy = self.prefix / 'private/etc/darling-smbios.conf' + legacy.parent.mkdir(parents=True) + original = (f'SMBIOS_SERIAL_NUMBER={platform.KNOWN_SERIAL}\n' + f'SMBIOS_PLATFORM_UUID={platform.KNOWN_UUID}\n' + 'SMBIOS_MLB=CUSTOM-BOARD\nSMBIOS_MODEL=CustomModel\nSMBIOS_ROM=001122334455\n').encode() + legacy.write_bytes(original) + self.run_cli('--write') + result = platform.load(platform.destination(self.prefix)) + self.assertNotEqual(result['IOPlatformSerialNumber'], platform.KNOWN_SERIAL) + self.assertNotEqual(result['IOPlatformUUID'], platform.KNOWN_UUID) + self.assertEqual(result['mlb-serial-number'], 'CUSTOM-BOARD') + self.assertEqual(result['model'], 'CustomModel') + self.assertEqual(result['ROM'], bytes.fromhex('001122334455')) + self.assertEqual(legacy.with_name(legacy.name+'.before-platform-change').read_bytes(), original) + before = result.copy() + self.run_cli('--write') + self.assertEqual(platform.load(platform.destination(self.prefix)), before) + + def test_custom_canonical_identity_wins_over_legacy_defaults(self): + custom = platform.provision({}) + platform.save(self.prefix, custom) + legacy = self.prefix / 'private/etc/darling-smbios.conf' + legacy.write_text(f'SMBIOS_SERIAL_NUMBER={platform.KNOWN_SERIAL}\nSMBIOS_PLATFORM_UUID={platform.KNOWN_UUID}\n') + self.run_cli('--write') + self.assertEqual(platform.load(platform.destination(self.prefix)), custom) + + def test_explicit_oui_and_existing_rom(self): + value = platform.provision({}, bytes.fromhex('001122')) + self.assertEqual(value['ROM'][:3], bytes.fromhex('001122')) + self.assertEqual(platform.provision(value, bytes.fromhex('AABBCC')), value) + for oui in ['1234', '011122', 'garbage']: + result = subprocess.run([*self.command, '--rom-oui', oui, '--write'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(platform.destination(self.prefix).exists()) + + def test_version_source_required_before_identity_written(self): + for raw in [b'not a plist', plistlib.dumps({'ProductVersion': '99.0'})]: + self.version.write_bytes(raw) + result = subprocess.run([*self.command, '--write'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(platform.destination(self.prefix).exists()) + self.assertFalse((self.prefix / 'private/etc/darling-smbios.conf').exists()) + self.version.unlink() + result = subprocess.run([*self.command, '--write'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn('Traceback', result.stderr) + + def test_system_version_backup_and_symlink_preflight(self): + target = self.prefix / 'System/Library/CoreServices/SystemVersion.plist' + target.parent.mkdir(parents=True) + original = plistlib.dumps({'ProductVersion': 'old'}) + target.write_bytes(original) + self.run_cli('--write') + self.assertEqual(target.with_name(target.name+'.before-platform-change').read_bytes(), original) + self.assertEqual(plistlib.loads(target.read_bytes()), self.metadata) + target.unlink() + target.symlink_to(self.version) + before = platform.destination(self.prefix).read_bytes() + result = subprocess.run([*self.command, '--set', 'model=Changed', '--write'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(platform.destination(self.prefix).read_bytes(), before) + + def test_live_prefix_is_not_changed(self): + self.prefix.mkdir() + (self.prefix / '.init.pid').write_text(str(os.getpid())) + result = subprocess.run([*self.command, '--write'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(platform.destination(self.prefix).exists()) + + def test_concurrent_writers_share_one_identity(self): + workers = [subprocess.Popen([*self.command, '--write'], stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) for _ in range(4)] + for worker in workers: + output, error = worker.communicate(timeout=15) + self.assertEqual(worker.returncode, 0, error) + canonical = platform.destination(self.prefix) + self.assertEqual(platform.load(canonical), platform.load(self.prefix / 'private/etc/darling-smbios.conf')) + self.assertFalse(canonical.with_name(canonical.name+'.before-platform-change').exists()) + + def test_custom_system_id_is_preserved_and_udid_mismatch_rejected(self): + value = platform.provision({}) + self.assertEqual(platform.provision({'provisioning_UDID': value['provisioning_UDID']})['IOPlatformUUID'], value['IOPlatformUUID']) + supplied = {'system-id': value['system-id']} + self.assertEqual(platform.provision(supplied)['IOPlatformUUID'], value['IOPlatformUUID']) + with self.assertRaises(ValueError): + platform.validate({'IOPlatformUUID': value['IOPlatformUUID'], 'provisioning_UDID': str(uuid.uuid4())}) diff --git a/tools/configure-darling-platform.py b/tools/configure-darling-platform.py index 9ba1c87..7332dca 100644 --- a/tools/configure-darling-platform.py +++ b/tools/configure-darling-platform.py @@ -2,6 +2,9 @@ """Preview or explicitly save per-prefix IOKit platform overrides; defaults stay unchanged.""" import argparse import json +import fcntl +import secrets +from contextlib import nullcontext import os from pathlib import Path import plistlib @@ -11,6 +14,9 @@ import uuid from xml.parsers.expat import ExpatError MAX_SIZE = 65536 +BASE34 = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZ' +KNOWN_SERIAL = 'C02ZL0GVMD6N' +KNOWN_UUID = '6F3B2A1E-8C4D-4E5F-9A7B-1D2C3E4F5A6B' STRINGS = {'IOPlatformSerialNumber': 64, 'manufacturer': 128, 'model': 128, 'product-name': 128, 'board-id': 128, 'mlb-serial-number': 64} BYTES = {'ROM': 6, 'system-id': 16} @@ -22,23 +28,24 @@ ALIASES = { 'SystemUUID': 'IOPlatformUUID', 'SmUUID': 'IOPlatformUUID', 'SMBIOS_PLATFORM_UUID': 'IOPlatformUUID', 'MLB': 'mlb-serial-number', 'Board Serial': 'mlb-serial-number', 'SMBIOS_MLB': 'mlb-serial-number', 'SMBIOS_ROM': 'ROM', 'SMBIOS_SYSTEM_ID': 'system-id', + 'SMBIOS_PROVISIONING_UDID': 'provisioning_UDID', } def validate(properties): - if not isinstance(properties, dict) or len(properties) > 9: + if not isinstance(properties, dict) or len(properties) > 10: raise ValueError('properties must be a dictionary of supported identity fields') result = {} for key, value in properties.items(): if key in STRINGS: if not isinstance(value, str) or not 1 <= len(value) <= STRINGS[key] or any(not 32 <= ord(c) <= 126 for c in value): raise ValueError('Invalid printable string for ' + key) - elif key == 'IOPlatformUUID': + elif key in ('IOPlatformUUID', 'provisioning_UDID'): if not isinstance(value, str): - raise ValueError('IOPlatformUUID must be a UUID string') + raise ValueError(key + ' must be a UUID string') parsed = uuid.UUID(value) if not parsed.int: - raise ValueError('IOPlatformUUID must not be zero') + raise ValueError(key + ' must not be zero') value = str(parsed).upper() elif key in BYTES: if isinstance(value, str): @@ -55,6 +62,8 @@ def validate(properties): if 'system-id' in result and result['system-id'] != system_id: raise ValueError('system-id does not match IOPlatformUUID') result['system-id'] = system_id + if result.get('provisioning_UDID', result['IOPlatformUUID']) != result['IOPlatformUUID']: + raise ValueError('provisioning_UDID must match IOPlatformUUID') return result @@ -101,7 +110,7 @@ def load(source): result = {} for key, value in payload.items(): name = ALIASES.get(key, key) - if name not in {*STRINGS, *BYTES, 'IOPlatformUUID'}: + if name not in {*STRINGS, *BYTES, 'IOPlatformUUID', 'provisioning_UDID'}: continue # OpenCore includes unrelated boot, firmware and CPU settings. if name in result and result[name] != value: raise ValueError('Conflicting imports for ' + name) @@ -111,50 +120,49 @@ def load(source): return validate(result) -def destination(prefix): +def checked_path(prefix, relative): prefix = Path(prefix).expanduser() if not prefix.is_absolute(): raise ValueError('Prefix must be absolute') - path = prefix / 'private/etc/darling-platform.plist' + path = prefix / relative if path.is_symlink() or not path.resolve().is_relative_to(prefix.resolve()): raise ValueError('Identity destination must remain inside the selected prefix') return path -def save(prefix, properties): - path = destination(prefix) - prefix = Path(prefix) - # A host write behind Darling's mounted overlay may not be visible. Never - # stop a user's session implicitly or write a second copy behind its back. - marker = prefix / '.init.pid' +def destination(prefix): + return checked_path(prefix, 'private/etc/darling-platform.plist') + + +def ensure_stopped(prefix): + marker = Path(prefix) / '.init.pid' if marker.exists(): + text = marker.read_text().strip() + if not text.isdecimal() or int(text) <= 1: + raise ValueError('Invalid .init.pid; verify the selected prefix is stopped before writing') try: - text = marker.read_text().strip() - if not text.isdecimal() or int(text) <= 1: - raise ValueError('Invalid .init.pid; verify the selected prefix is stopped before writing') - pid = int(text) - os.kill(pid, 0) + os.kill(int(text), 0) except ProcessLookupError: - pass - else: - raise ValueError('Shut down the selected Darling prefix before writing platform identity') - encoded = plistlib.dumps({'schema': 1, 'properties': validate(properties)}, sort_keys=True) - if path.exists() and path.read_bytes() == encoded: + return + raise ValueError('Shut down the selected Darling prefix before writing platform identity') + + +def atomic_write(path, data): + if path.exists() and path.read_bytes() == data: return False path.parent.mkdir(parents=True, exist_ok=True) - # Recheck after creating parents, before opening a temporary file. - path = destination(prefix) if path.exists(): - load(path) # refuse to silently overwrite an unknown schema backup = path.with_name(path.name + '.before-platform-change') if not backup.exists(): fd = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, 'wb') as stream: stream.write(path.read_bytes()) + stream.flush() + os.fsync(stream.fileno()) fd, temporary = tempfile.mkstemp(prefix='.darling-platform-', dir=path.parent) try: with os.fdopen(fd, 'wb') as stream: - stream.write(encoded) + stream.write(data) stream.flush() os.fsync(stream.fileno()) os.replace(temporary, path) @@ -163,38 +171,162 @@ def save(prefix, properties): return True +def save(prefix, properties): + path = destination(prefix) + ensure_stopped(prefix) + encoded = plistlib.dumps({'schema': 1, 'properties': validate(properties)}, sort_keys=True) + checked_path(prefix, 'private/etc/darling-platform.plist.before-platform-change') + if path.exists(): + load(path) # refuse to silently overwrite an unknown schema + path.parent.mkdir(parents=True, exist_ok=True) + return atomic_write(destination(prefix), encoded) + + +def provision(properties, rom_oui=None): + """Fill missing identifiers, replacing only the two known shared defaults.""" + result = validate(properties) + if result.get('IOPlatformSerialNumber') == KNOWN_SERIAL: + result.pop('IOPlatformSerialNumber') + if result.get('IOPlatformUUID', '').upper() == KNOWN_UUID: + result.pop('IOPlatformUUID') + result.pop('system-id', None) + result.pop('provisioning_UDID', None) + for key, length in [('IOPlatformSerialNumber', 12), ('mlb-serial-number', 17)]: + if key not in result: + result[key] = ''.join(secrets.choice(BASE34) for _ in range(length)) + if 'IOPlatformUUID' not in result: + # Preserve a custom standalone EFI system ID during legacy migration. + result['IOPlatformUUID'] = (result['provisioning_UDID'] if 'provisioning_UDID' in result else + str(uuid.UUID(bytes_le=result['system-id']) if 'system-id' in result else uuid.uuid4()).upper()) + result.setdefault('provisioning_UDID', result['IOPlatformUUID']) + if 'ROM' not in result: + if rom_oui is not None: + result['ROM'] = rom_oui + secrets.token_bytes(3) + else: + value = bytearray(secrets.token_bytes(6)) + value[0] = (value[0] | 2) & 0xfe # locally administered, unicast + result['ROM'] = bytes(value) + return validate(result) + + +def provisioning_files(prefix, properties, runtime_root): + """Preflight every file before making any identity changes.""" + version = Path(runtime_root) / 'System/Library/CoreServices/SystemVersion.plist' + with version.open('rb') as stream: + raw = stream.read(MAX_SIZE + 1) + if len(raw) > MAX_SIZE: + raise ValueError('Runtime SystemVersion.plist exceeds 64 KiB') + metadata = plistlib.loads(raw) + if not isinstance(metadata, dict) or any(not isinstance(metadata.get(key), str) or not metadata[key] + for key in ('ProductName', 'ProductVersion', 'ProductBuildVersion')): + raise ValueError('Runtime SystemVersion.plist is missing version metadata') + reverse = {value: key for key, value in ALIASES.items() if key.startswith('SMBIOS_')} + lines = ['# Synthetic per-prefix identity; generated by configure-darling-platform.py.'] + for key, value in sorted(properties.items()): + value = value.hex().upper() if isinstance(value, bytes) else value + lines.append(reverse[key] + '=' + shlex.quote(value)) + files = { + 'private/etc/darling-smbios.conf': ('\n'.join(lines) + '\n').encode(), + 'System/Library/CoreServices/SystemVersion.plist': raw, + 'private/etc/darling-platform.plist': plistlib.dumps({'schema': 1, 'properties': properties}, sort_keys=True), + } + for relative in files: + path = checked_path(prefix, relative) + checked_path(prefix, relative + '.before-platform-change') + if path.exists(): + if relative.endswith('darling-platform.plist'): + load(path) + elif relative.endswith('.conf'): + load(path) + else: + if not isinstance(plistlib.loads(path.read_bytes()), dict): + raise ValueError('Existing SystemVersion.plist is invalid') + return files + + +def save_provisioning(prefix, properties, runtime_root): + ensure_stopped(prefix) + files = provisioning_files(prefix, properties, runtime_root) + # Save the canonical identity first: an interrupted run reuses it on retry. + order = ['private/etc/darling-platform.plist', 'private/etc/darling-smbios.conf', + 'System/Library/CoreServices/SystemVersion.plist'] + changed = False + for relative in order: + changed = atomic_write(checked_path(prefix, relative), files[relative]) or changed + return changed + + +def prefix_lock(prefix): + ensure_stopped(prefix) + path = checked_path(prefix, 'private/etc/.darling-platform.lock') + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + stream = os.fdopen(fd, 'r+') + fcntl.flock(stream, fcntl.LOCK_EX) + return stream + + def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--prefix', type=Path) parser.add_argument('--import', dest='source', type=Path, help='OpenCore plist, GenSMBIOS JSON, legacy .conf, or schema-1 plist/JSON') parser.add_argument('--set', action='append', default=[], metavar='KEY=VALUE') + parser.add_argument('--provision', action='store_true', help='Generate missing synthetic identifiers and provision prefix metadata') + parser.add_argument('--runtime-root', type=Path, help='Runtime supplying SystemVersion.plist (default: saved SDK or /usr/local/libexec/darling)') + parser.add_argument('--rom-oui', help='Explicit three-byte unicast OUI for a missing ROM; default is locally administered') parser.add_argument('--write', action='store_true', help='Write validated overrides to a stopped prefix (otherwise preview only)') args = parser.parse_args(argv) config = Path(__file__).resolve().parents[1] / 'build/config.json' saved = json.loads(config.read_text()) if config.exists() else {} prefix = args.prefix or Path(os.environ.get('DPREFIX') or saved.get('prefix', str(Path.home() / '.darling'))) try: - path = destination(prefix) - properties = load(path) if path.exists() else {} - if args.source: - incoming = load(args.source) - # Replacing a UUID also replaces its previously derived byte form. - if 'IOPlatformUUID' in incoming: - properties.pop('system-id', None) - properties.update(incoming) - for assignment in args.set: - key, sep, value = assignment.partition('=') - if not sep: - raise ValueError('--set requires KEY=VALUE') - if key == 'IOPlatformUUID': properties.pop('system-id', None) - if key == 'model': properties.pop('product-name', None) - properties[key] = value - properties = validate(properties) - if args.write: - changed = save(prefix, properties) - print('Platform identity ' + ('saved' if changed else 'unchanged') + '; restart iokitd to apply.') - else: - print(json.dumps({'schema': 1, 'properties': properties}, default=lambda value: value.hex().upper(), indent=2)) + prefix = prefix.expanduser() + rom_oui = None + if args.rom_oui: + if not args.provision: + raise ValueError('--rom-oui requires --provision') + rom_oui = bytes.fromhex(args.rom_oui.replace(':', '').replace('-', '')) + if len(rom_oui) != 3 or rom_oui[0] & 1: + raise ValueError('--rom-oui requires exactly three unicast bytes') + with prefix_lock(prefix) if args.write else nullcontext(): + path = destination(prefix) + properties = {} + if args.provision: + legacy = checked_path(prefix, 'private/etc/darling-smbios.conf') + if legacy.exists(): + properties.update(load(legacy)) + if path.exists(): + incoming = load(path) + if 'IOPlatformUUID' in incoming: + properties.pop('system-id', None) + properties.pop('provisioning_UDID', None) + properties.update(incoming) + if args.source: + incoming = load(args.source) + if 'IOPlatformUUID' in incoming: + properties.pop('system-id', None) + properties.pop('provisioning_UDID', None) + properties.update(incoming) + for assignment in args.set: + key, sep, value = assignment.partition('=') + if not sep: + raise ValueError('--set requires KEY=VALUE') + if key == 'IOPlatformUUID': + properties.pop('system-id', None) + properties.pop('provisioning_UDID', None) + if key == 'model': properties.pop('product-name', None) + properties[key] = value + runtime_root = args.runtime_root or Path(saved.get('sdk', '/usr/local/libexec/darling')) + if args.provision: + properties = provision(properties, rom_oui) + provisioning_files(prefix, properties, runtime_root) + else: + properties = validate(properties) + if args.write: + changed = (save_provisioning(prefix, properties, runtime_root) if args.provision else save(prefix, properties)) + print('Platform identity ' + ('saved' if changed else 'unchanged') + '; restart iokitd to apply.') + else: + print(json.dumps({'schema': 1, 'properties': properties}, default=lambda value: value.hex().upper(), indent=2)) except (OSError, ValueError, TypeError, plistlib.InvalidFileException, ExpatError) as error: parser.exit(1, str(error) + '\n') return 0 diff --git a/tools/riot-registry-compatibility-check.mm b/tools/riot-registry-compatibility-check.mm index ee8d157..8103281 100644 --- a/tools/riot-registry-compatibility-check.mm +++ b/tools/riot-registry-compatibility-check.mm @@ -64,12 +64,23 @@ static void checkProperties(Fixture& fixture) { } static void checkIdentity() { NSString* uuid = @"00112233-4455-4677-8899-AABBCCDDEEFF"; - auto* valid = DarlingPlatformIdentity::properties(@{@"schema": @1, @"properties": @{@"model": @"ExampleModel1,1", @"IOPlatformUUID": uuid, @"IOPlatformSerialNumber": @"SUPPLIED"}}); + auto* valid = DarlingPlatformIdentity::properties(@{@"schema": @1, @"properties": @{@"model": @"ExampleModel1,1", @"provisioning_UDID": uuid, @"IOPlatformUUID": uuid, @"IOPlatformSerialNumber": @"SUPPLIED"}}); assert(valid && [valid[@"IOPlatformUUID"] isEqual:uuid]); + assert([valid[@"provisioning_UDID"] isEqual:uuid]); + NSMutableDictionary* complete = [@{@"IOPlatformSerialNumber": @"SYNTHETIC123", @"IOPlatformUUID": uuid, + @"provisioning_UDID": uuid, @"model": @"ExampleModel", @"product-name": @"ExampleModel", + @"manufacturer": @"Example", @"board-id": @"Board", @"mlb-serial-number": @"SYNTHETIC-BOARD", + @"ROM": [NSData dataWithBytes:"abcdef" length:6]} mutableCopy]; + complete[@"system-id"] = valid[@"system-id"]; + assert([complete count] == 10); + assert(DarlingPlatformIdentity::properties(@{@"schema": @1, @"properties": complete})); + [complete release]; assert([valid[@"model"] isKindOfClass:[NSData class]] && [valid[@"model"] isEqual:valid[@"product-name"]]); const unsigned char bytes[] = {0x33,0x22,0x11,0,0x55,0x44,0x77,0x46,0x88,0x99,0xaa,0xbb,0xcc,0xdd,0xee,0xff}; assert([valid[@"system-id"] isEqual:[NSData dataWithBytes:bytes length:16]]); for (NSDictionary* invalid in @[ + @{@"schema": @1, @"properties": @{@"provisioning_UDID": @"bad"}}, + @{@"schema": @1, @"properties": @{@"IOPlatformUUID": uuid, @"provisioning_UDID": @"11112233-4455-4677-8899-AABBCCDDEEFF"}}, @{@"schema": @2, @"properties": @{}}, @{@"schema": @YES, @"properties": @{}}, @{@"schema": @1, @"properties": @{@"unknown": @"value"}}, @{@"schema": @1, @"properties": @{@"ROM": [NSData data]}}, diff --git a/tools/run-riot-native-check.py b/tools/run-riot-native-check.py index 2eafd83..964ecc6 100644 --- a/tools/run-riot-native-check.py +++ b/tools/run-riot-native-check.py @@ -9,6 +9,7 @@ import tempfile root = Path(__file__).resolve().parent.parent checks = { + "riot-registry-compatibility-check": "REGISTRY_COMPAT", "riot-cfhost-cancel-check": "CFHOST_CANCEL_CHECK", "riot-launchservices-null-error-check": "LAUNCHSERVICES_NULL_ERROR", "m327-cgl-resolver-check": "GL_RESOLVER", From caa471924146c2a960935ad4d14963629e908f92 Mon Sep 17 00:00:00 2001 From: Kalah <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:11:42 +0200 Subject: [PATCH 18/23] Update README with contact info and project goals Added contact information for job opportunities and clarified project goals regarding Linux compatibility. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 77babf8..ccf8377 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,15 @@ > - don't use your main account. > > 3-Day Suspension ("UNSUPPORTED_THIRD_PARTY_TOOLS") DON'T USE IT RN +> > Even with all of that, you will probably still get permabanned. > > **3. Takedown.** Riot may well shut this project down. If that happens, I will publish a fork of Darling containing only the changes made here (none of them breach anything), and release the small script as a gist or similar. > > **4. To Riot.** If you are reading this, it means a lot of people want to play your game on Linux and are disappointed that they currently can't. As with macOS for your other games (TFT, Valorant), it feels like Riot doesn't care about a part of its players who have been waiting the longest. If you want to fix this, make LoL Next compatible with Linux, either natively or through a layer like Proton. Yes, that includes your kernel anticheat: Epic Games did it with Easy Anti-Cheat. Follow the trend. This is the year of Linux, and people shouldn't be locked out of a platform or forced to dual boot for a single game. +> Since Riot have heard of the repo if you want to contact me you can at darling-lolriot@proton.me (especially if you want to do an official or supported version of league of legends on linux) + +If someone else wants to contact me know that i'm open to jobs, part-time remote only as a employee and full time remote as a freelancer for a mission https://github.com/user-attachments/assets/140df3e0-3790-46b6-bab5-578139b84a7c From 3c0b9cdfd3e7bc4c2a43bf5bc346c29496763146 Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Sun, 18 Oct 2026 00:21:30 +0200 Subject: [PATCH 19/23] feat(nix): add experimental rootless FHS package and desktop launcher --- .gitattributes | 3 + README.md | 29 +- darling-lol-launcher.sh | 60 ++++ darling-lol.png | Bin 0 -> 2526 bytes darling-lol.svg | 6 + docs/SETUP.md | 88 ++++- docs/TROUBLESHOOTING.md | 42 +-- flake.lock | 27 ++ flake.nix | 328 ++++++++++++++++++ patches/cctools-port.patch | 38 +++ patches/darling.patch | 8 +- scripts/build.sh | 6 +- scripts/darling-rootless.sh | 14 + scripts/fhs-entry.sh | 9 + scripts/install-runtime.sh | 15 +- scripts/installer.sh | 9 +- scripts/nix-copy.py | 44 +++ scripts/nix-idle.py | 36 ++ scripts/nix-session.sh | 32 ++ scripts/nix-state.py | 164 +++++++++ scripts/prepare.py | 36 +- scripts/run.sh | 5 + scripts/update.sh | 21 +- scripts/validate.py | 3 + tests/test_nix_launcher.py | 90 +++++ tests/test_nix_repairs.py | 519 +++++++++++++++++++++++++++++ tests/test_nix_runtime_identity.py | 111 ++++++ upstream/manifest.json | 6 +- 28 files changed, 1686 insertions(+), 63 deletions(-) create mode 100644 .gitattributes create mode 100644 darling-lol-launcher.sh create mode 100644 darling-lol.png create mode 100644 darling-lol.svg create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 patches/cctools-port.patch create mode 100644 scripts/darling-rootless.sh create mode 100644 scripts/fhs-entry.sh create mode 100644 scripts/nix-copy.py create mode 100644 scripts/nix-idle.py create mode 100755 scripts/nix-session.sh create mode 100644 scripts/nix-state.py create mode 100644 tests/test_nix_launcher.py create mode 100644 tests/test_nix_repairs.py create mode 100644 tests/test_nix_runtime_identity.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..00c1a6e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Unified-diff context uses a single space for an empty source line. +# Do not report that patch syntax as trailing source whitespace. +patches/*.patch whitespace=-blank-at-eol diff --git a/README.md b/README.md index ccf8377..d997fb1 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,34 @@ Game files, Riot installers, account databases, tokens, local prefixes, captures Use the dependency list in the [pinned Darling source](https://github.com/darlinghq/darling/tree/60ba801decee7a00782f74f6be4c8ffb013f79ff) for your distribution. `tools/env-check.sh` provides a starting check; its legacy package names may need adapting to your distro. -## Build on the other PC +## Experimental Nix/rootless package + +```sh +nix run github:lucannez64/darling-lol#installer +nix run github:lucannez64/darling-lol#default -- --update +nix run github:lucannez64/darling-lol#default +nix run github:lucannez64/darling-lol#shell +``` + +Or from a local checkout: + +```sh +nix run .#installer +nix run .#default +``` + +Nix supplies the launcher/dependencies, not a prebuilt Darling or game. First +setup is interactive and builds into the writable per-user workspace. The +desktop entry requests a terminal; EOF stdin produces an actionable error. +Run updates only after manually closing the clients and shutting Darling down +in the original namespace. A live prefix marker blocks a second launch rather +than killing or attempting to reconnect. One workspace/foreground namespace is +supported at a time. Readiness is per canonical prefix, configured guest and +compatible package/runtime/source identity. Store closure roots are retained +for mutable binaries; do not delete `build/nix-gcroots/` while using them. +See [Nix setup and operational limits](docs/SETUP.md#nixos-package). + +## Build on the other PC (non-Nix) ```sh git clone https://github.com/lucannez64/darling-lol.git diff --git a/darling-lol-launcher.sh b/darling-lol-launcher.sh new file mode 100644 index 0000000..604c9f0 --- /dev/null +++ b/darling-lol-launcher.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +WORKSPACE="${XDG_DATA_HOME:-$HOME/.local/share}/darling-lol" +SHARE_DIR="@SHARE_DIR@" +FHS_BIN="@FHS_BIN@" +UNSHARE_BIN="@UNSHARE_BIN@" +# The store package is both a compatibility identity and a closure GC root. +export DARLING_PACKAGE_ID="$SHARE_DIR" +export DISPLAY="${DISPLAY:-:0}" +export EGL_PLATFORM="${EGL_PLATFORM:-x11}" +export DPREFIX="${DPREFIX:-$HOME/.darling}" +export DARLING_HOST_UID="$(id -u)" +export DARLING_HOST_GID="$(id -g)" +export DARLING_WORKSPACE="$WORKSPACE" + +python3 - "$WORKSPACE" <<'PY' +import pathlib, sys +workspace = pathlib.Path(sys.argv[1]) +for path in [workspace, *workspace.parents]: + if path.is_symlink(): + raise SystemExit(f'Refusing symlink workspace ancestor: {path}') +if (workspace / '.launcher.lock').is_symlink(): + raise SystemExit('Refusing symlink workspace lock') +PY +mkdir -p "$WORKSPACE" +# One mutable base runtime per workspace; do not refresh scripts mid-session. +exec 9<>"$WORKSPACE/.launcher.lock" +flock -n 9 || { echo 'Another darling-lol workspace session is active; use its terminal.' >&2; exit 1; } +# Do not attempt to reconnect across separately created user namespaces. No kill, +# sudo, or shutdown is attempted here (the PID might even be stale/reused). +if [ -f "$DPREFIX/.init.pid" ]; then + read -r server_pid < "$DPREFIX/.init.pid" || true + if [[ "${server_pid:-}" =~ ^[1-9][0-9]*$ ]] && [ -d "/proc/$server_pid" ]; then + state=$(awk '/^State:/ {print $2}' "/proc/$server_pid/status") + if [ "$state" != Z ]; then + echo 'Prefix has a live server marker; namespace reconnect is unsupported.' >&2 + echo 'Use the original darling-lol-shell terminal to run darling shutdown, then retry. No processes were stopped.' >&2 + exit 1 + fi + fi +fi +python3 "$SHARE_DIR/scripts/nix-copy.py" "$SHARE_DIR" "$WORKSPACE" +mkdir -p "$WORKSPACE/usr-local" +cd "$WORKSPACE" +# Map subordinate IDs before bubblewrap sets no_new_privs. UID 0 is not host root. +fhs_command=("$UNSHARE_BIN" --user --map-auto --map-current-user --setuid=0 --setgid=0 "$FHS_BIN") +# Keep this supervisor alive to hold the lock: bubblewrap may close extra FDs. +# It never owns or signals game processes; foreground execution carries SIGINT. +case "${1:-}" in + --shell) shift; "${fhs_command[@]}" bash "$@";; + --update) shift; "${fhs_command[@]}" ./scripts/update.sh "$@";; + --installer) shift; "${fhs_command[@]}" bash ./scripts/nix-session.sh --installer "$@";; + *) + if [ $# -gt 0 ]; then + "${fhs_command[@]}" "$@" + else + "${fhs_command[@]}" bash ./scripts/nix-session.sh + fi;; +esac diff --git a/darling-lol.png b/darling-lol.png new file mode 100644 index 0000000000000000000000000000000000000000..8369dfc2fceba02251510572909468b568a61ada GIT binary patch literal 2526 zcmV<42_g20P)}=6G=+cyCE+D96auvtssptn z)Y?i%oOY}oYXwJVoQ}1{jxU3C+HpE|+D_>>T3ggs>T9(QptexUi;Pg9IUvx|gx4eo zBzKpWyUX44IJ*dg3f$i9vzKRczt2qm+kJK?`@Ls(pZz_%03n1BLI@#*5JHLtE9A!f z<7ZyS)0su!m<@@k02%<)0;mGuMR6Gd&?GRv(#k*QxH>07t zb=3_9LQV6S6wd-|uIjdn+%9mwI(oYGUs?vBzP`S^Zz%AD1Xcmys@f(0MKIu*-tvKm z(B7VaE&=e5pEiwW_y&N@QqdmCYOfS>MJ#gg2!=ZVfnei&1{mk3(RSM+IqBhoE$@kh z--Zpgy6Qlv@skYfO*;6)qE>LZDNxsR%dqqSf#CEO2KF}qm3DT3*u@69z><7>0+#a9h*2-n!>TxsXLdczf%sDuuAcSOWL98>p%ZXC}gUCEb2VZNPN%>SR zKWxhZs0lVLqv2gwXbFTGZ&4lq3Hfl|yF?0#OZjc9%UbFxYU&z4B02Wh+AC5}T=LmH z-L3nxE@!1N!R6gb_KFmY;KI@|vlM{y#T>*{BvvR1fKkRpl{~IAAyC)!VcP*PYxsyO zF^-$Timde+C0SdMn1*36;qT8XZBJ$d&L*>J?Q((cBBp;gG&We1i+ZIQFM5q5>?A? zH2;#VM-h8vGX{6InmvV!_*IMpl%mL$&6te3wGSZpt%Wb{EWl;d~!h7vlxCI43F;-rN(QEECZx`Kw0^oqsDH9M_X=~1A!B7Ak68QX5ci zVw?i1@4F4UqX0Ol;$zL`gCw0QjJBXUwXf&aXUE1yI_gXKgJ7;C~pWfykrlkZS9IN)ujd;2fpbO@wYJ09PX= zVlQuoD&7y(LpKzFEC#pjQ+6#-GETqODN_I}3>KL)f)pqTje%|`02UXou)q@fc5>7%D0`{^#gHB-FnzJI^cSwo4vhoK|t0WNp&zjr@Mr%r%M18?tzj*4b#m=&Jj zDAaxTOUe&^=4=$n@KNZ90$^Yal}lSt`@mQC| zp{t?^!1?Ln74QUW;hlVqIlNoGV5TF#_)87$Xw`ORa8U$6^_NF-E-U}Fy0UU7&|QFo z2HtuH$?ylz83n+>a`NcU{si4o02~tg)kX}R=!Whn01hx-K@RvQ2-P&p7@>l;m=*s^y9rzwiotzG@V+L^6i;FWiNAylYqjFl^TV#xK9v55B=y7Qju5zk%U0YymL3 zeR5ty!vg3W0R1MkjW809U1lX!1fUNNFfM?|dF|~x;E=e20I(<=U|jtg7eK{sFcHPp zH=cB=`Iv$LNJs8bj9VY$>i2NlUy({W(#dEomFz=X+h+5B(AVeK(D70G13?UJkXQ@{ z7`Hxue(fPAnkE34lx_BV00BJ<+DTTD@qM-fAWAY@vTus$ND91}ZJDeifrjd_jmN>D z--~X^)pSI=4m2nk2$CEdtSm<~BAKOQW+?zX!`9p0AyQBr>=)LSv!FK;-U~3tMk|O~ z4A|Dw-MU}tvj741n614c1tSDoYjs&m7l9X+tqwNs1TZhZ-6Dm+Ah-8+9lXJbr-2e& ztOA%j%3X^R%RsQN=CXz-mxYskXHJ(_)funNoS*Z)5ywf$r=r~lUv#t{px2-JCM5Rc zx*lStir)08u(u#9o57xSl9CXXMAC}|OjRga%O2N`C(n1|}V6_4;4gFL;F14?^ZyV@K%^eTG{|_MXMUZF)lhcd|LjM^|ssf-1p^dLnc0jVjVAyMvam6Cx o_Z;3fA%qY@2qAt<8 literal 0 HcmV?d00001 diff --git a/darling-lol.svg b/darling-lol.svg new file mode 100644 index 0000000..7ec69b9 --- /dev/null +++ b/darling-lol.svg @@ -0,0 +1,6 @@ + + darling-lol + + + + diff --git a/docs/SETUP.md b/docs/SETUP.md index 7c669a2..e0a7b18 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -1,5 +1,74 @@ # Setup and portability +## NixOS package + +Use `darling-lol`, `darling-lol --installer`, or `darling-lol-shell` rather than +invoking the raw FHS environment. The launcher uses `unshare --map-auto` before +bubblewrap, so the user needs subordinate UID/GID ranges (`/etc/subuid` and +`/etc/subgid`, or NixOS `users.users..autoSubUidGidRange = true`). +Namespace root maps to a subordinate host ID, not host root. The FHS entrypoint +restores the real Linux user's IDs while retaining namespace-only capabilities; +the Darling shim temporarily switches effective IDs for mounts. No `sudo`, +root-owned binary in a writable workspace, or host setuid installation is needed. + +The launcher refreshes managed scripts with executable bits and owner-write access, +rejecting symlinks in that managed tree. It preserves `build/`, `usr-local/`, and +the guest prefix. Edit the source checkout, not copied workspace tools. + +First setup: run `darling-lol --installer` in an interactive terminal, choose the +build when prompted, then choose the Riot region. The desktop entry uses +`Terminal=true` and delegates terminal selection to the desktop environment. +A launch with EOF stdin fails with a command to run in a terminal; it does not +try a hard-coded list of terminal emulators or silently abandon a prompt. + +Update: after manually exiting League and both Riot clients, shut down Darling +in the ORIGINAL `darling-lol-shell` terminal with `darling shutdown`, then exit +that shell. Upgrade the Nix package and run `darling-lol --update` in a terminal. +This rebuilds incrementally, installs into the user-owned `/usr/local` bind, +and installs paired per-prefix overlays without sudo. For a first-time setup, +the same rootless update path performs the base build/install. Native/manual +(non-Nix) instructions below still use their normal privileged base install. + +Readiness JSON under `build/nix-ready/` is keyed by canonical prefix and checks +the configured guest and resolved host guest home, UID/GID, package path, +maintained patch/tool identities, executable modes, base SDK/runtime metadata +and required-overlay inventory. Stamping reuses required-artifact validation. +Changing `RIOT_GUEST_HOME` invalidates readiness. Explicit `DPREFIX` and +`RIOT_GUEST_HOME` environment overrides take precedence over saved configuration; +clear stale values before selecting a different installation. A separate base +manifest avoids rebuilding when only a new prefix needs overlays. +Updates invalidate readiness before replacing the base; failed/interrupted setup +cannot leave the previous prefix stamp authoritative. Existing app bundles never +bypass these checks. Normal ready launches only check state, not rebuild. + +Operational limits: each workspace supports one foreground namespace session. +The launcher holds a lock while its child runs, even if bubblewrap closes file +descriptors. It refuses a live `.init.pid` rather than reconnecting across new +user namespaces or killing anything. A stale/reused live PID can therefore block +a launch conservatively; inspect it before taking manual action. Never remove a +marker just to force another launch. Manual stop/reconnect and game lifecycle +acceptance have not been exercised with this repaired package. Keep the original +shell open if you need to stop a session. Maintenance requires that prefix idle; +rootless CMake's shutdown hook checks only that prefix, never enumerates and +shuts down every user's launchd. The inherited scoped stop helper remains used +by overlay installers; it is fixture-tested, not live-game tested here. + +Mutable Linux ELF outputs can retain Nix-store interpreter/library references. +A native compiler probe executed successfully with its store-path interpreter; +there is no evidence that all such outputs require `patchelf`. The update path +creates indirect closure roots under `build/nix-gcroots/` before compiling and +retains old package roots for incremental outputs. Keep those roots while using +the workspace; remove them only when discarding/rebuilding all dependent mutable +outputs. This trades disk retention for GC safety, not a claim of full runtime +relocatability. A full Darling build/upgrade and GC acceptance test remains open. + +The FFmpeg dependency is explicitly 8.x: generated Darling AV wrappers reference +`libavformat.so.62`, `libavcodec.so.62`, and `libavutil.so.60`; FFmpeg 9 cannot +replace those ABI versions. + +Run the package regression suite with `python3 -m unittest discover -s tests -v`. + + The patch manifest pins Darling to `60ba801decee7a00782f74f6be4c8ffb013f79ff` and records every changed submodule's revision. `scripts/prepare.py` checks each revision before applying patches and never resets an existing checkout to make a patch apply. Re-running it accepts an already-applied patch. Git LFS must be available before fetching the upstream submodules. The pinned `darling-swift` submodule's `.lfsconfig` points at the retired `git-lfs.darlinghq.org`, which no longer accepts connections; `prepare.py` redirects that submodule's LFS requests to GitHub, which still serves the objects. Without that override a fresh `git submodule update` blocks with no output right after `src/external/sqlite` is checked out. The local development checkout had removed prebuilt Swift files from its working directory; that deletion is deliberately not exported. A fresh upstream checkout keeps its original Swift assets and licensing. Do not install Git LFS pointer text as a library. @@ -12,22 +81,7 @@ python3 scripts/configure.py --guest-user my-linux-user python3 scripts/configure.py --sdk /usr/local/libexec/darling ``` -The runtime installation wrappers resolve `DPREFIX` first, otherwise the saved -`configure.py --prefix` setting. The configured guest account supplies the -default guest home. `RIOT_GUEST_HOME`, when set, is an absolute **host** path -inside the selected prefix, not a guest `/Users/...` path. Installers, runtime -validation and the Riot launcher's guest library paths use that selection. -Paths containing spaces are supported by isolated regression fixtures. Explicit -environment overrides win over saved configuration; clear stale values before -selecting a different installation. - -A complete real installation using a custom prefix has not been verified. -Archived tools may still assume the default layout. Runtime validation fails on -missing expected binaries, unreadable/missing directories or checker errors; -an empty input set is not a successful validation. - -Runtime installation requires the selected runtime to stop before overlay -replacement. Failed shutdown blocks replacement. +The normal wrappers currently support the default `~/.darling` prefix and `/usr/local` base installation. The configurator exposes a custom prefix for developers, but several archived tools assume the default layout. Custom prefixes/install locations have not been tested end to end. The build uses Unix Makefiles because the incremental runtime helpers read CMake `flags.make` and `link.txt`. Ninja is not a drop-in replacement for those helpers. @@ -39,7 +93,7 @@ Linux 5.14 or newer is required for `MADV_POPULATE_WRITE`, used when Darwin's al The runtime installer requires native `pkg-config` entries for `x11`, `x11-xcb`, `xcb-shm` and `xext`, plus the base Darling SDK. -Run configuration, builds and the per-user runtime installation as your ordinary account. Use sudo only for the base CMake installation and the incremental server installer. Running the whole build or per-user install under sudo can create root-owned files and configure the wrong guest home. `scripts/install-runtime.sh` initializes the selected user prefix and creates its `private/tmp` directory with mode `1777`, so the installer can write guest temporary files. It then shuts down Darling before replacing the paired runtime: first exit the game and both clients manually. +Run configuration, builds and the per-user runtime installation as your ordinary account. Use sudo only for the base CMake installation and the incremental server installer. Running the whole build or per-user install under sudo can create root-owned files and configure the wrong guest home. `scripts/install-runtime.sh` initializes the default user prefix and creates `~/.darling/private/tmp` with mode `1777`, so the installer can write guest temporary files. It then shuts down Darling before replacing the paired runtime: first exit the game and both clients manually. The normal launcher starts neither an Electron inspector nor client API/UI helpers. It clears inherited `DYLD_INSERT_LIBRARIES`, retains Darling runtime library/framework overrides, supplies an absolute log directory to the launcher, and selects the League product. The log-directory/Chromium argument shim, empty-autofill hook and optional native VPX bridge are no longer injected or built by runtime installation. Existing copies on disk are inactive and may remain. Child-process log-directory rewriting and Chromium feature overrides previously supplied by the shim are removed; client startup without those workarounds needs manual validation. The first-time installer uses `--skip-to-install` and omits the installed client's `--app-root`, avoiding an immediate exit or inaccessible intro screen. The project ships no inspector hooks, client API helpers, input automation or client binary patches. Keep account data, logs and captures private. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 954d6e7..6c24424 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -1,5 +1,23 @@ # Troubleshooting +## NixOS launcher fails before Riot opens + +A message saying Darling must be setuid root is not fixed by running `sudo` +inside `buildFHSEnv`: bubblewrap enables `no_new_privs`, so setuid elevation is +ignored there. Use the package's rootless launcher with subordinate UID/GID +mapping enabled. Never make a mutable workspace executable host-setuid-root. + +If a newly installed client aborts with `Cannot load libavformat.so.62 (ELF)`, +check the FHS runtime dependencies. The generated Darling wrappers use FFmpeg 8 +SONAMEs, so an unversioned nixpkgs `ffmpeg` dependency can break them when its +major changes. The package pins `ffmpeg_8`; do not create fake ABI symlinks. + +Older packages used `cp -n` and never refreshed already-copied tools. The current +launcher replaces packaged source files while retaining compiled/runtime data. +An incomplete prefix can be backed up by renaming it with Darling stopped; +never delete a populated Riot/game prefix just to repair the launcher. + + Use the [setup instructions](SETUP.md) from the repository root as your ordinary user. Exit the game and both clients manually before a full installation or Darling restart. Incremental server/memory installers can replace files without interrupting processes, but their changes need a later full restart to activate. ## Memory installer: permission denied @@ -56,18 +74,6 @@ The installer fetcher no longer treats a missing archive `Info.plist` or the inf There is also a separate known compatibility gap: native LaunchAgent watch registration can return HTTP 409 after the League install path is accepted, leaving the download idle. A historical inspector hook handles this and interrupted `pending_verify` states in 28 mock cases, but that hook is disabled in normal launches and excluded from the manual workflow. There is no verified runtime-only replacement for that handoff in this snapshot. Do not assume the native permission and launcher fixes resolve every idle Install case, or enable debugger/API automation to work around it. -## `Unable to read system root certificates` or `Error -25263` - -Riot logs `CertBundle: Unable to read system root certificates` and Chromium logs `[known_roots_mac.cc(60)] Unable to determine trusted roots; assuming all roots are trusted! Error -25263` when the prefix has no system trust domain. Security reads that domain from `/System/Library/Keychains/SystemRootCertificates.keychain` and `SystemTrustSettings.plist`, which Darling does not ship, so `SecTrustSettingsCopyCertificates` returns `errSecNoTrustSettings`. - -`tools/ensure-darling-root-certificates.py` builds both from the host CA bundle (`/etc/ssl/cert.pem` or whichever bundle Python's `ssl` reports; `--bundle` overrides it). The launcher and `scripts/install-runtime.sh` run it. Each self-signed root gets an unrestricted trust entry. The keychain is built with the guest `security` tool in a single `darling shell`. A stamp in the same directory records the bundle's digest, so later launches skip it without starting Darling until the host bundle changes. If Darling was already running and the guest could not write the directory, the script installs the files on the host and says they apply after `darling shutdown`. A failure prints `Darling system root certificates were not installed: …` and the launch continues. To rebuild by hand: - -```sh -python3 build/runtime/tools/ensure-darling-root-certificates.py --force -``` - -`python3 -m unittest tests.test_root_certificates` compares the generated SHA-1 keys, issuer names and serial numbers with OpenSSL, and checks that an unchanged bundle is skipped. It uses stub `darling` and `security` commands. Reading the keychain and the system trust domain inside a real guest (`security find-certificate -a /System/Library/Keychains/SystemRootCertificates.keychain`, `security dump-trust-settings -s`) is not yet confirmed. - ## Riot closes after a network lookup is cancelled Two local crashes were traced to `CFNetwork::_HostCancel` calling `CFRetain(NULL)`. @@ -246,18 +252,6 @@ A non-zero error or `FAILED` means the frames cannot reach the window. Before 20 An additional NVIDIA report on `68f816d` showed successful optimal-tiling import but repeated `Metal Device timed out giving us a Drawable to present` messages. Its [presentation trace](https://github.com/lucannez64/darling-lol/issues/3#issuecomment-6214732591) shows all three drawables queued and all three main-thread presentation callbacks running, followed by timeouts with three frames still queued. The compositor only prepared a Metal layer when it was the root layer, skipping nested Metal layers. It now prepares Metal layers throughout the tree. A standalone nested-layer reproduction exhausts the pool before this correction and passes ten presentations with pixel readback after it; the root-layer probe also passes. Confirmation on the reporter's NVIDIA system is still needed. -### What Metal reports about the GPU - -`MTLDevice.name` is the selected Vulkan device's name in macOS style: the trailing driver note in brackets and the `(R)`/`(TM)` marks are removed (`AMD Radeon RX 6800 XT (RADV NAVI21)` becomes `AMD Radeon RX 6800 XT`, `Intel(R) UHD Graphics 630 (CML GT2)` becomes `Intel UHD Graphics 630`, `llvmpipe (LLVM …)` becomes `llvmpipe`). NVIDIA names are unchanged. The name is never replaced with another vendor's GPU. Before 2026-10-17 it was always `Darling Indium Device`. - -`recommendedMaxWorkingSetSize` is the size of the device-local Vulkan heaps (the VRAM) on a discrete GPU. On integrated and software GPUs it is the size of all heaps, because their device-local heap can be just the firmware carve-out while they also use system RAM; macOS likewise reports a large share of system RAM for unified-memory GPUs. It falls back to the old 1 GiB when Vulkan reports no usable heap. `hasUnifiedMemory` stays `YES` by default: with `NO`, apps switch to managed and private resource paths (`synchronizeResource:`, private buffers created with data) that are not implemented here. Set these in the game's environment to override: - -- `DARLING_METAL_GPU_NAME=`: report this name. -- `DARLING_METAL_VRAM_MB=`: report this working-set size in MiB. Other values are ignored. -- `DARLING_METAL_HOST_UNIFIED_MEMORY=1`: report `hasUnifiedMemory` from the GPU type (`YES` for integrated and software GPUs, `NO` for discrete ones). This is experimental; on a discrete GPU it can break rendering. - -Each process reads these once. - ### Host-side checks These are host settings, not game settings. Run on AC power, and use a performance power profile (`powerprofilesctl set performance`, or `asusctl profile -P Performance` on ASUS laptops). On the tested desktop the game is limited mostly by its own main thread on the CPU, not by the GPU (see [status](STATUS.md#game-fps-2026-10-1314)), so laptop CPU power limits matter as much as the GPU choice. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..6197b4b --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1791956154, + "narHash": "sha256-jBYGPi4udQ9uo4KQp9m2J0546nJVQitG15gvZSaVVAs=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "55bfb4d7437bd4e09ab7cba58a43bd68eb310044", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4fe1a74 --- /dev/null +++ b/flake.nix @@ -0,0 +1,328 @@ +{ + description = "Darling League of Legends - experimental macOS LoL runner on Linux via Darling"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + + # Python environment with dependencies required by tooling + pythonEnv = pkgs.python3.withPackages ( + ps: with ps; [ + requests + urllib3 + websocket-client + pillow + ] + ); + + darlingEntry = pkgs.writeShellScript "darling-lol-entry" (builtins.readFile ./scripts/fhs-entry.sh); + darlingRunner = pkgs.runCommand "darling-lol-rootless-runner" { } '' + mkdir -p $out/bin + cp ${./scripts/darling-rootless.sh} $out/bin/darling + chmod +x $out/bin/darling + patchShebangs $out/bin/darling + ''; + + # Common runtime libraries needed by Darling, Vulkan, X11, Audio, and graphics + buildDeps = with pkgs; [ + # Build tools & compilers + cmake + gnumake + ninja + pkg-config + clang + lld + llvm + llvm.dev + llvmPackages.clang-unwrapped + bison + flex + git + git-lfs + nix + curl + unzip + file + patchelf + util-linux + + # System & core libraries + glibc + glibc.dev + glibc.static + zlib + zlib.dev + libffi + libffi.dev + libbsd + libbsd.dev + libmd + libmd.dev + libcap + libelf + fuse + systemdLibs # libudev + dbus + libxml2 + openssl + libedit + + # Graphics & Display + libGL + libGLU + mesa + vulkan-headers + vulkan-loader + vulkan-tools + cairo + freetype + fontconfig + libpng + libpng.dev + libjpeg + libjpeg.dev + libtiff + giflib + # Darling's generated AV wrappers bind FFmpeg 8 SONAMEs (e.g. .62). + # Do not let nixpkgs' default FFmpeg major silently break installed builds. + ffmpeg_8 + + # X11 & input + xorgproto + libx11 + libxext + libxrandr + libxcursor + libxinerama + libxcomposite + libxdamage + libxfixes + libxi + libxrender + libxtst + libxcb + xcbutil + xcbutilwm + xcbutilimage + xcbutilkeysyms + xcbutilrenderutil + libxkbfile + libxkbcommon + + # Audio + pulseaudio + libpulseaudio + alsa-lib + + # Python + pythonEnv + ]; + + # FHS environment for building and running Darling and League of Legends. + # Darling requires root/multitasking mount & namespace semantics, /usr/local layout, + # and dynamically compiled overlays. An FHS environment provides the standard standard + # filesystem hierarchies (/usr/include, /usr/lib, /usr/bin) and native dynamic linker. + darlingLolFhs = pkgs.buildFHSEnv { + name = "darling-lol-fhs"; + targetPkgs = pkgs: buildDeps; + multiPkgs = pkgs: [ pkgs.glibc ]; + + extraOutputsToInstall = [ "dev" "out" ]; + + extraBwrapArgs = [ + # The outer user namespace maps subordinate IDs before no_new_privs. + # These capabilities apply only inside that namespace, not on the host. + "--cap-add" "ALL" + "--bind-try" (''$'' + ''{DARLING_WORKSPACE}/usr-local'') "/usr-local" + ]; + + extraBuildCommands = '' + mkdir -p bin usr/bin + rm -f bin/clang bin/clang++ usr/bin/clang usr/bin/clang++ + ln -sfn /usr-local $out/usr/local + cat << 'EOF' > usr/bin/clang +#!/usr/bin/env bash +is_darwin=0 +for a in "$@"; do + if [[ "$a" == *darwin* ]] || [[ "$a" == *apple* ]] || [[ "$a" == *macosx* ]]; then + is_darwin=1 + break + fi +done +if [[ "$is_darwin" -eq 1 ]]; then + exec ${pkgs.llvmPackages.clang-unwrapped}/bin/clang "$@" +else + exec ${pkgs.clang}/bin/clang -idirafter /usr/include "$@" +fi +EOF + chmod +x usr/bin/clang + + cat << 'EOF' > usr/bin/clang++ +#!/usr/bin/env bash +is_darwin=0 +for a in "$@"; do + if [[ "$a" == *darwin* ]] || [[ "$a" == *apple* ]] || [[ "$a" == *macosx* ]]; then + is_darwin=1 + break + fi +done +if [[ "$is_darwin" -eq 1 ]]; then + exec ${pkgs.llvmPackages.clang-unwrapped}/bin/clang++ "$@" +else + exec ${pkgs.clang}/bin/clang++ -idirafter /usr/include "$@" +fi +EOF + chmod +x usr/bin/clang++ + cp -f usr/bin/clang bin/clang + cp -f usr/bin/clang++ bin/clang++ + ''; + + profile = '' + export DPREFIX="''${DPREFIX:-$HOME/.darling}" + export LC_ALL=C.UTF-8 + export LANG=C.UTF-8 + export DARLING_RUNNER_DIR=${darlingRunner}/bin + export PATH="$DARLING_RUNNER_DIR:/usr/local/bin:''${PATH:-}" + export PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/lib/pkgconfig:/usr/lib64/pkgconfig:/usr/share/pkgconfig:''${PKG_CONFIG_PATH:-}" + export CMAKE_PREFIX_PATH="/usr/local:/usr:/usr/lib/cmake/llvm:''${CMAKE_PREFIX_PATH:-}" + unset NIX_LDFLAGS NIX_CFLAGS_COMPILE NIX_HARDENING_ENABLE CPATH LIBRARY_PATH + ''; + + runScript = pkgs.writeShellScript "darling-lol-run" '' + if [ $# -eq 0 ]; then + exec ${darlingEntry} bash + else + exec ${darlingEntry} "$@" + fi + ''; + }; + + # Wrapper script package to run scripts directly + darlingLol = pkgs.stdenv.mkDerivation { + pname = "darling-lol"; + version = "0.1.0"; + src = pkgs.lib.cleanSourceWith { + src = ./.; + filter = path: type: + let name = baseNameOf path; in + !(builtins.elem name [ ".git" "build" "__pycache__" "usr-local" "darling-install" ]) + && !(pkgs.lib.hasPrefix "result" name); + }; + + nativeBuildInputs = [ + pkgs.makeWrapper + ]; + + installPhase = '' + runHook preInstall + + mkdir -p $out/bin $out/share/darling-lol $out/share/icons/hicolor/scalable/apps $out/share/icons/hicolor/128x128/apps $out/share/pixmaps $out/share/applications + cp -r * $out/share/darling-lol/ + cp darling-lol.svg $out/share/icons/hicolor/scalable/apps/darling-lol.svg + cp darling-lol.png $out/share/icons/hicolor/128x128/apps/darling-lol.png + cp darling-lol.png $out/share/pixmaps/darling-lol.png + cp darling-lol.svg $out/share/pixmaps/darling-lol.svg + + # Install configured launcher + substitute ./darling-lol-launcher.sh $out/bin/darling-lol \ + --replace-fail "@SHARE_DIR@" "$out/share/darling-lol" \ + --replace-fail "@FHS_BIN@" "${darlingLolFhs}/bin/darling-lol-fhs" \ + --replace-fail "@UNSHARE_BIN@" "${pkgs.util-linux}/bin/unshare" + chmod +x $out/bin/darling-lol + wrapProgram $out/bin/darling-lol --prefix PATH : ${pkgs.lib.makeBinPath [ pythonEnv pkgs.coreutils pkgs.util-linux pkgs.gawk ]} + + makeWrapper $out/bin/darling-lol $out/bin/darling-lol-installer \ + --add-flags "--installer" + + makeWrapper $out/bin/darling-lol $out/bin/darling-lol-shell \ + --add-flags "--shell" + + makeWrapper $out/bin/darling-lol $out/bin/darling-lol-build \ + --add-flags "./scripts/build.sh" + + cat << 'DESKTOP_EOF' > $out/share/applications/darling-lol.desktop +[Desktop Entry] +Categories=Game; +Comment=Play League of Legends macOS client on Linux via Darling +Exec=darling-lol +GenericName=MOBA Game +Icon=darling-lol +Keywords=League;LoL;Riot;Darling; +Name=League of Legends +StartupWMClass=RiotClientServices +Terminal=true +Type=Application +Version=1.5 +DESKTOP_EOF + + runHook postInstall + ''; + + meta = with pkgs.lib; { + description = "Tooling and patches to run macOS League of Legends on Linux with Darling"; + homepage = "https://github.com/lucannez64/darling-lol"; + license = licenses.gpl3Only; + platforms = [ "x86_64-linux" ]; + }; + }; + in + { + checks.${system}.packaging = pkgs.runCommand "darling-lol-packaging-tests" { + nativeBuildInputs = [ pythonEnv pkgs.bash pkgs.git pkgs.coreutils pkgs.util-linux pkgs.gawk ]; + } '' + cp -R ${darlingLol}/share/darling-lol source + chmod -R u+w source + cd source + export HOME="$TMPDIR/home" PYTHONDONTWRITEBYTECODE=1 + mkdir -p "$HOME" + # Tests use private stub binaries/repositories, never Darling itself. + python3 -m unittest discover -s tests -v + python3 scripts/validate.py + grep -q '^Terminal=true$' ${darlingLol}/share/applications/darling-lol.desktop + touch "$out" + ''; + + packages.${system} = { + darling-lol = darlingLol; + darling-lol-fhs = darlingLolFhs; + default = darlingLol; + }; + + apps.${system} = { + default = { + type = "app"; + program = "${darlingLol}/bin/darling-lol"; + meta.description = "Launch League of Legends via Darling"; + }; + shell = { + type = "app"; + program = "${darlingLol}/bin/darling-lol-shell"; + meta.description = "Open an interactive shell inside the Darling FHS environment"; + }; + installer = { + type = "app"; + program = "${darlingLol}/bin/darling-lol-installer"; + meta.description = "Run the Darling and League of Legends rootless installer"; + }; + build = { + type = "app"; + program = "${darlingLol}/bin/darling-lol-build"; + meta.description = "Build the Darling runtime tree inside the FHS sandbox"; + }; + }; + + devShells.${system}.default = pkgs.mkShell { + packages = buildDeps ++ [ darlingLolFhs ]; + shellHook = '' + echo "Darling LoL build & development environment loaded." + echo "Tip: Run 'darling-lol-fhs' to enter the FHS build sandbox if standard paths (/usr/include, /usr/lib) are needed." + ''; + }; + }; +} diff --git a/patches/cctools-port.patch b/patches/cctools-port.patch new file mode 100644 index 0000000..eacef6a --- /dev/null +++ b/patches/cctools-port.patch @@ -0,0 +1,38 @@ +diff --git a/cctools/ld64/src/3rd/strlcpy.h b/cctools/ld64/src/3rd/strlcpy.h +--- a/cctools/ld64/src/3rd/strlcpy.h ++++ b/cctools/ld64/src/3rd/strlcpy.h +@@ -7,8 +7,15 @@ + extern "C" { + #endif + #include ++#include + ++#if defined(__GLIBC__) ++# if !__GLIBC_PREREQ(2, 38) + size_t strlcpy(char *dst, const char *src, size_t siz); ++# endif ++#else ++size_t strlcpy(char *dst, const char *src, size_t siz); ++#endif + + #ifdef __cplusplus + } +diff --git a/cctools/ld64/src/3rd/strlcat.h b/cctools/ld64/src/3rd/strlcat.h +--- a/cctools/ld64/src/3rd/strlcat.h ++++ b/cctools/ld64/src/3rd/strlcat.h +@@ -7,8 +7,15 @@ + extern "C" { + #endif + #include ++#include + ++#if defined(__GLIBC__) ++# if !__GLIBC_PREREQ(2, 38) + size_t strlcat(char *dst, const char *src, size_t siz); ++# endif ++#else ++size_t strlcat(char *dst, const char *src, size_t siz); ++#endif + + #ifdef __cplusplus + } diff --git a/patches/darling.patch b/patches/darling.patch index 00f4482..b399000 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -10588,13 +10588,19 @@ diff --git a/tools/shutdown-user.sh b/tools/shutdown-user.sh index 072a6e2..c804100 100755 --- a/tools/shutdown-user.sh +++ b/tools/shutdown-user.sh -@@ -1,26 +1,34 @@ +@@ -1,26 +1,40 @@ #!/bin/sh -if [ "$(whoami)" != "root" ] -then - echo "$0 must be run as root, invoking sudo" - exec sudo su -c "$0" "$@" ++# Rootless installs own only this workspace/prefix, never all launchd users. ++if [ -n "${DARLING_WORKSPACE:-}" ]; then ++ : "${DPREFIX:?rootless install requires an explicit prefix}" ++ exec python3 "$DARLING_WORKSPACE/scripts/nix-idle.py" ++fi ++ +if [ "$(whoami)" != "root" ]; then + echo "$0 must be run as root, invoking sudo" + exec sudo sh "$0" "$@" diff --git a/scripts/build.sh b/scripts/build.sh index 6113553..6bb9a13 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -20,4 +20,8 @@ cmake -S build/darling-src -B build/darling-src/build -G 'Unix Makefiles' \ -DCMAKE_C_FLAGS="-O3 -march=native -mtune=generic -pipe" \ -DCMAKE_CXX_FLAGS="-O3 -march=native -mtune=generic -pipe" cmake --build build/darling-src/build --parallel "${JOBS:-$(nproc)}" -echo 'Build complete. Install the base runtime with: sudo cmake --install build/darling-src/build' +if [ -n "${DARLING_WORKSPACE:-}" ]; then + echo 'Build complete. Install the base runtime with: cmake --install build/darling-src/build' +else + echo 'Build complete. Install the base runtime with: sudo cmake --install build/darling-src/build' +fi diff --git a/scripts/darling-rootless.sh b/scripts/darling-rootless.sh new file mode 100644 index 0000000..6414a70 --- /dev/null +++ b/scripts/darling-rootless.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${DARLING_HOST_UID:?missing Darling user namespace}" +: "${DARLING_WORKSPACE:?missing Darling workspace}" +if [ "${DARLING_SETUP:-0}" != 1 ] && [ "${1:-}" != shutdown ]; then + python3 "$DARLING_WORKSPACE/scripts/nix-state.py" check || { + echo 'Rootless runtime readiness missing; run darling-lol --installer in a terminal.' >&2 + exit 1 + } +fi +# This is namespace root (a subordinate host UID), not host root. Keep real +# UID/GID intact so Darling initializes the correct guest account and ownership. +exec setpriv --euid 0 --egid 0 --keep-groups \ + "$DARLING_WORKSPACE/usr-local/bin/darling" "$@" diff --git a/scripts/fhs-entry.sh b/scripts/fhs-entry.sh new file mode 100644 index 0000000..c6950ba --- /dev/null +++ b/scripts/fhs-entry.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${DARLING_HOST_UID:?start this environment with darling-lol}" +: "${DARLING_HOST_GID:?start this environment with darling-lol}" +# Carry only namespace-scoped capabilities into the ordinary-user process. +# Darling's shim temporarily switches effective IDs; real IDs stay the user's. +exec setpriv --ruid "$DARLING_HOST_UID" --euid "$DARLING_HOST_UID" \ + --rgid "$DARLING_HOST_GID" --egid "$DARLING_HOST_GID" --keep-groups \ + --inh-caps +all --ambient-caps +all "$@" diff --git a/scripts/install-runtime.sh b/scripts/install-runtime.sh index 6d3c345..677b2ab 100755 --- a/scripts/install-runtime.sh +++ b/scripts/install-runtime.sh @@ -3,6 +3,13 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" +if [ -n "${DARLING_WORKSPACE:-}" ]; then + python3 scripts/nix-idle.py + python3 scripts/nix-state.py check-base || { echo 'Base runtime readiness missing; run darling-lol --update.' >&2; exit 1; } + python3 scripts/nix-state.py invalidate + # Prefix initialization is a setup-only exception to the runner launch gate. + export DARLING_SETUP=1 +fi python3 scripts/configure.py . build/runtime/tools/runtime-paths.sh if [ -d build/venv/bin ]; then PATH="$repo/build/venv/bin:$PATH"; export PATH; fi @@ -16,7 +23,7 @@ prefix=$prefix_dir mkdir -p "$prefix/private/tmp" chmod 1777 "$prefix/private/tmp" # Build the system root store now that the prefix exists; Darling is stopped next anyway. -python3 build/runtime/tools/ensure-darling-root-certificates.py || +python3 build/runtime/tools/ensure-darling-root-certificates.py || \ echo 'Darling system root store not installed; the launcher retries it' >&2 python3 build/runtime/tools/stop-darling-runtime.py sh build/runtime/tools/install-riot-presentation-runtime.sh @@ -29,3 +36,9 @@ fi python3 build/runtime/tools/install-darling-system-profiler.py # Every guest process loads these overlays; refuse a set the compiler broke (check-darwin-tlv-calls.py). python3 build/runtime/tools/validate-riot-runtime.py +# Mark this canonical prefix and configured guest ready only after validation. +if [ -n "${DARLING_WORKSPACE:-}" ]; then + # Overlay installation can also refresh darlingserver; record the final base. + python3 scripts/nix-state.py stamp-base + python3 scripts/nix-state.py stamp +fi diff --git a/scripts/installer.sh b/scripts/installer.sh index 62c7ed6..48f607c 100755 --- a/scripts/installer.sh +++ b/scripts/installer.sh @@ -2,10 +2,13 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" +if [ -n "${DARLING_WORKSPACE:-}" ] && ! python3 scripts/nix-state.py check; then + echo 'Rootless runtime readiness is missing or incompatible; run darling-lol --installer in a terminal.' >&2 + exit 1 +fi +export PATH="${DARLING_RUNNER_DIR:+$DARLING_RUNNER_DIR:}$repo/usr-local/bin:/usr/local/bin:$PATH" region=${1:-euw} -# Riot publishes LAN, LAS and OCE installers under their platform codes. -case "$region" in lan) region=la1;; las) region=la2;; oce) region=oc1;; esac -case "$region" in euw|eune|na|br|kr|jp|la1|la2|oc1|ru|tr) ;; *) echo 'Use a Riot installer region code' >&2; exit 1;; esac +case "$region" in euw|eune|na|br|kr|jp|lan|las|oce|ru|tr) ;; *) echo 'Use a Riot installer region code' >&2; exit 1;; esac python3 scripts/configure.py >/dev/null bash build/runtime/tools/fetch-installer.sh "$region" build/installer unzip -q -o "build/installer/live.$region.zip" -d build/installer diff --git a/scripts/nix-copy.py b/scripts/nix-copy.py new file mode 100644 index 0000000..17874cb --- /dev/null +++ b/scripts/nix-copy.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Refresh managed package files without following mutable destination symlinks.""" +import os +from pathlib import Path +import shutil +import stat +import sys +import tempfile + +source, destination = map(Path, sys.argv[1:]) +items = 'docs patches research scripts tests tools upstream LICENSE NOTICE.md README.md requirements.txt'.split() +# Preflight the entire managed tree before writing anything. Never chmod or copy +# through a link planted by a previous installation (including directory links). +for root in [destination, *destination.parents]: + if root.is_symlink(): + raise SystemExit(f'Refusing symlink workspace ancestor: {root}') +for item in items: + for base in [source / item, destination / item]: + for path in [base, *base.rglob('*')] if base.is_dir() else [base]: + if path.is_symlink(): + raise SystemExit(f'Refusing symlink in managed package tree: {path}') + if path.exists() and not (path.is_file() or path.is_dir()): + raise SystemExit(f'Refusing special file in managed package tree: {path}') +for item in items: + src = source / item + if not src.exists(): + continue + for path in [src, *src.rglob('*')] if src.is_dir() else [src]: + target = destination / path.relative_to(source) + if path.is_dir(): + target.mkdir(parents=True, exist_ok=True) + target.chmod(0o755) + else: + target.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix='.package-', dir=target.parent) + try: + with os.fdopen(fd, 'wb') as output, path.open('rb') as input_file: + shutil.copyfileobj(input_file, output) + # Keep executable bits, remove special bits, and keep owner writable. + os.chmod(temporary, stat.S_IMODE(path.stat().st_mode) & 0o777 | 0o600) + os.replace(temporary, target) + finally: + if os.path.exists(temporary): + os.unlink(temporary) diff --git a/scripts/nix-idle.py b/scripts/nix-idle.py new file mode 100644 index 0000000..8ce0dc0 --- /dev/null +++ b/scripts/nix-idle.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Read-only, fail-closed rootless maintenance preflight. Never signal a process.""" +import os +from pathlib import Path + +prefix = Path(os.environ.get('DPREFIX', str(Path.home() / '.darling'))).resolve() +try: + pid = int((prefix / '.init.pid').read_text().strip()) +except FileNotFoundError: + pid = 0 +except (OSError, ValueError): + raise SystemExit('Cannot validate prefix server marker; inspect it before maintenance.') +if pid > 0: + try: + status = (Path('/proc') / str(pid) / 'status').read_text() + if not any(line.startswith('State:') and line.split()[1] == 'Z' for line in status.splitlines()): + raise SystemExit('Prefix has a live server marker; exit Riot/League and run darling shutdown in the ORIGINAL namespace before updating. No process was stopped.') + except FileNotFoundError: + pass +# Only inspect same-user mappings under this exact prefix. An unrelated game's +# command name is not grounds to stop or modify it. Unreadable same-user maps +# fail closed because we cannot rule out a client still using these files. +paths = [str(prefix / name) + '/' for name in ( + 'Applications/Riot Client.app', 'Applications/League of Legends.app', + 'Users/Shared/Riot Games')] +for process in Path('/proc').iterdir(): + if not process.name.isdigit(): + continue + try: + if process.stat().st_uid != os.getuid(): + continue + mappings = (process / 'maps').read_text() + if any(path in mappings for path in paths): + raise SystemExit('A client maps this prefix; exit it before rootless maintenance. No process was stopped.') + except (FileNotFoundError, ProcessLookupError, PermissionError): + continue diff --git a/scripts/nix-session.sh b/scripts/nix-session.sh new file mode 100755 index 0000000..9626465 --- /dev/null +++ b/scripts/nix-session.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Run setup and the client in the SAME namespace. Never reconnect by killing it. +set -euo pipefail +cd "${DARLING_WORKSPACE:?start with darling-lol}" +ready() { python3 scripts/nix-state.py check; } +interactive() { + if [ ! -t 0 ]; then + echo 'Setup needs an interactive terminal. Run darling-lol --installer in a terminal (or use the desktop entry with Terminal=true).' >&2 + exit 1 + fi +} +if ! ready; then + interactive + echo "Prefix: $DPREFIX" + if ! python3 scripts/nix-state.py check-base; then + echo 'Base runtime missing or incompatible; building requires about 40–50 GB.' + read -r -p 'Build/update the rootless runtime? [y/N] ' reply || exit 1 + case "$reply" in y|Y) ;; *) exit 1;; esac + ./scripts/update.sh + else + ./scripts/install-runtime.sh + fi +fi +# Ready existing installations never rebuild merely because the launcher opens. +if [ "${1:-}" = --installer ] || + { [ ! -e "$DPREFIX/Applications/Riot Client.app" ] && + [ ! -d "$DPREFIX/Users/Shared/Riot Games/Riot Client.app" ]; }; then + interactive + read -r -p 'Region [euw]: ' region || exit 1 + exec ./scripts/installer.sh "${region:-euw}" +fi +exec ./scripts/run.sh diff --git a/scripts/nix-state.py b/scripts/nix-state.py new file mode 100644 index 0000000..e52dff4 --- /dev/null +++ b/scripts/nix-state.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Per-prefix rootless readiness; no Darling execution or prefix writes.""" +import hashlib +import json +import os +from pathlib import Path +import pwd +import sys + +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +from runtime_paths import runtime_paths + + +def selected_paths(): + config_path = ROOT / 'build/config.json' + config = json.loads(config_path.read_text()) if config_path.exists() else {} + guest = config.get('guest_user') or pwd.getpwuid(os.getuid()).pw_name + config.setdefault('prefix', str(Path.home() / '.darling')) + config.setdefault('guest_home', '/Users/' + guest) + configured_prefix = Path(os.environ.get('DPREFIX') or config['prefix']).resolve() + env_prefix = os.environ.get('DPREFIX') + try: + os.environ['DPREFIX'] = str(configured_prefix) + prefix, home = runtime_paths(config) + finally: + if env_prefix is None: + os.environ.pop('DPREFIX', None) + else: + os.environ['DPREFIX'] = env_prefix + return prefix.resolve(), home.resolve(), guest + + +def guest_user(): + return selected_paths()[2] + + +def digest(paths): + result = hashlib.sha256() + for path in sorted(paths): + if path.is_file(): + result.update(str(path.relative_to(ROOT)).encode() + b'\0' + path.read_bytes()) + return result.hexdigest() + + +def base_identity(): + runtime_tree = ROOT / 'usr-local/libexec/darling' + runtime_modes = {name: (ROOT / 'usr-local/bin' / name).stat().st_mode & 0o777 + if (ROOT / 'usr-local/bin' / name).exists() else None + for name in ('darling', 'darlingserver')} + return {'schema': 2, 'package': os.environ.get('DARLING_PACKAGE_ID', ''), + 'runtime_modes': runtime_modes, + 'runtime_tree': inventory(runtime_tree), + 'sources': digest([* (ROOT / 'patches').glob('*.patch'), + ROOT / 'upstream/manifest.json', + * (ROOT / 'scripts').glob('*.py'), + * (ROOT / 'scripts').glob('*.sh'), + * (ROOT / 'tools').glob('*')]), + 'runtime': digest([ROOT / 'usr-local/bin/darling', + ROOT / 'usr-local/bin/darlingserver'])} + + +def inventory(root): + """Include all required trees and modes without hashing large installed binaries.""" + if not root.is_dir(): + return None + result = hashlib.sha256() + def enumeration_error(error): + raise error + + for directory, dirs, files in os.walk(root, followlinks=False, + onerror=enumeration_error): + for name in sorted(dirs + files): + path = Path(directory) / name + if any(token in path.name for token in ('.before-', '.rt-', '.llvm')): + continue + info = path.lstat() + target_str = '' + if path.is_symlink(): + try: + target_str = f':{os.readlink(path)}' + except OSError: + pass + result.update(str(path.relative_to(root)).encode() + b'\0') + result.update(f'{info.st_mode}:{info.st_size}:{info.st_mtime_ns}{target_str}'.encode()) + return result.hexdigest() + + +def overlay_identity(home): + result: dict[str, object] = {name: inventory(home / name) for name in + ('RuntimeLibraries', 'RuntimeFrameworks', + 'Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component')} + helper = home / 'libDarlingXShm.dylib' + result['xshm'] = ([helper.stat().st_mode, helper.stat().st_size, + helper.stat().st_mtime_ns] if helper.is_file() else None) + return result + + +def identity(): + prefix, home, guest = selected_paths() + return dict(base_identity(), prefix=str(prefix), guest=guest, guest_home=str(home), + overlays=overlay_identity(home), uid=os.getuid(), gid=os.getgid()) + + +def read(path): + try: + return json.loads(path.read_text()) + except (OSError, ValueError): + return None + + +def atomic(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix('.tmp') + temporary.write_text(json.dumps(value, sort_keys=True) + '\n') + temporary.replace(path) + + +def main(): + action = sys.argv[1] + state = identity() + key = hashlib.sha256(state['prefix'].encode()).hexdigest() + marker = ROOT / 'build/nix-ready' / (key + '.json') + base_marker = ROOT / 'build/nix-base.json' + if action == 'guest': + print(state['guest']) + elif action == 'check-base': + return 0 if read(base_marker) == base_identity() else 1 + elif action == 'check': + return 0 if read(marker) == state and read(base_marker) == base_identity() else 1 + elif action == 'stamp-base': + if (not all((ROOT / 'usr-local/bin' / name).is_file() and + os.access(ROOT / 'usr-local/bin' / name, os.X_OK) + for name in ('darling', 'darlingserver')) or + not (ROOT / 'usr-local/libexec/darling').is_dir() or not state['package']): + raise SystemExit('Cannot stamp readiness without executable base runtime, SDK and package identity') + atomic(base_marker, base_identity()) + elif action == 'stamp': + if read(base_marker) != base_identity(): + raise SystemExit('Base runtime readiness is missing; run darling-lol --update in a terminal.') + home = Path(str(state['guest_home'])) + if not home.is_dir() or not any((home / 'RuntimeLibraries').glob('*')) or not any((home / 'RuntimeFrameworks').glob('*')): + raise SystemExit('Cannot stamp an empty or missing prefix runtime') + # Reuse the complete installation gate; readiness must never mint an + # apparently valid manifest for partial overlays, even when called directly. + import importlib.util + spec = importlib.util.spec_from_file_location('runtime_validation', ROOT / 'tools/validate-riot-runtime.py') + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.inputs(Path(str(state['prefix'])), home) + atomic(marker, state) + elif action in ('invalidate', 'invalidate-base'): + marker.unlink(missing_ok=True) + if action == 'invalidate-base': + base_marker.unlink(missing_ok=True) + else: + raise SystemExit(f'Unknown state action: {action}') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/prepare.py b/scripts/prepare.py index 0e8bc72..dca0a83 100755 --- a/scripts/prepare.py +++ b/scripts/prepare.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 """Fetch pinned Darling sources and apply each patch to its owning repository.""" -import argparse,json,os,subprocess,tempfile +import argparse,json,os,subprocess,tempfile,sys from pathlib import Path +# --check-only must not create import caches in the source package either. +sys.dont_write_bytecode=True from common import ROOT,render p=argparse.ArgumentParser(description=__doc__) p.add_argument('--source',type=Path,default=ROOT/'build/darling-src') @@ -25,19 +27,27 @@ def previous_patches(component): if shown.returncode==0:yield render(shown.stdout) if not (source/'.git').exists(): if a.check_only:p.error('check-only requires an existing pinned source checkout') + if source.exists():p.error('refusing non-checkout destination; choose an empty source path') source.parent.mkdir(parents=True,exist_ok=True) - run(['git','clone',manifest['upstream'],str(source)]) - run(['git','-C',str(source),'checkout','--detach',manifest['revision']]) - # Recursive initialization can spend a long time fetching nested repos (and - # Git LFS assets). Explicit progress keeps the last completed submodule - # from looking like the operation that is currently stuck. The pinned - # darling-swift .lfsconfig points at the retired git-lfs.darlinghq.org, so - # fetch that submodule's LFS objects from GitHub, which still serves them; - # otherwise the checkout blocks on the dead host right after sqlite. - print('Initializing Darling submodules (large repositories/LFS assets may take a while)...',flush=True) - run(['git','-C',str(source),'-c','lfs.url=https://github.com/darlinghq/darling-swift.git/info/lfs', - 'submodule','update','--init','--recursive','--progress']) - print('Darling submodules initialized.',flush=True) + # Fetch the exact pin, not today's master. Publish only a complete checkout; + # failed downloads are removed without touching an existing user checkout. + with tempfile.TemporaryDirectory(prefix='darling-clone-',dir=source.parent) as temporary: + checkout=Path(temporary)/'source' + run(['git','init',str(checkout)]) + run(['git','-C',str(checkout),'remote','add','origin',manifest['upstream']]) + run(['git','-C',str(checkout),'fetch','--depth','1','origin',manifest['revision']]) + run(['git','-C',str(checkout),'checkout','--detach','FETCH_HEAD']) + print('Initializing Darling submodules (large repositories/LFS assets may take a while)...',flush=True) + for attempt in range(1,4): + try: + run(['git','-C',str(checkout),'-c','lfs.url=https://github.com/darlinghq/darling-swift.git/info/lfs', + 'submodule','update','--init','--recursive','--depth','1','--progress']) + break + except subprocess.CalledProcessError: + if attempt==3:raise + print(f'Submodule fetch interrupted (attempt {attempt}/3). Retrying...',flush=True) + checkout.rename(source) + # Never reset someone else's checkout to make a patch apply. for component in manifest['components']: repo=source/component['path'] diff --git a/scripts/run.sh b/scripts/run.sh index 56b3665..08f513b 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -2,6 +2,11 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" +if [ -n "${DARLING_WORKSPACE:-}" ] && ! python3 scripts/nix-state.py check; then + echo 'Rootless runtime readiness is missing or incompatible; run darling-lol --installer in a terminal.' >&2 + exit 1 +fi +export PATH="${DARLING_RUNNER_DIR:+$DARLING_RUNNER_DIR:}$repo/usr-local/bin:/usr/local/bin:$PATH" python3 scripts/configure.py >/dev/null if [ -d build/venv/bin ]; then PATH="$repo/build/venv/bin:$PATH"; export PATH; fi # darling-lol perf: optionally wrap with gamemoderun for automatic diff --git a/scripts/update.sh b/scripts/update.sh index 5658d37..f8e997c 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -1,10 +1,29 @@ #!/bin/sh # Bring an existing build and installation up to date after `git pull`: re-apply the patches that changed # (prepare.py reverts the earlier version it finds applied), rebuild incrementally, and reinstall the base -# runtime and the per-user overrides. Run it with no Riot/League session open; it never stops one. +# runtime, and the per-user overrides. Run it after manually closing Riot/League. +# Rootless callers must remain in their original namespace; the outer launcher +# refuses a live prefix marker instead of trying to reconnect or stop a session. set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" +if [ -n "${DARLING_WORKSPACE:-}" ]; then + python3 scripts/nix-idle.py + # Initial setup and upgrades use the same supported user-owned install path. + # Keep each package closure rooted: copied ELF files can retain its loader. + package=${DARLING_PACKAGE_ID%/share/darling-lol} + case "$package" in /nix/store/*) ;; *) echo 'Missing Nix package identity' >&2; exit 1;; esac + mkdir -p build/nix-gcroots + nix-store --realise "$package" --add-root "$repo/build/nix-gcroots/$(basename "$package")" --indirect >/dev/null + python3 scripts/nix-state.py invalidate-base + ./scripts/build.sh + python3 build/runtime/tools/stop-darling-runtime.py + cmake --install build/darling-src/build + python3 scripts/nix-state.py stamp-base + ./scripts/install-runtime.sh + echo 'Rootless update installed. In this shell use scripts/run.sh; otherwise exit this shell and start darling-lol.' + exit 0 +fi if [ ! -d build/darling-src/build ]; then echo 'No existing build; follow the first-time setup in README.md instead.' >&2 exit 1 diff --git a/scripts/validate.py b/scripts/validate.py index df0584b..57fcf9f 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -12,6 +12,9 @@ errors=[] secret_patterns=[r'gh[pousr]_[A-Za-z0-9]{30,}',r'AKIA[0-9A-Z]{16}',r'eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}',r'-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----'] for name in filter(None,files): p=ROOT/name;data=p.read_bytes() + # The Nix desktop package intentionally ships this icon, not a runtime binary. + if name == 'darling-lol.png' and data.startswith(b'\x89PNG\r\n\x1a\n'): + continue if data.startswith((b'\x7fELF',b'MZ',b'\xcf\xfa\xed\xfe',b'\xca\xfe\xba\xbe')) or b'\0' in data: errors.append(name+': binary content is not part of this source package');continue try:text=data.decode() diff --git a/tests/test_nix_launcher.py b/tests/test_nix_launcher.py new file mode 100644 index 0000000..b4190fc --- /dev/null +++ b/tests/test_nix_launcher.py @@ -0,0 +1,90 @@ +"""Regression tests for the Nix launcher; no root or game account required.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class LauncherTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.folder = Path(self.tmp.name) + self.home = self.folder / 'home' + self.home.mkdir() + self.share = self.folder / 'share' + (self.share / 'scripts').mkdir(parents=True) + (self.share / 'scripts/run.sh').write_text('new packaged script\n') + import shutil + shutil.copy(ROOT / 'scripts/nix-copy.py', self.share / 'scripts/nix-copy.py') + self.workspace = self.home / '.local/share/darling-lol' + self.log = self.folder / 'calls' + self.bin = self.folder / 'bin' + self.bin.mkdir() + for name in ['unshare', 'fhs']: + executable = self.bin / name + executable.write_text('#!/bin/sh\nprintf "%s\\n" "$0" "$@" > "$TEST_LOG"\n') + executable.chmod(0o755) + self.launcher = self.folder / 'launcher' + self.launcher.write_text((ROOT / 'darling-lol-launcher.sh').read_text() + .replace('@SHARE_DIR@', str(self.share)) + .replace('@FHS_BIN@', str(self.bin / 'fhs')) + .replace('@UNSHARE_BIN@', str(self.bin / 'unshare'))) + self.env = dict(os.environ, HOME=str(self.home), XDG_DATA_HOME=str(self.home / '.local/share'), + TEST_LOG=str(self.log), PATH=str(self.bin) + ':' + os.environ['PATH']) + + def launch(self, *args): + return subprocess.run(['bash', str(self.launcher), *args], env=self.env, + text=True, capture_output=True, timeout=10) + + def test_package_upgrade_refreshes_tools_without_touching_build(self): + (self.workspace / 'scripts').mkdir(parents=True) + (self.workspace / 'scripts/run.sh').write_text('stale script\n') + (self.workspace / 'build').mkdir() + binary = self.workspace / 'build/keep-me' + binary.write_text('compiled runtime') + result = self.launch('true') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((self.workspace / 'scripts/run.sh').read_text(), 'new packaged script\n') + self.assertEqual(binary.read_text(), 'compiled runtime') + + def test_fhs_enters_subuid_namespace_before_bubblewrap(self): + result = self.launch('echo', 'hello world') + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.log.read_text().splitlines() + self.assertEqual(calls[0], str(self.bin / 'unshare')) + self.assertIn('--map-auto', calls) + self.assertIn('--map-current-user', calls) + self.assertIn('--setuid=0', calls) + self.assertIn('--setgid=0', calls) + self.assertEqual(calls[-3:], [str(self.bin / 'fhs'), 'echo', 'hello world']) + + def test_setup_never_makes_a_user_writable_runtime_setuid(self): + result = self.launch('--installer') + self.assertEqual(result.returncode, 0, result.stderr) + setup = (ROOT / 'scripts/nix-session.sh').read_text() + (ROOT / 'scripts/update.sh').read_text().split('if [ ! -d build/darling-src/build ]; then')[0] + self.assertNotIn(' sudo ', setup) + self.assertNotIn('\nsudo ', setup) + self.assertNotIn('chmod 4755', setup) + self.assertIn('install-runtime.sh', setup) + + +class RuntimeEntryTests(unittest.TestCase): + def test_entry_preserves_host_identity_and_uses_namespace_only_privileges(self): + entry = ROOT / 'scripts/fhs-entry.sh' + self.assertTrue(entry.is_file(), 'missing rootless FHS entrypoint') + text = entry.read_text() + self.assertIn('DARLING_HOST_UID', text) + self.assertIn('DARLING_HOST_GID', text) + self.assertIn('--ambient-caps', text) + shim = ROOT / 'scripts/darling-rootless.sh' + self.assertTrue(shim.is_file(), 'missing namespace-local Darling shim') + self.assertIn('setpriv', shim.read_text()) + self.assertIn('--euid', shim.read_text()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py new file mode 100644 index 0000000..f0ee4f5 --- /dev/null +++ b/tests/test_nix_repairs.py @@ -0,0 +1,519 @@ +"""Safe packaging regressions: only temporary repositories, homes and stub commands.""" +import hashlib +import importlib.util +import json +import getpass +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +def run(*args, cwd=None, env=None): + return subprocess.run(args, cwd=cwd, env=env, input='', text=True, + capture_output=True, timeout=30) + + +def git(repo, *args): + result = run('git', '-C', str(repo), *args) + if result.returncode: + raise AssertionError(result.stderr) + return result.stdout.strip() + + +def init(repo): + repo.mkdir(parents=True) + git(repo, 'init', '-b', 'master') + git(repo, 'config', 'user.name', 'Fixture') + git(repo, 'config', 'user.email', 'fixture@example.invalid') + + +def shutdown_patch(): + return 'diff --git a/tools/shutdown-user.sh b/tools/shutdown-user.sh\n' + ( + ROOT / 'patches/darling.patch').read_text().split( + 'diff --git a/tools/shutdown-user.sh b/tools/shutdown-user.sh\n', 1)[1].split('\ndiff --git ', 1)[0] + '\n' + + +def patch_base(patch): + lines = patch.splitlines() + start = next(i for i, line in enumerate(lines) if line.startswith('@@')) + 1 + return '\n'.join(line[1:] for line in lines[start:] if line.startswith((' ', '-'))) + '\n' + + +class PrepareTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='nix-prepare-test-') + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.project = self.root / 'project' + for name in ['scripts', 'patches', 'upstream']: + (self.project / name).mkdir(parents=True) + for name in ['prepare.py', 'common.py']: + shutil.copy(ROOT / 'scripts' / name, self.project / 'scripts' / name) + self.source = self.root / 'source' + init(self.source) + self.env = dict(os.environ, PYTHONDONTWRITEBYTECODE='1', HOME=str(self.root)) + + def prepare(self, check=False): + return run(sys.executable, 'scripts/prepare.py', '--source', str(self.source), + *(['--check-only'] if check else []), cwd=self.project, env=self.env) + + def manifest(self, revision, components, upstream=None): + (self.project / 'upstream/manifest.json').write_text(json.dumps({ + 'upstream': upstream or self.source.as_uri(), 'revision': revision, 'components': components})) + + def test_prepare_twice_and_check_only_preserves_all_files(self): + patch = shutdown_patch() + (self.source / 'tools').mkdir() + (self.source / 'tools/shutdown-user.sh').write_text(patch_base(patch)) + git(self.source, 'add', '.') + git(self.source, 'commit', '-m', 'base') + rev = git(self.source, 'rev-parse', 'HEAD') + (self.project / 'patches/darling.patch').write_text(patch) + self.manifest(rev, [{'path': '.', 'patch': 'patches/darling.patch', 'revision': rev}]) + for _ in range(2): + result = self.prepare() + self.assertEqual(result.returncode, 0, result.stderr) + before = {str(p): (p.read_bytes(), p.stat().st_mtime_ns) for p in self.root.rglob('*') if p.is_file()} + result = self.prepare(True) + self.assertEqual(result.returncode, 0, result.stderr) + after = {str(p): (p.read_bytes(), p.stat().st_mtime_ns) for p in self.root.rglob('*') if p.is_file()} + self.assertEqual(before, after) + + def test_check_only_does_not_rewrite_compatibility_headers(self): + path = self.source / 'src/external/cctools-port/cctools/ld64/src/3rd/strlcpy.h' + path.parent.mkdir(parents=True) + path.write_text('size_t strlcpy(char *dst, const char *src, size_t siz);\n') + git(self.source, 'add', '.') + git(self.source, 'commit', '-m', 'header') + self.manifest(git(self.source, 'rev-parse', 'HEAD'), []) + before = path.read_bytes(), path.stat().st_mtime_ns + self.assertEqual(self.prepare(True).returncode, 0) + self.assertEqual(before, (path.read_bytes(), path.stat().st_mtime_ns)) + + def test_rootless_shutdown_dispatches_only_scoped_helper_and_propagates_failure(self): + with tempfile.TemporaryDirectory(prefix='nix-hook-test-') as directory: + root = Path(directory) + (root / 'scripts').mkdir() + helper = root / 'scripts/nix-idle.py' + helper.write_text('import os, sys\nprint(os.environ["DPREFIX"])\nsys.exit(int(os.environ["TEST_EXIT"]))\n') + patch = shutdown_patch().splitlines() + start = next(i for i, line in enumerate(patch) if line.startswith('@@')) + 1 + script = root / 'shutdown.sh' + script.write_text('\n'.join(line[1:] for line in patch[start:] if line.startswith((' ', '+'))) + '\n') + bin_dir = root / 'bin' + bin_dir.mkdir() + # These commands must never be reached by rootless preparation. + for name in ['sudo', 'ps', 'su', 'darling', 'killall', 'pkill']: + stub = bin_dir / name + stub.write_text('#!/bin/sh\necho unexpected-' + name + ' >&2\nexit 99\n') + stub.chmod(0o755) + env = dict(os.environ, DARLING_WORKSPACE=str(root), DPREFIX=str(root / 'prefix'), + TEST_EXIT='0', PATH=str(bin_dir) + ':' + os.environ['PATH']) + success = run('sh', str(script), env=env) + self.assertEqual(success.returncode, 0, success.stderr) + self.assertEqual(success.stdout.strip(), env['DPREFIX']) + env['TEST_EXIT'] = '23' + self.assertEqual(run('sh', str(script), env=env).returncode, 23) + + def test_cctools_patch_is_maintained_and_repeatable(self): + component = next(c for c in json.loads((ROOT / 'upstream/manifest.json').read_text())['components'] + if c['path'] == 'src/external/cctools-port') + patch = (ROOT / component['patch']).read_text() + # Reconstruct exactly the base of each maintained hunk in a local repo. + for section in patch.split('diff --git ')[1:]: + lines = section.splitlines() + path = lines[0].split(' b/')[1] + header = next(i for i, line in enumerate(lines) if line.startswith('@@')) + # Header hunk starts at line six; use a neutral prefix of that length. + start = int(lines[header].split('-')[1].split(',')[0]) + base = ['/* untouched */\n'] * (start - 1) + base += [line[1:] + '\n' if line else '\n' for line in lines[header + 1:] if not line or line.startswith((' ', '-'))] + target = self.source / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(''.join(base)) + git(self.source, 'add', '.') + git(self.source, 'commit', '-m', 'cctools') + rev = git(self.source, 'rev-parse', 'HEAD') + (self.project / 'patches/cctools-port.patch').write_text(patch) + self.manifest(rev, [{'path': '.', 'revision': rev, 'patch': 'patches/cctools-port.patch'}]) + for check in [False, False, True]: + result = self.prepare(check) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_fetch_pin_older_than_advanced_remote_and_failed_retry(self): + (self.source / 'file').write_text('pinned') + git(self.source, 'add', '.') + git(self.source, 'commit', '-m', 'pin') + pin = git(self.source, 'rev-parse', 'HEAD') + (self.source / 'file').write_text('advanced') + git(self.source, 'commit', '-am', 'advance') + remote = self.source + self.source = self.root / 'clone' + self.manifest('0' * 40, [], remote.as_uri()) + failed = self.prepare() + self.assertNotEqual(failed.returncode, 0) + self.assertFalse(self.source.exists(), 'failed initial checkout must not poison retries') + self.manifest(pin, [], remote.as_uri()) + result = self.prepare() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(git(self.source, 'rev-parse', 'HEAD'), pin) + self.assertEqual((self.source / 'file').read_text(), 'pinned') + + +class CopyTests(unittest.TestCase): + def test_symlink_workspace_is_rejected_without_external_writes(self): + with tempfile.TemporaryDirectory(prefix='nix-symlink-test-') as directory: + root = Path(directory) + share = root / 'share' + (share / 'scripts').mkdir(parents=True) + shutil.copy(ROOT / 'scripts/nix-copy.py', share / 'scripts/nix-copy.py') + outside = root / 'outside' + outside.mkdir() + data = root / 'data' + data.mkdir() + (data / 'darling-lol').symlink_to(outside, target_is_directory=True) + launcher = root / 'launcher' + launcher.write_text((ROOT / 'darling-lol-launcher.sh').read_text().replace('@SHARE_DIR@', str(share))) + env = dict(os.environ, HOME=str(root), XDG_DATA_HOME=str(data), DPREFIX=str(root / 'prefix')) + result = run('bash', str(launcher), 'true', env=env) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(list(outside.iterdir()), []) + + def test_copied_scripts_execute_and_upgrade_without_following_symlinks(self): + with tempfile.TemporaryDirectory(prefix='nix-copy-test-') as directory: + root = Path(directory) + share, workspace = root / 'share', root / 'workspace' + (share / 'scripts').mkdir(parents=True) + shutil.copy(ROOT / 'scripts/nix-copy.py', share / 'scripts/nix-copy.py') + entry = share / 'scripts/run.sh' + entry.write_text('#!/bin/sh\nprintf first\n') + entry.chmod(0o555) + env = dict(os.environ, HOME=str(root), XDG_DATA_HOME=str(root / 'data')) + # Use real cp/filesystem execution, but never a real namespace or Darling. + launcher = root / 'launcher' + launcher.write_text((ROOT / 'darling-lol-launcher.sh').read_text() + .replace('@SHARE_DIR@', str(share)).replace('@FHS_BIN@', shutil.which('true')) + .replace('@UNSHARE_BIN@', shutil.which('true')).replace('@TERMINAL_BIN@', shutil.which('false'))) + workspace = root / 'data/darling-lol' + result = run('bash', str(launcher), 'true', env=env) + self.assertEqual(result.returncode, 0, result.stderr) + target = workspace / 'scripts/run.sh' + self.assertTrue(os.access(target, os.X_OK), 'copied entrypoint lost executable bits') + self.assertTrue(target.stat().st_mode & 0o200) + self.assertEqual(run(str(target), env=env).stdout, 'first') + entry.chmod(0o755) + entry.write_text('#!/bin/sh\nprintf second\n') + entry.chmod(0o555) + self.assertEqual(run('bash', str(launcher), 'true', env=env).returncode, 0) + self.assertEqual(run(str(target), env=env).stdout, 'second') + external = root / 'external' + external.write_text('untouched') + target.unlink() + target.symlink_to(external) + result = run('bash', str(launcher), 'true', env=env) + self.assertNotEqual(result.returncode, 0, 'reject managed destination symlinks') + self.assertEqual(external.read_text(), 'untouched') + + +def populate_overlays(prefix, home, tools): + for name in ('validate-riot-runtime.py', 'runtime_paths.py'): + shutil.copy(ROOT / 'tools' / name, tools / name) + sys.path.insert(0, str(tools)) + try: + spec = importlib.util.spec_from_file_location('validation_fixture', tools / 'validate-riot-runtime.py') + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + guest_req = getattr(module, 'GUEST_REQUIRED', getattr(module, 'REQUIRED', ())) + prefix_req = getattr(module, 'PREFIX_REQUIRED', ()) + for relative in guest_req: + path = home / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'\xcf\xfa\xed\xfe' + b'inert test fixture') + for relative in prefix_req: + path = prefix / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'\xcf\xfa\xed\xfe' + b'inert test fixture') + finally: + sys.path.remove(str(tools)) + + +class ReadinessTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='nix-state-test-') + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.repo = self.root / 'repo' + shutil.copytree(ROOT / 'scripts', self.repo / 'scripts', ignore=shutil.ignore_patterns('__pycache__')) + for folder in ['upstream', 'patches', 'tools', 'usr-local/bin']: + (self.repo / folder).mkdir(parents=True) + (self.repo / 'upstream/manifest.json').write_text('{}') + shutil.copy(ROOT / 'tools/runtime_paths.py', self.repo / 'tools/runtime_paths.py') + (self.repo / 'usr-local/bin/darling').write_text('fixture, never executed') + (self.repo / 'usr-local/bin/darlingserver').write_text('server fixture, never executed') + (self.repo / 'usr-local/libexec/darling/usr/lib').mkdir(parents=True) + (self.repo / 'usr-local/libexec/darling/usr/lib/libSystem.B.dylib').write_text('SDK fixture') + for name in ['darling', 'darlingserver']: + (self.repo / 'usr-local/bin' / name).chmod(0o755) + (self.repo / 'usr-local/libexec/darling').mkdir(parents=True, exist_ok=True) + (self.repo / 'build').mkdir() + (self.repo / 'build/config.json').write_text(json.dumps({'guest_user': 'guest-one'})) + self.env = dict(os.environ, HOME=str(self.root), DPREFIX=str(self.root / 'prefix'), + DARLING_WORKSPACE=str(self.repo), DARLING_PACKAGE_ID='package-one', + DARLING_GUEST_USER='guest-one', PYTHONDONTWRITEBYTECODE='1') + self.env.pop('RIOT_GUEST_HOME', None) + populate_overlays(Path(self.env['DPREFIX']), Path(self.env['DPREFIX']) / 'Users/guest-one', self.repo / 'tools') + + def state(self, action): + return run(sys.executable, str(self.repo / 'scripts/nix-state.py'), action, env=self.env) + + def test_prefix_guest_package_source_and_runtime_identity(self): + self.assertNotEqual(self.state('check').returncode, 0) + (Path(self.env['DPREFIX']) / 'Applications/Riot Client.app').mkdir(parents=True) + self.assertNotEqual(self.state('check').returncode, 0, 'existing app is not readiness') + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + self.assertEqual(self.state('check').returncode, 0) + # Canonical aliases share readiness, but a different prefix must not. + self.env['DPREFIX'] += '/../prefix' + self.assertEqual(self.state('check').returncode, 0) + for key, value in [('DPREFIX', str(self.root / 'new-prefix')), + ('DARLING_PACKAGE_ID', 'package-two')]: + old = self.env[key] + self.env[key] = value + self.assertNotEqual(self.state('check').returncode, 0, key) + self.env[key] = old + (self.repo / 'build/config.json').write_text(json.dumps({'guest_user': 'guest-two'})) + self.assertNotEqual(self.state('check').returncode, 0) + (self.repo / 'build/config.json').write_text(json.dumps({'guest_user': 'guest-one'})) + (self.repo / 'patches/change.patch').write_text('upgrade') + self.assertNotEqual(self.state('check').returncode, 0) + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + (self.repo / 'usr-local/bin/darlingserver').write_text('server-one') + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + (self.repo / 'usr-local/bin/darlingserver').write_text('server-two') + self.assertNotEqual(self.state('check').returncode, 0) + (self.repo / 'usr-local/bin/darling').write_text('changed runtime') + self.assertNotEqual(self.state('check').returncode, 0) + + def test_deleted_overlay_invalidates_readiness(self): + overlay = Path(self.env['DPREFIX']) / 'Users/guest-one/RuntimeLibraries/library.dylib' + overlay.parent.mkdir(parents=True, exist_ok=True) + overlay.write_text('fixture overlay') + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + self.assertEqual(self.state('check').returncode, 0) + overlay.unlink() + self.assertNotEqual(self.state('check').returncode, 0) + + def test_missing_runtime_and_noop_checks_do_not_create_or_rebuild(self): + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + before = {str(p): p.stat().st_mtime_ns for p in self.repo.rglob('*') if p.is_file()} + for _ in range(2): + self.assertEqual(self.state('check').returncode, 0) + after = {str(p): p.stat().st_mtime_ns for p in self.repo.rglob('*') if p.is_file()} + self.assertEqual(before, after) + (self.repo / 'usr-local/bin/darling').unlink() + self.assertNotEqual(self.state('check').returncode, 0) + self.assertNotEqual(self.state('stamp-base').returncode, 0) + + def test_interrupted_setup_invalidates_previous_stamp(self): + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + self.assertEqual(self.state('invalidate').returncode, 0) + self.assertNotEqual(self.state('check').returncode, 0) + + def test_runner_refuses_unready_even_for_raw_launch_paths(self): + runner = ROOT / 'scripts/darling-rootless.sh' + env = dict(self.env, DARLING_HOST_UID=str(os.getuid())) + result = run('bash', str(runner), 'shell', '/usr/bin/true', env=env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('readiness', result.stderr) + + def test_run_and_installer_refuse_unready_before_configure_or_darling(self): + for name in ['run.sh', 'installer.sh']: + result = run('sh', str(self.repo / 'scripts' / name), env=self.env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('readiness', result.stderr.lower()) + self.assertFalse((self.repo / 'build/runtime').exists()) + + +class SessionTests(unittest.TestCase): + def test_workspace_lock_survives_namespace_closing_extra_fds(self): + with tempfile.TemporaryDirectory(prefix='nix-lock-test-') as directory: + root = Path(directory) + share = root / 'share' + (share / 'scripts').mkdir(parents=True) + shutil.copy(ROOT / 'scripts/nix-copy.py', share / 'scripts/nix-copy.py') + stub = root / 'namespace' + stub.write_text('#!/bin/sh\nexec 9>&-\ntouch "$TEST_ENTERED"\nsleep 1\n') + stub.chmod(0o755) + launcher = root / 'launcher' + launcher.write_text((ROOT / 'darling-lol-launcher.sh').read_text() + .replace('@SHARE_DIR@', str(share)).replace('@UNSHARE_BIN@', str(stub))) + entered = root / 'entered' + env = dict(os.environ, HOME=str(root), XDG_DATA_HOME=str(root / 'data'), TEST_ENTERED=str(entered)) + first = subprocess.Popen(['bash', str(launcher), 'true'], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + import time + for _ in range(100): + if entered.exists(): + break + time.sleep(.01) + self.assertTrue(entered.exists()) + second = run('bash', str(launcher), 'true', env=env) + self.assertNotEqual(second.returncode, 0) + self.assertIn('session is active', second.stderr) + finally: + first.communicate(timeout=10) + + def test_live_prefix_marker_refuses_before_refresh_or_namespace(self): + with tempfile.TemporaryDirectory(prefix='nix-marker-test-') as directory: + root = Path(directory) + prefix = root / 'prefix' + prefix.mkdir() + # This fixture's own PID is harmless; the launcher must never signal it. + (prefix / '.init.pid').write_text(str(os.getpid())) + launcher = root / 'launcher' + launcher.write_text((ROOT / 'darling-lol-launcher.sh').read_text() + .replace('@SHARE_DIR@', str(root / 'missing-share')) + .replace('@UNSHARE_BIN@', 'must-not-execute')) + env = dict(os.environ, HOME=str(root), DPREFIX=str(prefix), XDG_DATA_HOME=str(root / 'data')) + result = run('bash', str(launcher), env=env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('reconnect is unsupported', result.stderr) + self.assertFalse((root / 'data/darling-lol/scripts').exists()) + + def test_ready_session_launches_without_rebuild_and_missing_stamp_refuses_eof(self): + with tempfile.TemporaryDirectory(prefix='nix-dispatch-test-') as directory: + root = Path(directory) + (root / 'scripts').mkdir() + for name in ['nix-state.py', 'nix-session.sh']: + shutil.copy(ROOT / 'scripts' / name, root / 'scripts' / name) + (root / 'usr-local/bin').mkdir(parents=True) + (root / 'usr-local/bin/darling').write_text('never executed') + (root / 'usr-local/bin/darlingserver').write_text('never executed') + for name in ['darling', 'darlingserver']: + (root / 'usr-local/bin' / name).chmod(0o755) + (root / 'usr-local/libexec/darling/usr/lib').mkdir(parents=True, exist_ok=True) + (root / 'usr-local/libexec/darling/usr/lib/libSystem.B.dylib').write_text('SDK fixture') + (root / 'tools').mkdir() + (root / 'usr-local/libexec/darling').mkdir(parents=True, exist_ok=True) + shutil.copy(ROOT / 'tools/runtime_paths.py', root / 'tools/runtime_paths.py') + (root / 'prefix/Applications/Riot Client.app').mkdir(parents=True) + for name in ['run.sh', 'update.sh', 'install-runtime.sh', 'installer.sh']: + path = root / 'scripts' / name + path.write_text('#!/bin/sh\necho ' + name + ' >> "$TEST_LOG"\n') + path.chmod(0o755) + log = root / 'calls' + env = dict(os.environ, HOME=str(root), DPREFIX=str(root / 'prefix'), + DARLING_WORKSPACE=str(root), DARLING_PACKAGE_ID='fixture', TEST_LOG=str(log), + PYTHONDONTWRITEBYTECODE='1') + env.pop('RIOT_GUEST_HOME', None) + populate_overlays(root / 'prefix', root / 'prefix/Users' / getpass.getuser(), root / 'tools') + for action in ['stamp-base', 'stamp']: + self.assertEqual(run(sys.executable, str(root / 'scripts/nix-state.py'), action, env=env).returncode, 0) + for _ in range(2): + result = run('bash', str(root / 'scripts/nix-session.sh'), env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(log.read_text().splitlines(), ['run.sh', 'run.sh']) + run(sys.executable, str(root / 'scripts/nix-state.py'), 'invalidate', env=env) + result = run('bash', str(root / 'scripts/nix-session.sh'), env=env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('terminal', result.stderr) + self.assertEqual(log.read_text().splitlines(), ['run.sh', 'run.sh']) + env['DPREFIX'] = str(root / 'other-prefix') + result = run('bash', str(root / 'scripts/nix-session.sh'), env=env) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((root / 'other-prefix').exists()) + + def test_first_run_eof_is_actionable_and_desktop_requests_terminal(self): + with tempfile.TemporaryDirectory(prefix='nix-session-test-') as directory: + root = Path(directory) + (root / 'scripts').mkdir() + for p in (ROOT / 'scripts').glob('*'): + if p.is_file(): + shutil.copy(p, root / 'scripts' / p.name) + env = dict(os.environ, HOME=str(root), DPREFIX=str(root / 'prefix'), + DARLING_WORKSPACE=str(root), DARLING_PACKAGE_ID='fixture') + result = run('bash', str(root / 'scripts/nix-session.sh'), env=env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('terminal', result.stderr.lower()) + self.assertFalse((root / 'build').exists()) + self.assertIn('Terminal=true', (ROOT / 'flake.nix').read_text()) + self.assertIn('checks.${system}.packaging', (ROOT / 'flake.nix').read_text()) + + def test_rootless_update_refuses_live_marker_without_build_or_stop(self): + with tempfile.TemporaryDirectory(prefix='nix-update-idle-') as directory: + root = Path(directory) + (root / 'scripts').mkdir() + for name in ['update.sh', 'nix-idle.py']: + if (ROOT / 'scripts' / name).exists(): + shutil.copy(ROOT / 'scripts' / name, root / 'scripts' / name) + prefix = root / 'prefix' + prefix.mkdir() + (prefix / '.init.pid').write_text(str(os.getpid())) + env = dict(os.environ, DARLING_WORKSPACE=str(root), DPREFIX=str(prefix), + DARLING_PACKAGE_ID='/nix/store/fixture-package/share/darling-lol') + result = run('sh', str(root / 'scripts/update.sh'), env=env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('live server', result.stderr.lower()) + self.assertFalse((root / 'build').exists()) + + def test_rootless_update_executes_without_sudo_and_fails_closed(self): + with tempfile.TemporaryDirectory(prefix='nix-update-test-') as directory: + root = Path(directory) + scripts, tools, bin_dir = root / 'scripts', root / 'build/runtime/tools', root / 'bin' + scripts.mkdir() + tools.mkdir(parents=True) + bin_dir.mkdir() + shutil.copy(ROOT / 'scripts/update.sh', scripts / 'update.sh') + # Process discovery has its own read-only marker test; isolate this + # installer-order fixture from unrelated host /proc permissions. + (scripts / 'nix-idle.py').write_text('pass\n') + log = root / 'calls' + for name, body in [('build.sh', 'echo build >> "$TEST_LOG"'), + ('install-runtime.sh', 'echo overlays >> "$TEST_LOG"')]: + path = scripts / name + path.write_text('#!/bin/sh\n' + body + '\n') + path.chmod(0o755) + (scripts / 'nix-state.py').write_text('import os, sys\nwith open(os.environ["TEST_LOG"], "a") as f: f.write(sys.argv[1]+"\\n")\n') + (tools / 'stop-darling-runtime.py').write_text('import os\nwith open(os.environ["TEST_LOG"], "a") as f: f.write("stop\\n")\n') + for name in ['cmake', 'sudo', 'nix-store']: + path = bin_dir / name + path.write_text('#!/bin/sh\necho ' + name + ' >> "$TEST_LOG"\n' + + ('exit "${INSTALL_EXIT:-0}"\n' if name == 'cmake' else 'exit 0\n')) + path.chmod(0o755) + env = dict(os.environ, DARLING_WORKSPACE=str(root), HOME=str(root), DPREFIX=str(root / 'prefix'), + DARLING_PACKAGE_ID='/nix/store/fixture-package/share/darling-lol', + TEST_LOG=str(log), PATH=str(bin_dir) + ':' + os.environ['PATH']) + result = run('sh', str(scripts / 'update.sh'), env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(log.read_text().splitlines(), + ['nix-store', 'invalidate-base', 'build', 'stop', 'cmake', 'stamp-base', 'overlays']) + log.write_text('') + env['INSTALL_EXIT'] = '23' + self.assertEqual(run('sh', str(scripts / 'update.sh'), env=env).returncode, 23) + self.assertNotIn('stamp-base', log.read_text()) + self.assertNotIn('overlays', log.read_text()) + self.assertNotIn('sudo', log.read_text()) + + def test_rootless_update_never_uses_sudo_and_stamps_only_after_success(self): + text = (ROOT / 'scripts/update.sh').read_text() + self.assertIn('DARLING_WORKSPACE', text) + self.assertIn('stamp-base', text) + self.assertIn('invalidate-base', text) + self.assertIn('nix-state.py', text) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_nix_runtime_identity.py b/tests/test_nix_runtime_identity.py new file mode 100644 index 0000000..e292085 --- /dev/null +++ b/tests/test_nix_runtime_identity.py @@ -0,0 +1,111 @@ +"""Readiness regression fixtures: inert runtime bytes, no Darling execution.""" +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class RuntimeIdentityTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='nix-identity-test-') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + for name in ('scripts', 'tools'): + shutil.copytree(ROOT / name, self.root / name) + self.prefix = self.root / 'prefix' + self.guest = self.prefix / 'Users/tester' + (self.guest / 'RuntimeLibraries').mkdir(parents=True) + (self.guest / 'RuntimeFrameworks').mkdir() + (self.guest / 'RuntimeLibraries/test.dylib').write_bytes(b'\xcf\xfa\xed\xfe' + b'fixture') + (self.guest / 'libDarlingXShm.dylib').write_bytes(b'\xcf\xfa\xed\xfe' + b'fixture') + audio = self.guest / 'Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component/Contents/MacOS/CoreAudio' + audio.parent.mkdir(parents=True) + audio.write_bytes(b'\xcf\xfa\xed\xfe' + b'fixture') + spec = importlib.util.spec_from_file_location('validation_fixture', self.root / 'tools/validate-riot-runtime.py') + sys.path.insert(0, str(self.root / 'tools')) + self.addCleanup(lambda: sys.path.remove(str(self.root / 'tools'))) + validation = importlib.util.module_from_spec(spec) + spec.loader.exec_module(validation) + guest_req = getattr(validation, 'GUEST_REQUIRED', getattr(validation, 'REQUIRED', ())) + prefix_req = getattr(validation, 'PREFIX_REQUIRED', ()) + for relative in guest_req: + path = self.guest / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'\xcf\xfa\xed\xfe' + b'inert fixture') + for relative in prefix_req: + path = self.prefix / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'\xcf\xfa\xed\xfe' + b'inert fixture') + for name in ('usr-local/bin/darling', 'usr-local/bin/darlingserver', + 'usr-local/libexec/darling/usr/lib/libSystem.B.dylib'): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('inert runtime fixture\n') + path.chmod(0o755) + (self.root / 'build').mkdir() + self.config = self.root / 'build/config.json' + self.config.write_text(json.dumps({'prefix': str(self.prefix), 'guest_user': 'tester', + 'guest_home': '/Users/tester'})) + self.env = dict(os.environ, DPREFIX=str(self.prefix), + DARLING_PACKAGE_ID='/nix/store/fixture-package/share/darling-lol') + self.env.pop('RIOT_GUEST_HOME', None) + for action in ('stamp-base', 'stamp'): + result = self.state(action) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.state('check').returncode, 0) + + def state(self, action, env=None): + return subprocess.run([sys.executable, '-B', 'scripts/nix-state.py', action], + cwd=self.root, env=env or self.env, text=True, + capture_output=True, timeout=20) + + def test_host_guest_home_override_invalidates_readiness(self): + env = dict(self.env, RIOT_GUEST_HOME=str(self.prefix / 'Users/alternate')) + self.assertNotEqual(self.state('check', env).returncode, 0) + + def test_configured_guest_home_change_invalidates_readiness(self): + config = json.loads(self.config.read_text()) + config['guest_home'] = '/Users/alternate' + self.config.write_text(json.dumps(config)) + self.assertNotEqual(self.state('check').returncode, 0) + + def test_required_xshm_loss_invalidates_readiness(self): + (self.guest / 'libDarlingXShm.dylib').unlink() + self.assertNotEqual(self.state('check').returncode, 0) + + def test_audio_component_loss_invalidates_readiness(self): + shutil.rmtree(self.guest / 'Library') + self.assertNotEqual(self.state('check').returncode, 0) + + def test_launcher_execute_permission_loss_invalidates_readiness(self): + (self.root / 'usr-local/bin/darling').chmod(0o644) + self.assertNotEqual(self.state('check').returncode, 0) + + def test_base_runtime_tree_loss_invalidates_readiness(self): + shutil.rmtree(self.root / 'usr-local/libexec') + self.assertNotEqual(self.state('check').returncode, 0) + + def test_missing_base_server_cannot_be_restamped(self): + (self.root / 'usr-local/bin/darlingserver').unlink() + self.assertNotEqual(self.state('stamp-base').returncode, 0) + + def test_empty_prefix_cannot_be_stamped(self): + shutil.rmtree(self.prefix) + self.prefix.mkdir() + self.assertNotEqual(self.state('stamp').returncode, 0) + + def test_configured_prefix_used_without_environment_override(self): + env = dict(self.env) + env.pop('DPREFIX') + self.assertEqual(self.state('check', env).returncode, 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/upstream/manifest.json b/upstream/manifest.json index 09e7fa8..8a83a47 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -246,7 +246,6 @@ "private-include/Metal/MTLArgumentInternal.h", "private-include/Metal/MTLBlitCommandEncoderInternal.h", "private-include/Metal/MTLDepthStencilInternal.h", - "private-include/Metal/MTLDeviceInfo.h", "private-include/Metal/MTLSamplerStateInternal.h", "private-include/Metal/MTLVertexDescriptorInternal.h", "src/Metal/MTLArgument.mm", @@ -317,6 +316,11 @@ "patch": "patches/cfnetwork.patch", "modified_files": 1, "new_files": [] + }, + { + "path": "src/external/cctools-port", + "revision": "d9456c221e1f462e17c0b3297748bc089d5a861e", + "patch": "patches/cctools-port.patch" } ], "omitted": [ From 573f2905522869a1b58664186f48128b4baeecdc Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Sun, 18 Oct 2026 13:35:15 +0200 Subject: [PATCH 20/23] fix(nix): simplify compiler setup and complete rootless session lifecycle --- docs/NIX.md | 229 ++++++++++++++++++++++++++++++++ docs/SETUP.md | 78 +++-------- flake.nix | 66 +++------ scripts/nix-compiler.sh | 8 ++ scripts/nix-copy.py | 204 +++++++++++++++++++++++----- scripts/nix-idle.py | 85 +++++++----- scripts/nix-lifecycle.py | 78 +++++++++++ scripts/nix-session.sh | 4 +- tests/test_nix_compilers.py | 52 ++++++++ tests/test_nix_copy_manifest.py | 101 ++++++++++++++ tests/test_nix_lifecycle.py | 90 +++++++++++++ tests/test_nix_repairs.py | 5 +- 12 files changed, 824 insertions(+), 176 deletions(-) create mode 100644 docs/NIX.md create mode 100644 scripts/nix-compiler.sh create mode 100644 scripts/nix-lifecycle.py create mode 100644 tests/test_nix_compilers.py create mode 100644 tests/test_nix_copy_manifest.py create mode 100644 tests/test_nix_lifecycle.py diff --git a/docs/NIX.md b/docs/NIX.md new file mode 100644 index 0000000..853d844 --- /dev/null +++ b/docs/NIX.md @@ -0,0 +1,229 @@ +# Nix setup + +The experimental Nix package provides a rootless build and launch environment +for x86_64 Linux. It supplies the dependencies, not a prebuilt Darling runtime +or game files. Initial setup builds Darling in a writable per-user workspace. + +## Prerequisites + +Enable Nix flakes and configure subordinate UID/GID ranges for your user. On +NixOS, add this to your system configuration and rebuild: + +```nix +users.users..autoSubUidGidRange = true; +``` + +On other Linux distributions, [install Nix using the official instructions](https://nixos.org/download/). +You do not need to install NixOS. If you already use `nix-shell`, Nix is installed. +Enable the modern CLI and flakes in `~/.config/nix/nix.conf`: + +```ini +experimental-features = nix-command flakes +``` + +Alternatively, enable them for an individual command: + +```sh +nix --extra-experimental-features 'nix-command flakes' run github:lucannez64/darling-lol +``` + +The rootless launcher requires unprivileged user namespaces, subordinate UID/GID +ranges, and working `newuidmap`/`newgidmap` helpers. If these are already configured +on your distribution, no additional host setup is needed. + +Run setup in an interactive terminal and allow roughly 40–50 GB for the runtime +build. + +## Install and launch + +Run the flake directly from GitHub; no local checkout is needed: + +```sh +nix run github:lucannez64/darling-lol +``` + +On first launch, setup prompts to build Darling and install the Riot client. +To explicitly open the installer, including for another region: + +```sh +nix run github:lucannez64/darling-lol#installer +``` + +Setup prompts before building. Subsequent launches reuse a compatible runtime; +they do not automatically rebuild it. If the package is installed in your +profile or system configuration, the equivalent commands are +`darling-lol --installer` and `darling-lol`. The desktop entry opens a terminal +for prompts and session output. + +Keep the terminal open while clients are running and until cleanup completes. +Default and installer sessions wait for clients to exit, then shut Darling down +in the same namespace. A failed shutdown is retried without discarding that +namespace. + +## Install permanently + +`nix run` does not add commands to your profile or install a desktop menu entry. +It does reuse downloaded store paths and the persistent Darling workspace. +For commands on your `PATH` and desktop-menu access, install the package using +one of the following methods. + +### User profile (NixOS or other Linux distributions) + +```sh +nix profile add github:lucannez64/darling-lol#default +``` + +Then launch from your application menu or terminal: + +```sh +darling-lol +# Explicit setup or runtime shell: +darling-lol --installer +darling-lol-shell +``` + +If the desktop entry does not appear, ensure your desktop session includes +`~/.nix-profile/share` in `XDG_DATA_DIRS`, then log out and back in. Non-NixOS +systems may need this desktop-session integration after installing Nix. + +To upgrade, find the package's name with `nix profile list`, then run +`nix profile upgrade `. Close the clients first; after upgrading, run +`darling-lol --update` to rebuild the mutable runtime when required. + +### NixOS system configuration + +In your system flake, add the input and pass it to your NixOS modules: + +```nix +inputs.darling-lol.url = "github:lucannez64/darling-lol"; + +outputs = inputs@{ nixpkgs, ... }: { + nixosConfigurations. = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + specialArgs = { inherit inputs; }; + modules = [ ./configuration.nix ]; + }; +}; +``` + +Merge these entries into your existing flake rather than replacing its outputs. +In `configuration.nix`, add the package and subordinate-ID configuration: + +```nix +{ inputs, pkgs, ... }: +{ + environment.systemPackages = [ + inputs.darling-lol.packages.${pkgs.stdenv.hostPlatform.system}.default + ]; + users.users..autoSubUidGidRange = true; + nix.settings.experimental-features = [ "nix-command" "flakes" ]; +} +``` + +Apply with `sudo nixos-rebuild switch --flake .#`. The launcher and +desktop entry are then installed system-wide; each user still has a separate +workspace and prefix. Start `darling-lol` as your normal user, not with sudo. + +To upgrade, update the input with `nix flake update darling-lol`, rebuild your +system, and run `darling-lol --update` after closing the clients. + +## Update + +Close League and both Riot clients and wait for the launcher to finish cleanup. +Then run the updated flake and rebuild the runtime: + +```sh +nix run --refresh github:lucannez64/darling-lol -- --update +# Installed package: darling-lol --update +``` + +This rebuilds incrementally, installs the base runtime into the user-owned +workspace, and refreshes the per-prefix overlays. No `sudo` is needed. + +## Local checkout and scripts + +You can also launch the flake directly from an already checked-out repository: + +```sh +cd /path/to/darling-lol +nix run . +``` + +Or clone it first: + +```sh +git clone https://github.com/lucannez64/darling-lol.git +cd darling-lol +nix run . +``` + +The same checkout also provides installer and update commands: + +```sh +nix run .#installer +# After updating the checkout: +nix run . -- --update +``` + +These commands work on NixOS and other Linux distributions with Nix installed. +Use `nix run` for this flake rather than `nix-shell`: the launcher sets up the +rootless namespace in addition to supplying dependencies. `nix develop` is for +development tools only; use `nix run .#shell` for an actual runtime shell. + +To run a repository script inside the rootless FHS environment, pass its command +after `--`. Commands run from the managed workspace, using the tools copied from +the selected flake: + +```sh +nix run github:lucannez64/darling-lol -- python3 scripts/validate.py +# Equivalent from a local checkout: +nix run . -- python3 scripts/validate.py +``` + +Use the supported installer/update commands for maintenance. Arbitrary script +commands do not get the default launcher's automatic client-session cleanup; if +a script starts Darling, shut it down in the same namespace before returning. +For multiple commands, use an interactive runtime shell: + +## Runtime shell + +```sh +nix run github:lucannez64/darling-lol#shell +# Local checkout: nix run .#shell +# Installed package: darling-lol-shell +``` + +Use the launcher-provided shell rather than entering the raw FHS environment. +If you start Darling manually in this shell, run `darling shutdown` there +before exiting. A separately launched shell cannot reconnect to an existing +Darling namespace. + +## Files and operational limits + +- Workspace: `${XDG_DATA_HOME:-$HOME/.local/share}/darling-lol`. +- Default guest prefix: `$HOME/.darling`; set `DPREFIX` to use another prefix. + Explicit `DPREFIX` and `RIOT_GUEST_HOME` overrides take precedence over saved + configuration, so clear stale overrides before switching installations. +- Package upgrades refresh managed tools and remove previously tracked obsolete + files. Builds, the installed runtime, guest prefixes, and unmanaged files are + preserved. Edit the source checkout rather than copied workspace tools. +- One active session is supported per workspace. A workspace lock and live + prefix marker prevent concurrent launches; the launcher does not kill an + existing session or reconnect across namespaces. Inspect an unexpected live + marker rather than deleting it to force a launch. +- Readiness records in `build/nix-ready/` validate the selected prefix, guest, + package, base runtime, and required overlays. A new prefix can reuse a + compatible base runtime; failed setup invalidates readiness. +- Keep `build/nix-gcroots/` while using the workspace. Mutable compiled binaries + can reference Nix-store libraries, so these roots retain the package closures + they need. Remove them only when discarding the dependent build outputs. + +The launcher uses subordinate-ID user namespaces before entering the FHS +environment. Mount privileges are scoped to that namespace, not host root; +workspace and prefix files retain the Linux user's ownership. The setup does +not install a host setuid binary or require a privileged runtime installation. + +Darling compilation still happens outside Nix derivations and uses the host CPU; +the resulting runtime is not a portable, binary-cacheable Nix package. The +FFmpeg dependency is pinned to 8.x because the generated AV wrappers require +its library ABI. diff --git a/docs/SETUP.md b/docs/SETUP.md index e0a7b18..5998a08 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -1,73 +1,33 @@ # Setup and portability -## NixOS package + -Use `darling-lol`, `darling-lol --installer`, or `darling-lol-shell` rather than -invoking the raw FHS environment. The launcher uses `unshare --map-auto` before -bubblewrap, so the user needs subordinate UID/GID ranges (`/etc/subuid` and -`/etc/subgid`, or NixOS `users.users..autoSubUidGidRange = true`). -Namespace root maps to a subordinate host ID, not host root. The FHS entrypoint -restores the real Linux user's IDs while retaining namespace-only capabilities; -the Darling shim temporarily switches effective IDs for mounts. No `sudo`, -root-owned binary in a writable workspace, or host setuid installation is needed. +## Nix package -The launcher refreshes managed scripts with executable bits and owner-write access, -rejecting symlinks in that managed tree. It preserves `build/`, `usr-local/`, and -the guest prefix. Edit the source checkout, not copied workspace tools. +See [prerequisites](NIX.md#prerequisites) before starting, and the +[Nix setup guide](NIX.md) for local checkout usage, updates, scripts, and runtime +details. -First setup: run `darling-lol --installer` in an interactive terminal, choose the -build when prompted, then choose the Riot region. The desktop entry uses -`Terminal=true` and delegates terminal selection to the desktop environment. -A launch with EOF stdin fails with a command to run in a terminal; it does not -try a hard-coded list of terminal emulators or silently abandon a prompt. +Run the experimental rootless package on x86_64 Linux: -Update: after manually exiting League and both Riot clients, shut down Darling -in the ORIGINAL `darling-lol-shell` terminal with `darling shutdown`, then exit -that shell. Upgrade the Nix package and run `darling-lol --update` in a terminal. -This rebuilds incrementally, installs into the user-owned `/usr/local` bind, -and installs paired per-prefix overlays without sudo. For a first-time setup, -the same rootless update path performs the base build/install. Native/manual -(non-Nix) instructions below still use their normal privileged base install. +```sh +nix --extra-experimental-features 'nix-command flakes' run github:lucannez64/darling-lol +``` -Readiness JSON under `build/nix-ready/` is keyed by canonical prefix and checks -the configured guest and resolved host guest home, UID/GID, package path, -maintained patch/tool identities, executable modes, base SDK/runtime metadata -and required-overlay inventory. Stamping reuses required-artifact validation. -Changing `RIOT_GUEST_HOME` invalidates readiness. Explicit `DPREFIX` and -`RIOT_GUEST_HOME` environment overrides take precedence over saved configuration; -clear stale values before selecting a different installation. A separate base -manifest avoids rebuilding when only a new prefix needs overlays. -Updates invalidate readiness before replacing the base; failed/interrupted setup -cannot leave the previous prefix stamp authoritative. Existing app bundles never -bypass these checks. Normal ready launches only check state, not rebuild. +To install permanently, adding the launcher to your user profile: -Operational limits: each workspace supports one foreground namespace session. -The launcher holds a lock while its child runs, even if bubblewrap closes file -descriptors. It refuses a live `.init.pid` rather than reconnecting across new -user namespaces or killing anything. A stale/reused live PID can therefore block -a launch conservatively; inspect it before taking manual action. Never remove a -marker just to force another launch. Manual stop/reconnect and game lifecycle -acceptance have not been exercised with this repaired package. Keep the original -shell open if you need to stop a session. Maintenance requires that prefix idle; -rootless CMake's shutdown hook checks only that prefix, never enumerates and -shuts down every user's launchd. The inherited scoped stop helper remains used -by overlay installers; it is fixture-tested, not live-game tested here. +```sh +nix profile add github:lucannez64/darling-lol#default +``` -Mutable Linux ELF outputs can retain Nix-store interpreter/library references. -A native compiler probe executed successfully with its store-path interpreter; -there is no evidence that all such outputs require `patchelf`. The update path -creates indirect closure roots under `build/nix-gcroots/` before compiling and -retains old package roots for incremental outputs. Keep those roots while using -the workspace; remove them only when discarding/rebuilding all dependent mutable -outputs. This trades disk retention for GC safety, not a claim of full runtime -relocatability. A full Darling build/upgrade and GC acceptance test remains open. +Then run `darling-lol` or use the desktop application menu. See +[permanent installation](NIX.md#install-permanently) for details. -The FFmpeg dependency is explicitly 8.x: generated Darling AV wrappers reference -`libavformat.so.62`, `libavcodec.so.62`, and `libavutil.so.60`; FFmpeg 9 cannot -replace those ABI versions. - -Run the package regression suite with `python3 -m unittest discover -s tests -v`. +First launch builds Darling and guides you through Riot client installation. +NixOS is not required; other Linux distributions with Nix installed are also +supported. +## Native setup notes The patch manifest pins Darling to `60ba801decee7a00782f74f6be4c8ffb013f79ff` and records every changed submodule's revision. `scripts/prepare.py` checks each revision before applying patches and never resets an existing checkout to make a patch apply. Re-running it accepts an already-applied patch. diff --git a/flake.nix b/flake.nix index 4fe1a74..54f5ec8 100644 --- a/flake.nix +++ b/flake.nix @@ -10,6 +10,7 @@ let system = "x86_64-linux"; pkgs = nixpkgs.legacyPackages.${system}; + llvmPackages = pkgs.llvmPackages_21; # Python environment with dependencies required by tooling pythonEnv = pkgs.python3.withPackages ( @@ -36,10 +37,9 @@ gnumake ninja pkg-config - clang - lld - llvm - llvm.dev + llvmPackages.clang + llvmPackages.lld + llvmPackages.llvm llvmPackages.clang-unwrapped bison flex @@ -54,16 +54,11 @@ # System & core libraries glibc - glibc.dev glibc.static zlib - zlib.dev libffi - libffi.dev libbsd - libbsd.dev libmd - libmd.dev libcap libelf fuse @@ -131,8 +126,7 @@ darlingLolFhs = pkgs.buildFHSEnv { name = "darling-lol-fhs"; targetPkgs = pkgs: buildDeps; - multiPkgs = pkgs: [ pkgs.glibc ]; - + # multiArch is disabled; glibc is already in targetPkgs and the FHS base. extraOutputsToInstall = [ "dev" "out" ]; extraBwrapArgs = [ @@ -143,44 +137,18 @@ ]; extraBuildCommands = '' - mkdir -p bin usr/bin - rm -f bin/clang bin/clang++ usr/bin/clang usr/bin/clang++ - ln -sfn /usr-local $out/usr/local - cat << 'EOF' > usr/bin/clang -#!/usr/bin/env bash -is_darwin=0 -for a in "$@"; do - if [[ "$a" == *darwin* ]] || [[ "$a" == *apple* ]] || [[ "$a" == *macosx* ]]; then - is_darwin=1 - break - fi -done -if [[ "$is_darwin" -eq 1 ]]; then - exec ${pkgs.llvmPackages.clang-unwrapped}/bin/clang "$@" -else - exec ${pkgs.clang}/bin/clang -idirafter /usr/include "$@" -fi -EOF - chmod +x usr/bin/clang - - cat << 'EOF' > usr/bin/clang++ -#!/usr/bin/env bash -is_darwin=0 -for a in "$@"; do - if [[ "$a" == *darwin* ]] || [[ "$a" == *apple* ]] || [[ "$a" == *macosx* ]]; then - is_darwin=1 - break - fi -done -if [[ "$is_darwin" -eq 1 ]]; then - exec ${pkgs.llvmPackages.clang-unwrapped}/bin/clang++ "$@" -else - exec ${pkgs.clang}/bin/clang++ -idirafter /usr/include "$@" -fi -EOF - chmod +x usr/bin/clang++ - cp -f usr/bin/clang bin/clang - cp -f usr/bin/clang++ bin/clang++ + mkdir -p "$out/usr/bin" + rm -f "$out/usr/bin/clang" "$out/usr/bin/clang++" + ln -sfn /usr-local "$out/usr/local" + { + printf '%s\n' '#!/bin/sh' \ + 'wrapped=${llvmPackages.clang}/bin' \ + 'unwrapped=${llvmPackages.clang-unwrapped}/bin' + cat ${./scripts/nix-compiler.sh} + } > "$out/usr/bin/clang" + chmod +x "$out/usr/bin/clang" + ln -s clang "$out/usr/bin/clang++" + # /bin is already a guest-absolute symlink to /usr/bin. ''; profile = '' diff --git a/scripts/nix-compiler.sh b/scripts/nix-compiler.sh new file mode 100644 index 0000000..91178c6 --- /dev/null +++ b/scripts/nix-compiler.sh @@ -0,0 +1,8 @@ +# Invoked under clang or clang++: keep the driver's original name. +driver=${0##*/} +for arg in "$@"; do + case "$arg" in + *darwin*|*apple*|*macosx*) exec "$unwrapped/$driver" "$@" ;; + esac +done +exec "$wrapped/$driver" -idirafter /usr/include "$@" diff --git a/scripts/nix-copy.py b/scripts/nix-copy.py index 17874cb..a7eb2bd 100644 --- a/scripts/nix-copy.py +++ b/scripts/nix-copy.py @@ -1,44 +1,178 @@ #!/usr/bin/env python3 -"""Refresh managed package files without following mutable destination symlinks.""" +"""Refresh tracked package files, never recursively deleting workspace data. + +Legacy workspaces have no ownership inventory: adopt only files in the current +package, preserving unknown legacy files rather than guessing what is disposable. +The manifest is untrusted input, not authority to delete arbitrary workspace paths. +This guards pre-existing links; concurrent hostile workspace mutation is unsupported. +""" +import errno +import json import os -from pathlib import Path +from pathlib import Path, PurePosixPath import shutil import stat import sys import tempfile -source, destination = map(Path, sys.argv[1:]) -items = 'docs patches research scripts tests tools upstream LICENSE NOTICE.md README.md requirements.txt'.split() -# Preflight the entire managed tree before writing anything. Never chmod or copy -# through a link planted by a previous installation (including directory links). -for root in [destination, *destination.parents]: - if root.is_symlink(): - raise SystemExit(f'Refusing symlink workspace ancestor: {root}') -for item in items: - for base in [source / item, destination / item]: - for path in [base, *base.rglob('*')] if base.is_dir() else [base]: - if path.is_symlink(): - raise SystemExit(f'Refusing symlink in managed package tree: {path}') - if path.exists() and not (path.is_file() or path.is_dir()): - raise SystemExit(f'Refusing special file in managed package tree: {path}') -for item in items: - src = source / item - if not src.exists(): - continue - for path in [src, *src.rglob('*')] if src.is_dir() else [src]: - target = destination / path.relative_to(source) - if path.is_dir(): - target.mkdir(parents=True, exist_ok=True) - target.chmod(0o755) +ITEMS = 'docs patches research scripts tests tools upstream LICENSE NOTICE.md README.md requirements.txt'.split() +DIRECTORY_ITEMS = set(ITEMS[:7]) +MANIFEST = '.nix-managed-files.json' + + +def refuse(message): + raise ValueError(message) + + +def check_ancestors(path): + for ancestor in [path, *path.parents]: + if ancestor.is_symlink(): + refuse(f'Refusing symlink workspace ancestor: {ancestor}') + + +def inventory(root): + files, directories = set(), set() + + def visit(path): + mode = path.lstat().st_mode + relative = path.relative_to(root).as_posix() + if stat.S_ISREG(mode): + files.add(relative) + elif stat.S_ISDIR(mode): + directories.add(relative) + for child in sorted(path.iterdir()): + visit(child) else: - target.parent.mkdir(parents=True, exist_ok=True) - fd, temporary = tempfile.mkstemp(prefix='.package-', dir=target.parent) + refuse(f'Refusing symlink or special file in managed package tree: {path}') + + for item in ITEMS: + path = root / item + if path.exists() or path.is_symlink(): + if path.is_dir() != (item in DIRECTORY_ITEMS) and not path.is_symlink(): + refuse(f'Invalid managed root type: {path}') + visit(path) + return files, directories + + +def load_manifest(path): + if path.is_symlink(): + refuse(f'Refusing symlink manifest: {path}') + if not path.exists(): + return set(), set() + if not path.is_file(): + refuse(f'Refusing special manifest: {path}') + data = json.loads(path.read_text()) + if (not isinstance(data, dict) or set(data) != {'version', 'files', 'directories'} + or type(data['version']) is not int or data['version'] != 1): + refuse('Invalid managed-file manifest schema') + result = [] + for field in ('files', 'directories'): + entries = data[field] + if not isinstance(entries, list): + refuse(f'Invalid manifest {field}') + validated = set() + for name in entries: + if not isinstance(name, str): + refuse('Non-string manifest path') + parts = PurePosixPath(name).parts + if not parts: + refuse('Empty manifest path') + continue + if (name != '/'.join(parts) or '\\' in name or '\x00' in name + or any(part in ('.', '..', '/') for part in parts) + or parts[0] not in ITEMS + or (len(parts) > 1 and parts[0] not in DIRECTORY_ITEMS) + or (field == 'directories' and parts[0] not in DIRECTORY_ITEMS)): + refuse(f'Unsafe manifest path: {name!r}') + if name in validated: + refuse(f'Duplicate manifest path: {name}') + validated.add(name) + result.append(validated) + files, directories = result + if files & directories: + refuse('Conflicting manifest file/directory paths') + for name in files | directories: + if any(parent.as_posix() in files for parent in PurePosixPath(name).parents): + refuse(f'Manifest file used as directory: {name}') + return files, directories + + +def atomic_write(target, writer, mode): + fd, temporary = tempfile.mkstemp(prefix='.package-', dir=target.parent) + try: + with os.fdopen(fd, 'wb') as output: + writer(output) + os.chmod(temporary, mode) + os.replace(temporary, target) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def refresh(source, destination): + for root in (source, destination): + if '..' in root.parts: + refuse(f'Refusing path traversal: {root}') + check_ancestors(root) + source, destination = source.absolute(), destination.absolute() + if not source.is_dir(): + refuse(f'Missing package source: {source}') + if source == destination or source in destination.parents or destination in source.parents: + refuse('Package source and workspace must not overlap') + old_files, old_directories = load_manifest(destination / MANIFEST) + files, directories = inventory(source) + present_files, present_directories = inventory(destination) + + # Validate ownership/type conflicts before copying or pruning anything. + if old_files & present_directories or old_directories & present_files: + refuse('Tracked path changed type in workspace') + stale_files = old_files - files + stale_directories = old_directories - directories + if directories & (present_files - stale_files): + refuse('Unmanaged file blocks package directory') + replaced_directories = files & present_directories + for name in replaced_directories: + if name not in stale_directories: + refuse(f'Unmanaged directory blocks package file: {name}') + prefix = name + '/' + if (any(p.startswith(prefix) and p not in stale_files for p in present_files) + or any(p.startswith(prefix) and p not in stale_directories for p in present_directories)): + refuse(f'Unmanaged data blocks directory replacement: {name}') + + for name in sorted(stale_files): + if name in present_files: + (destination / name).unlink() + # rmdir only: a user-added child always keeps its containing tree alive. + for name in sorted(stale_directories, key=lambda p: (-p.count('/'), p)): + if name in present_directories: try: - with os.fdopen(fd, 'wb') as output, path.open('rb') as input_file: - shutil.copyfileobj(input_file, output) - # Keep executable bits, remove special bits, and keep owner writable. - os.chmod(temporary, stat.S_IMODE(path.stat().st_mode) & 0o777 | 0o600) - os.replace(temporary, target) - finally: - if os.path.exists(temporary): - os.unlink(temporary) + (destination / name).rmdir() + except OSError as error: + if error.errno not in (errno.ENOTEMPTY, errno.EEXIST): + raise + destination.mkdir(parents=True, exist_ok=True) + for name in sorted(directories, key=lambda p: (p.count('/'), p)): + target = destination / name + target.mkdir(parents=True, exist_ok=True) + target.chmod(0o755) + for name in sorted(files): + path, target = source / name, destination / name + target.parent.mkdir(parents=True, exist_ok=True) + + def copy(output): + with path.open('rb') as input_file: + shutil.copyfileobj(input_file, output) + + atomic_write(target, copy, (stat.S_IMODE(path.stat().st_mode) & 0o777) | 0o600) + state = json.dumps({'version': 1, 'files': sorted(files), + 'directories': sorted(directories)}, indent=2) + '\n' + atomic_write(destination / MANIFEST, lambda output: output.write(state.encode()), 0o600) + + +if __name__ == '__main__': + try: + if len(sys.argv) != 3: + refuse('Usage: nix-copy.py SOURCE DESTINATION') + refresh(*map(Path, sys.argv[1:])) + except (ValueError, OSError) as error: + raise SystemExit(f'Package refresh refused: {error}') diff --git a/scripts/nix-idle.py b/scripts/nix-idle.py index 8ce0dc0..b827ffd 100644 --- a/scripts/nix-idle.py +++ b/scripts/nix-idle.py @@ -3,34 +3,59 @@ import os from pathlib import Path -prefix = Path(os.environ.get('DPREFIX', str(Path.home() / '.darling'))).resolve() -try: - pid = int((prefix / '.init.pid').read_text().strip()) -except FileNotFoundError: - pid = 0 -except (OSError, ValueError): - raise SystemExit('Cannot validate prefix server marker; inspect it before maintenance.') -if pid > 0: - try: - status = (Path('/proc') / str(pid) / 'status').read_text() - if not any(line.startswith('State:') and line.split()[1] == 'Z' for line in status.splitlines()): - raise SystemExit('Prefix has a live server marker; exit Riot/League and run darling shutdown in the ORIGINAL namespace before updating. No process was stopped.') - except FileNotFoundError: - pass -# Only inspect same-user mappings under this exact prefix. An unrelated game's -# command name is not grounds to stop or modify it. Unreadable same-user maps -# fail closed because we cannot rule out a client still using these files. -paths = [str(prefix / name) + '/' for name in ( - 'Applications/Riot Client.app', 'Applications/League of Legends.app', - 'Users/Shared/Riot Games')] -for process in Path('/proc').iterdir(): - if not process.name.isdigit(): - continue - try: - if process.stat().st_uid != os.getuid(): + +def client_active(prefix, *, processes=None): + """Check exact-prefix mappings; only vanished processes may be skipped.""" + paths = [str(prefix / name) + '/' for name in ( + 'Applications/Riot Client.app', 'Applications/League of Legends.app', + 'Users/Shared/Riot Games')] + namespace_map = Path('/proc/self/uid_map').read_text() + for process in Path('/proc').iterdir() if processes is None else processes: + if not process.name.isdigit(): continue - mappings = (process / 'maps').read_text() - if any(path in mappings for path in paths): - raise SystemExit('A client maps this prefix; exit it before rootless maintenance. No process was stopped.') - except (FileNotFoundError, ProcessLookupError, PermissionError): - continue + try: + if process.stat().st_uid != os.getuid(): + continue + # The outer live-marker gate excludes earlier Darling namespaces. + # Host processes are not ours to inspect; their maps are commonly + # unreadable from a child user namespace (including systemd --user). + if processes is None and (process / 'uid_map').read_text() != namespace_map: + continue + mappings = (process / 'maps').read_text() + if any(path in mappings for path in paths): + return True + except (FileNotFoundError, ProcessLookupError): + # Process exited between enumeration and inspection. + continue + except OSError as error: + raise RuntimeError(f'Cannot inspect process {process.name}; cannot establish that the prefix is idle: {error}') from error + return False + + +def main(): + prefix = Path(os.environ.get('DPREFIX', str(Path.home() / '.darling'))).resolve() + try: + pid = int((prefix / '.init.pid').read_text().strip()) + except FileNotFoundError: + pid = 0 + except (OSError, ValueError): + raise SystemExit('Cannot validate prefix server marker; inspect it before maintenance.') + if pid > 0: + try: + status = (Path('/proc') / str(pid) / 'status').read_text() + if not any(line.startswith('State:') and line.split()[1] == 'Z' for line in status.splitlines()): + raise SystemExit('Prefix has a live server marker; exit Riot/League and run darling shutdown in the ORIGINAL namespace before updating. No process was stopped.') + except (FileNotFoundError, ProcessLookupError): + pass + except OSError as error: + raise SystemExit(f'Cannot validate prefix server marker: {error}') from error + try: + active = client_active(prefix) + except (OSError, RuntimeError) as error: + raise SystemExit(str(error)) from error + if active: + raise SystemExit('A client maps this prefix; exit it before rootless maintenance. No process was stopped.') + + +if __name__ == '__main__': + main() diff --git a/scripts/nix-lifecycle.py b/scripts/nix-lifecycle.py new file mode 100644 index 0000000..598ad00 --- /dev/null +++ b/scripts/nix-lifecycle.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Keep namespace-local shutdown authority until the foreground and clients exit. + +Never kill by name or use a marker PID as authority to signal a host process. +Uninspectable mappings retain this namespace rather than guessing it is idle. +""" +import importlib.util +import os +from pathlib import Path +import signal +import subprocess +import sys +import time + + +def main(): + scripts = Path(__file__).resolve().parent + spec = importlib.util.spec_from_file_location('nix_idle', scripts / 'nix-idle.py') + assert spec is not None and spec.loader is not None + idle = importlib.util.module_from_spec(spec) + spec.loader.exec_module(idle) + prefix = Path(os.environ['DPREFIX']).resolve() + child = None + interrupted = 0 + + def interrupted_by(signum, _frame): + nonlocal interrupted + interrupted = 128 + signum + # Only our own foreground child, never a marker PID or host-wide search. + if child is not None and child.poll() is None: + try: + child.send_signal(signum) + except ProcessLookupError: + pass + + for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(signum, interrupted_by) + result = 1 + try: + child = subprocess.Popen(sys.argv[1:]) + result = child.wait() + if result < 0: + result = 128 - result + except OSError as error: + print(f'Cannot start client: {error}', file=sys.stderr) + finally: + # A launcher can return while guest clients remain. Do not tear down + # their server, or discard the only namespace capable of shutting it down. + last_error = None + while True: + try: + if not idle.client_active(prefix): + break + last_error = None + except (OSError, RuntimeError) as error: + if str(error) != last_error: + print(f'{error}; retaining original namespace and retrying. ' + 'Exit clients to allow safe shutdown.', file=sys.stderr) + last_error = str(error) + time.sleep(1) + while True: + try: + shutdown = subprocess.run(['bash', str(scripts / 'darling-rootless.sh'), 'shutdown']).returncode + except OSError as error: + print(f'Cannot run namespace-local shutdown: {error}', file=sys.stderr) + shutdown = 1 + if not shutdown: + break + print(f'Darling namespace-local shutdown failed (status {shutdown}); ' + 'retaining the original namespace and retrying in 5 seconds. ' + 'Keep this terminal open.', file=sys.stderr) + time.sleep(5) + result = interrupted or result + return result + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/nix-session.sh b/scripts/nix-session.sh index 9626465..f9fca2d 100755 --- a/scripts/nix-session.sh +++ b/scripts/nix-session.sh @@ -27,6 +27,6 @@ if [ "${1:-}" = --installer ] || [ ! -d "$DPREFIX/Users/Shared/Riot Games/Riot Client.app" ]; }; then interactive read -r -p 'Region [euw]: ' region || exit 1 - exec ./scripts/installer.sh "${region:-euw}" + exec python3 scripts/nix-lifecycle.py ./scripts/installer.sh "${region:-euw}" fi -exec ./scripts/run.sh +exec python3 scripts/nix-lifecycle.py ./scripts/run.sh diff --git a/tests/test_nix_compilers.py b/tests/test_nix_compilers.py new file mode 100644 index 0000000..a7a900d --- /dev/null +++ b/tests/test_nix_compilers.py @@ -0,0 +1,52 @@ +"""Direct regression for FHS compiler wrapper installation and dispatch.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class CompilerTests(unittest.TestCase): + def test_build_hook_and_compiler_dispatch(self): + with tempfile.TemporaryDirectory(prefix="nix-compilers-") as tmp: + root = Path(tmp) + out = root / "out" + cwd = root / "build" + llvm = root / "llvm" + for directory in (out / "usr/bin", out / "usr/lib", cwd, llvm / "bin"): + directory.mkdir(parents=True) + for name in ("clang", "clang++"): + target = llvm / "bin" / name + target.write_text("#!/bin/sh\nprintf '%s\\n' \"$0\" \"$@\"\n") + target.chmod(0o755) + (out / "usr/bin" / name).symlink_to(target) + flake = (ROOT / "flake.nix").read_text() + hook = flake.split("extraBuildCommands = ''", 1)[1].split("\n '';", 1)[0] + for variable in ("llvmPackages.clang", "llvmPackages.clang-unwrapped"): + hook = hook.replace("${" + variable + "}", str(llvm)) + hook = hook.replace("${./scripts/nix-compiler.sh}", str(ROOT / "scripts/nix-compiler.sh")) + hook = hook.replace("''${", "${") + result = subprocess.run(["bash", "-eu", "-c", hook], cwd=cwd, + env={**os.environ, "out": str(out)}, + text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(list(cwd.iterdir()), [], "hook must operate on $out, not cwd") + for name, args, expected in ( + ("clang", ["-c", "source with spaces.c"], + ["-idirafter", "/usr/include", "-c", "source with spaces.c"]), + ("clang++", ["-target", "x86_64-apple-darwin", "source.cc"], + ["-target", "x86_64-apple-darwin", "source.cc"]), + ): + wrapper = out / "usr/bin" / name + self.assertFalse(wrapper.is_symlink() and wrapper.resolve().is_relative_to(llvm)) + script = wrapper.read_text().replace("/usr/bin/", str(out / "usr/bin") + "/") + wrapper.write_text(script) + result = subprocess.run([str(wrapper), *args], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines(), [str(llvm / "bin" / name), *expected]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nix_copy_manifest.py b/tests/test_nix_copy_manifest.py new file mode 100644 index 0000000..d87f69c --- /dev/null +++ b/tests/test_nix_copy_manifest.py @@ -0,0 +1,101 @@ +"""Managed refresh regressions; all installations are disposable fixtures.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +COPY = ROOT / 'scripts/nix-copy.py' +MANIFEST = '.nix-managed-files.json' + + +class ManagedCopyTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR')) + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.source = self.root / 'source' + self.destination = self.root / 'workspace' + self.source.mkdir() + self.destination.mkdir() + self.put(self.source, 'scripts/current.sh', 'current') + + def put(self, root, name, content='user data'): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return path + + def refresh(self, success=True, source=None, destination=None): + result = subprocess.run([sys.executable, str(COPY), str(source or self.source), + str(destination or self.destination)], + capture_output=True, text=True) + if success: + self.assertEqual(result.returncode, 0, result.stderr) + else: + self.assertNotEqual(result.returncode, 0) + return result + + def manifest(self, files=(), directories=()): + path = self.destination / MANIFEST + path.write_text(json.dumps({'version': 1, 'files': list(files), + 'directories': list(directories)})) + return path + + def test_refresh_prunes_removed_files_and_empty_directories(self): + stale = self.put(self.source, 'scripts/obsolete/nested/old.sh') + self.put(self.source, 'NOTICE.md') + self.refresh() + stale.unlink() + stale.parent.rmdir() + stale.parent.parent.rmdir() + (self.source / 'NOTICE.md').unlink() + self.refresh() + self.assertFalse((self.destination / 'scripts/obsolete').exists()) + self.assertFalse((self.destination / 'NOTICE.md').exists()) + self.assertEqual((self.destination / 'scripts/current.sh').read_text(), 'current') + state = json.loads((self.destination / MANIFEST).read_text()) + self.assertEqual(state['files'], ['scripts/current.sh']) + self.assertEqual(state['directories'], ['scripts']) + + def test_removed_tree_retains_unmanaged_children_and_mutable_roots(self): + self.put(self.source, 'docs/old.txt') + self.refresh() + (self.source / 'docs/old.txt').unlink() + (self.source / 'docs').rmdir() + preserved = ['docs/personal.txt', 'scripts/local.sh', 'build/cache', + 'usr-local/bin/tool', 'prefix/game/save', 'other/data'] + for name in preserved: + self.put(self.destination, name) + self.refresh() + self.assertFalse((self.destination / 'docs/old.txt').exists()) + for name in preserved: + self.assertEqual((self.destination / name).read_text(), 'user data') + + def test_migration_preserves_unknown_legacy_files_then_tracks_new_files(self): + self.put(self.destination, 'scripts/legacy.sh') + self.refresh() + self.assertTrue((self.destination / MANIFEST).is_file()) + (self.source / 'scripts/current.sh').unlink() + self.refresh() + self.assertTrue((self.destination / 'scripts/legacy.sh').is_file()) + self.assertFalse((self.destination / 'scripts/current.sh').exists()) + + + + + + + + + + + + + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_nix_lifecycle.py b/tests/test_nix_lifecycle.py new file mode 100644 index 0000000..72ebd3e --- /dev/null +++ b/tests/test_nix_lifecycle.py @@ -0,0 +1,90 @@ +"""Inert fixtures: no real Darling execution or live prefix modification.""" +import importlib.util +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import MagicMock + +ROOT = Path(__file__).resolve().parents[1] + + +class IdleTests(unittest.TestCase): + def test_unreadable_maps_fail_closed_but_disappearing_process_is_skipped(self): + spec = importlib.util.spec_from_file_location('nix_idle_fixture', ROOT / 'scripts/nix-idle.py') + assert spec is not None and spec.loader is not None + idle = importlib.util.module_from_spec(spec) + # Older versions run on import: point even those at an inert prefix. + with tempfile.TemporaryDirectory() as folder: + from unittest.mock import patch + with patch.dict(os.environ, DPREFIX=folder): + spec.loader.exec_module(idle) + process = MagicMock() + process.name = '123' + process.stat.return_value.st_uid = os.getuid() + (process / 'maps').read_text.side_effect = PermissionError('denied') + with self.assertRaisesRegex(RuntimeError, 'Cannot inspect'): + idle.client_active(Path('/fixture/prefix'), processes=[process]) + (process / 'maps').read_text.side_effect = FileNotFoundError() + self.assertFalse(idle.client_active(Path('/fixture/prefix'), processes=[process])) + + +class LifecycleTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + scripts = self.root / 'scripts' + scripts.mkdir() + for name in ('nix-session.sh', 'nix-lifecycle.py'): + if (ROOT / 'scripts' / name).exists(): + shutil.copy(ROOT / 'scripts' / name, scripts / name) + self.log = self.root / 'log' + self.env = dict(os.environ, DARLING_WORKSPACE=str(self.root), + DPREFIX=str(self.root / 'prefix'), TEST_LOG=str(self.log)) + (self.root / 'prefix/Applications/Riot Client.app').mkdir(parents=True) + (scripts / 'nix-state.py').write_text('raise SystemExit(0)\n') + (scripts / 'nix-idle.py').write_text('''import os +calls = 0 +def client_active(prefix, **kwargs): + global calls + calls += 1 + with open(os.environ['TEST_LOG'], 'a') as f: + f.write('scan\\n') + return calls < 2 +''') + (scripts / 'darling-rootless.sh').write_text('''#!/bin/bash +echo "shutdown:$*" >> "$TEST_LOG" +if [ "${SHUTDOWN_RC:-0}" != 0 ] && [ ! -e "$TEST_LOG.retried" ]; then + touch "$TEST_LOG.retried" + exit "$SHUTDOWN_RC" +fi +exit 0 +''') + client = scripts / 'run.sh' + client.write_text('''#!/usr/bin/env bash +echo client-finished >> "$TEST_LOG" +exit "${CLIENT_RC:-0}" +'''.replace('#!/usr/bin/env bash', '#!' + str(shutil.which('bash')))) + client.chmod(0o755) + + def run_session(self, **environment): + return subprocess.run(['bash', str(self.root / 'scripts/nix-session.sh')], + env=dict(self.env, **environment), text=True, + capture_output=True, timeout=10) + + def test_exit_waits_for_command_and_clients_before_shutdown(self): + result = self.run_session() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.log.read_text().splitlines(), + ['client-finished', 'scan', 'scan', 'shutdown:shutdown']) + + def test_command_failure_preserved_and_shutdown_failure_reported(self): + result = self.run_session(CLIENT_RC='17', SHUTDOWN_RC='23') + self.assertEqual(result.returncode, 17, result.stderr) + self.assertIn('shutdown failed', result.stderr) + self.assertIn('23', result.stderr) + self.assertEqual(self.log.read_text().splitlines().count('shutdown:shutdown'), 2) + self.assertIn('shutdown:shutdown', self.log.read_text()) diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py index f0ee4f5..1a6e8c4 100644 --- a/tests/test_nix_repairs.py +++ b/tests/test_nix_repairs.py @@ -397,8 +397,11 @@ class SessionTests(unittest.TestCase): with tempfile.TemporaryDirectory(prefix='nix-dispatch-test-') as directory: root = Path(directory) (root / 'scripts').mkdir() - for name in ['nix-state.py', 'nix-session.sh']: + for name in ['nix-state.py', 'nix-session.sh', 'nix-lifecycle.py']: shutil.copy(ROOT / 'scripts' / name, root / 'scripts' / name) + # Keep lifecycle dispatch inert: no real /proc scan or Darling call. + (root / 'scripts/nix-idle.py').write_text('def client_active(prefix): return False\n') + (root / 'scripts/darling-rootless.sh').write_text('exit 0\n') (root / 'usr-local/bin').mkdir(parents=True) (root / 'usr-local/bin/darling').write_text('never executed') (root / 'usr-local/bin/darlingserver').write_text('never executed') From 33581fb216f5dea98647f6eac5b8f74f2ce4a4aa Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Sun, 18 Oct 2026 13:51:58 +0200 Subject: [PATCH 21/23] fix(nix): address review feedback on setup and runtime safety --- docs/NIX.md | 2 ++ docs/SETUP.md | 2 ++ scripts/installer.sh | 3 ++- scripts/nix-lifecycle.py | 22 +++++++++++++++------- scripts/nix-state.py | 6 ++++-- upstream/manifest.json | 1 + 6 files changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/NIX.md b/docs/NIX.md index 853d844..197120d 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -71,6 +71,8 @@ one of the following methods. ```sh nix profile add github:lucannez64/darling-lol#default +# Older Nix/Lix versions without `profile add`: +# nix profile install github:lucannez64/darling-lol#default ``` Then launch from your application menu or terminal: diff --git a/docs/SETUP.md b/docs/SETUP.md index 5998a08..d427c62 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -18,6 +18,8 @@ To install permanently, adding the launcher to your user profile: ```sh nix profile add github:lucannez64/darling-lol#default +# Older Nix/Lix versions without `profile add`: +# nix profile install github:lucannez64/darling-lol#default ``` Then run `darling-lol` or use the desktop application menu. See diff --git a/scripts/installer.sh b/scripts/installer.sh index 48f607c..9bbadbc 100755 --- a/scripts/installer.sh +++ b/scripts/installer.sh @@ -8,7 +8,8 @@ if [ -n "${DARLING_WORKSPACE:-}" ] && ! python3 scripts/nix-state.py check; then fi export PATH="${DARLING_RUNNER_DIR:+$DARLING_RUNNER_DIR:}$repo/usr-local/bin:/usr/local/bin:$PATH" region=${1:-euw} -case "$region" in euw|eune|na|br|kr|jp|lan|las|oce|ru|tr) ;; *) echo 'Use a Riot installer region code' >&2; exit 1;; esac +case "$region" in lan) region=la1;; las) region=la2;; oce) region=oc1;; esac +case "$region" in euw|eune|na|br|kr|jp|la1|la2|oc1|ru|tr) ;; *) echo 'Use a Riot installer region code' >&2; exit 1;; esac python3 scripts/configure.py >/dev/null bash build/runtime/tools/fetch-installer.sh "$region" build/installer unzip -q -o "build/installer/live.$region.zip" -d build/installer diff --git a/scripts/nix-lifecycle.py b/scripts/nix-lifecycle.py index 598ad00..321ef07 100644 --- a/scripts/nix-lifecycle.py +++ b/scripts/nix-lifecycle.py @@ -13,6 +13,14 @@ import sys import time +def warn(message): + """Diagnostics must not discard the namespace after a terminal hangup.""" + try: + print(message, file=sys.stderr, flush=True) + except OSError: + pass + + def main(): scripts = Path(__file__).resolve().parent spec = importlib.util.spec_from_file_location('nix_idle', scripts / 'nix-idle.py') @@ -42,7 +50,7 @@ def main(): if result < 0: result = 128 - result except OSError as error: - print(f'Cannot start client: {error}', file=sys.stderr) + warn(f'Cannot start client: {error}') finally: # A launcher can return while guest clients remain. Do not tear down # their server, or discard the only namespace capable of shutting it down. @@ -54,21 +62,21 @@ def main(): last_error = None except (OSError, RuntimeError) as error: if str(error) != last_error: - print(f'{error}; retaining original namespace and retrying. ' - 'Exit clients to allow safe shutdown.', file=sys.stderr) + warn(f'{error}; retaining original namespace and retrying. ' + 'Exit clients to allow safe shutdown.') last_error = str(error) time.sleep(1) while True: try: shutdown = subprocess.run(['bash', str(scripts / 'darling-rootless.sh'), 'shutdown']).returncode except OSError as error: - print(f'Cannot run namespace-local shutdown: {error}', file=sys.stderr) + warn(f'Cannot run namespace-local shutdown: {error}') shutdown = 1 if not shutdown: break - print(f'Darling namespace-local shutdown failed (status {shutdown}); ' - 'retaining the original namespace and retrying in 5 seconds. ' - 'Keep this terminal open.', file=sys.stderr) + warn(f'Darling namespace-local shutdown failed (status {shutdown}); ' + 'retaining the original namespace and retrying in 5 seconds. ' + 'Keep this terminal open.') time.sleep(5) result = interrupted or result return result diff --git a/scripts/nix-state.py b/scripts/nix-state.py index e52dff4..a9dbab5 100644 --- a/scripts/nix-state.py +++ b/scripts/nix-state.py @@ -71,10 +71,12 @@ def inventory(root): for directory, dirs, files in os.walk(root, followlinks=False, onerror=enumeration_error): + excluded = ('.before-', '.rt-', '.llvm') + dirs[:] = sorted(name for name in dirs if not any(token in name for token in excluded)) for name in sorted(dirs + files): - path = Path(directory) / name - if any(token in path.name for token in ('.before-', '.rt-', '.llvm')): + if any(token in name for token in excluded): continue + path = Path(directory) / name info = path.lstat() target_str = '' if path.is_symlink(): diff --git a/upstream/manifest.json b/upstream/manifest.json index 8a83a47..7ad829f 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -245,6 +245,7 @@ "include/Metal/MTLTrace.h", "private-include/Metal/MTLArgumentInternal.h", "private-include/Metal/MTLBlitCommandEncoderInternal.h", + "private-include/Metal/MTLDeviceInfo.h", "private-include/Metal/MTLDepthStencilInternal.h", "private-include/Metal/MTLSamplerStateInternal.h", "private-include/Metal/MTLVertexDescriptorInternal.h", From 3e67d946c68dafa54de8e00a06695b6b4d73f775 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:54:18 +0200 Subject: [PATCH 22/23] test(nix): cover region routing, stable readiness and lost terminals --- tests/test_installer_regions.py | 33 ++++++++++++++++++++++++++++++ tests/test_nix_lifecycle.py | 21 ++++++++++++++++++- tests/test_nix_runtime_identity.py | 33 ++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/test_installer_regions.py diff --git a/tests/test_installer_regions.py b/tests/test_installer_regions.py new file mode 100644 index 0000000..5d06082 --- /dev/null +++ b/tests/test_installer_regions.py @@ -0,0 +1,33 @@ +"""Stop at a stub fetcher: verify installer routing without network or runtime use.""" +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class InstallerRegions(unittest.TestCase): + def test_aliases_and_platform_codes_reach_the_expected_cdn_region(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + (root / 'scripts').mkdir() + (root / 'build/runtime/tools').mkdir(parents=True) + shutil.copy(ROOT / 'scripts/installer.sh', root / 'scripts') + (root / 'scripts/configure.py').write_text('') + (root / 'build/runtime/tools/fetch-installer.sh').write_text( + 'printf "%s" "$1" > "$TEST_REGION_LOG"\nexit 23\n') + log = root / 'region' + env = dict(os.environ, TEST_REGION_LOG=str(log)) + env.pop('DARLING_WORKSPACE', None) + for region, expected in [('lan', 'la1'), ('las', 'la2'), ('oce', 'oc1'), + ('la1', 'la1'), ('la2', 'la2'), ('oc1', 'oc1'), + ('euw', 'euw')]: + with self.subTest(region=region): + log.unlink(missing_ok=True) + result = subprocess.run(['sh', str(root / 'scripts/installer.sh'), region], + env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 23, result.stderr) + self.assertEqual(log.read_text(), expected) diff --git a/tests/test_nix_lifecycle.py b/tests/test_nix_lifecycle.py index 72ebd3e..f339e07 100644 --- a/tests/test_nix_lifecycle.py +++ b/tests/test_nix_lifecycle.py @@ -6,7 +6,7 @@ import shutil import subprocess import tempfile import unittest -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch ROOT = Path(__file__).resolve().parents[1] @@ -88,3 +88,22 @@ exit "${CLIENT_RC:-0}" self.assertIn('23', result.stderr) self.assertEqual(self.log.read_text().splitlines().count('shutdown:shutdown'), 2) self.assertIn('shutdown:shutdown', self.log.read_text()) + + def test_shutdown_retries_when_terminal_diagnostics_fail(self): + spec = importlib.util.spec_from_file_location('lifecycle_fixture', self.root / 'scripts/nix-lifecycle.py') + lifecycle = importlib.util.module_from_spec(spec) + spec.loader.exec_module(lifecycle) + child = MagicMock() + child.wait.return_value = 17 + stderr = MagicMock() + stderr.write.side_effect = OSError('terminal disconnected') + with patch.dict(os.environ, self.env), \ + patch.object(lifecycle.signal, 'signal'), \ + patch.object(lifecycle.subprocess, 'Popen', return_value=child), \ + patch.object(lifecycle.subprocess, 'run', side_effect=[ + subprocess.CompletedProcess([], 23), subprocess.CompletedProcess([], 0)]) as shutdown, \ + patch.object(lifecycle.time, 'sleep'), \ + patch.object(lifecycle.sys, 'stderr', stderr): + self.assertEqual(lifecycle.main(), 17) + self.assertEqual(shutdown.call_count, 2) + stderr.write.assert_called() diff --git a/tests/test_nix_runtime_identity.py b/tests/test_nix_runtime_identity.py index e292085..e58448c 100644 --- a/tests/test_nix_runtime_identity.py +++ b/tests/test_nix_runtime_identity.py @@ -106,6 +106,39 @@ class RuntimeIdentityTests(unittest.TestCase): env.pop('DPREFIX') self.assertEqual(self.state('check', env).returncode, 0) + def test_inventory_ignores_changes_inside_backup_directories(self): + spec = importlib.util.spec_from_file_location('inventory_fixture', self.root / 'scripts/nix-state.py') + state = importlib.util.module_from_spec(spec) + spec.loader.exec_module(state) + runtime = self.root / 'usr-local/libexec/darling' + for suffix in ('.before-test', '.rt-test', '.llvm-test'): + backup = runtime / ('backup' + suffix) / 'nested' + backup.mkdir(parents=True) + saved = backup / 'saved.dylib' + saved.write_text('old backup') + before = state.inventory(runtime) + saved.write_text('changed backup') + self.assertEqual(state.inventory(runtime), before) + + def test_inventory_is_independent_of_filesystem_directory_order(self): + from unittest.mock import patch + spec = importlib.util.spec_from_file_location('inventory_fixture', self.root / 'scripts/nix-state.py') + state = importlib.util.module_from_spec(spec) + spec.loader.exec_module(state) + runtime = self.root / 'usr-local/libexec/darling' + for name in ('a', 'b'): + folder = runtime / name + folder.mkdir() + (folder / 'file').write_text(name) + original_walk = os.walk + def reversed_walk(*args, **kwargs): + for directory, dirs, files in original_walk(*args, **kwargs): + dirs.reverse() + yield directory, dirs, files + before = state.inventory(runtime) + with patch.object(state.os, 'walk', reversed_walk): + self.assertEqual(state.inventory(runtime), before) + if __name__ == '__main__': unittest.main() From dceee00d10211946d626ed2aaa92386ebb507355 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:59:49 +0200 Subject: [PATCH 23/23] fix(nvidia): retain readback, resize and bounded completion fixes Rebuild the remaining NVIDIA changes on current main, preserving the merged visual, draw-buffer, descriptor ownership and mixed Metal-tree fixes. Scope native-thread forwarding to resize notifications and make completion polling per device, bounded and part of the caller timeout budget. Co-authored-by: Pirrandi --- docs/TROUBLESHOOTING.md | 24 ++++ patches/cocotron.patch | 30 ++-- patches/darling.patch | 16 +++ patches/metal--deps--indium.patch | 109 ++++++++++++--- patches/xnu.patch | 21 ++- tests/test_nvidia_runtime.py | 199 +++++++++++++++++++++++++++ tools/riot-ca-render-policy-check.mm | 19 +++ upstream/manifest.json | 5 +- 8 files changed, 384 insertions(+), 39 deletions(-) create mode 100644 tests/test_nvidia_runtime.py diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 6c24424..0cc5bcf 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -252,6 +252,30 @@ A non-zero error or `FAILED` means the frames cannot reach the window. Before 20 An additional NVIDIA report on `68f816d` showed successful optimal-tiling import but repeated `Metal Device timed out giving us a Drawable to present` messages. Its [presentation trace](https://github.com/lucannez64/darling-lol/issues/3#issuecomment-6214732591) shows all three drawables queued and all three main-thread presentation callbacks running, followed by timeouts with three frames still queued. The compositor only prepared a Metal layer when it was the root layer, skipping nested Metal layers. It now prepares Metal layers throughout the tree. A standalone nested-layer reproduction exhausts the pool before this correction and passes ten presentations with pixel readback after it; the root-layer probe also passes. Confirmation on the reporter's NVIDIA system is still needed. +### NVIDIA resize signals, CPU readback and completion latency + +The EGL-compatible window visual, compositor back buffer and collision-safe RPC +socket allocation fixes are already part of the runtime. The remaining NVIDIA +work covers these independent paths: + +- A terminal resize can deliver `SIGWINCH` to a native driver thread without a + Darling RPC socket. The runtime forwards that resize notification to the guest + main thread; it keeps the existing handling for other signals and faults. +- With `RCLOL_GPU_DIRECT_PRESENT=0`, Chromium frames arrive through CPU readback. + The local compositor skips non-Metal trees to avoid covering the live client + with stale cached textures. Mixed trees still prepare and present Metal layers. +- NVIDIA timeline waits can wake late after GPU completion. Each device now polls + briefly while completions are pending before blocking: 2000 microseconds by + default on NVIDIA and zero on other vendors. An idle device blocks immediately. + The guest file `/tmp/metal-wait-spin-us` overrides the budget when the device is + created; `0` disables polling. Values are capped at 10000 microseconds and + malformed values use the vendor default. Restart Darling to change the budget. + +The contributor reported custom matches on a single-GPU RTX 3060, driver 615.71, +with sway/Xwayland using CPU readback. The initial polling change improved their +reported rate from about 20 to 27 FPS. Those observations predate the narrowed +integration; they do not establish hybrid-GPU or general NVIDIA support. + ### Host-side checks These are host settings, not game settings. Run on AC power, and use a performance power profile (`powerprofilesctl set performance`, or `asusctl profile -P Performance` on ASUS laptops). On the tested desktop the game is limited mostly by its own main thread on the CPU, not by the GPU (see [status](STATUS.md#game-fps-2026-10-1314)), so laptop CPU power limits matter as much as the GPU choice. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index b70d394..351e394 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -8756,10 +8756,10 @@ index 5dcac115..5de4f03f 100644 + @end diff --git a/QuartzCore/CALayerContext.m b/QuartzCore/CALayerContext.m -index 5dce8c56..d325ba5f 100644 +index 5dce8c5..3a7f937 100644 --- a/QuartzCore/CALayerContext.m +++ b/QuartzCore/CALayerContext.m -@@ -1,47 +1,69 @@ +@@ -1,47 +1,77 @@ #import +#import #import @@ -8799,6 +8799,14 @@ index 5dce8c56..d325ba5f 100644 + for(CALayer *child in layer.sublayers) + if(hasFullWindowSurface(child,size))return YES; + return NO; ++} ++/* CPU readback already presents non-Metal trees remotely. Locally ++ * compositing their cached textures can leave a frozen child over the client. ++ * Metal trees must always prepare their drawables, including mixed trees. */ ++static BOOL skipLocalRender(CALayer *layer, CGSize size) { ++ return layer && getenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER") && ++ !hasMetalLayer(layer) && ++ (!getenv("RCLOL_GPU_DIRECT_PRESENT") || hasFullWindowSurface(layer, size)); +} @implementation CALayerContext @@ -8848,7 +8856,7 @@ index 5dce8c56..d325ba5f 100644 [super dealloc]; } -@@ -51,12 +73,22 @@ +@@ -51,12 +81,22 @@ } - (void) setLayer: (CALayer *) layer { @@ -8871,7 +8879,7 @@ index 5dce8c56..d325ba5f 100644 } - (void) setSubwindow: (CGSubWindow*) subwindow -@@ -68,16 +100,50 @@ +@@ -68,16 +108,50 @@ } _subwindow = [subwindow retain]; @@ -8923,7 +8931,7 @@ index 5dce8c56..d325ba5f 100644 } - (void) assignTextureIdsToLayerTree: (CALayer *) layer { -@@ -93,8 +159,27 @@ +@@ -93,8 +167,27 @@ [self assignTextureIdsToLayerTree: child]; } @@ -8951,7 +8959,7 @@ index 5dce8c56..d325ba5f 100644 glEnable(GL_DEPTH_TEST); glShadeModel(GL_SMOOTH); -@@ -118,27 +203,63 @@ +@@ -118,27 +211,62 @@ [_deleteTextureIds removeAllObjects]; @@ -8975,8 +8983,7 @@ index 5dce8c56..d325ba5f 100644 - (void) render { + if (_cglWindow == NULL || _layer == nil) + return; -+ if(getenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER") && -+ !hasMetalLayer(_layer) && hasFullWindowSurface(_layer,_frame.size))return; ++ if(skipLocalRender(_layer, _frame.size))return; [self renderLayer: _layer]; } @@ -9020,7 +9027,7 @@ index 5dce8c56..d325ba5f 100644 } - (void) startTimerIfNeeded { -@@ -147,7 +268,7 @@ +@@ -147,7 +275,7 @@ target: self selector: @selector(timer:) userInfo: nil @@ -9029,13 +9036,12 @@ index 5dce8c56..d325ba5f 100644 } - (void) deleteTextureId: (NSNumber *) textureId { -@@ -155,7 +276,10 @@ +@@ -155,7 +283,9 @@ } - (void) flush { - CGLFlushDrawable(_glContext); -+ if(getenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER") && _layer && -+ !hasMetalLayer(_layer) && hasFullWindowSurface(_layer,_frame.size))return; ++ if(skipLocalRender(_layer, _frame.size))return; + if (_glContext != NULL && _cglWindow != NULL) + CGLFlushDrawable(_glContext); } diff --git a/patches/darling.patch b/patches/darling.patch index b399000..66477f9 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -10706,3 +10706,19 @@ index 0000000..1f3c473 + } +} +#endif +diff --git a/src/startup/mldr/elfcalls/threads.c b/src/startup/mldr/elfcalls/threads.c +index e789986..0d56b4e 100644 +--- a/src/startup/mldr/elfcalls/threads.c ++++ b/src/startup/mldr/elfcalls/threads.c +@@ -387,8 +387,9 @@ int __darling_thread_rpc_socket(void) { + // this is the main thread + t_server_socket = __dserver_main_thread_socket_fd; + } else { +- // threads should already have a per-thread socket assigned when they're created +- abort(); ++ // Native host-library threads do not have a Darling RPC connection. ++ // Let the caller report EPIPE, including the resize-signal bridge. ++ return -1; + } + } + return t_server_socket; diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index 09cd9a4..9ae8d9b 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -781,7 +781,7 @@ index 8546daa..7e4993c 100644 void updateBindings(); diff --git a/private-include/indium/device.private.hpp b/private-include/indium/device.private.hpp -index 58f1794..be7dbdc 100644 +index 58f1794..1f4bd5a 100644 --- a/private-include/indium/device.private.hpp +++ b/private-include/indium/device.private.hpp @@ -13,6 +13,8 @@ @@ -793,7 +793,7 @@ index 58f1794..be7dbdc 100644 namespace Indium { class PrivateDevice; -@@ -25,6 +27,8 @@ namespace Indium { +@@ -25,8 +27,11 @@ namespace Indium { class PrivateDevice: public Device, public std::enable_shared_from_this { private: @@ -801,8 +801,11 @@ index 58f1794..be7dbdc 100644 + std::vector> _scratchFree; std::mutex _eventLoopMutex; std::mutex _pollingMutex; ++ uint64_t _completionSpinNanoseconds = 0; -@@ -59,6 +63,8 @@ namespace Indium { + // these are stored separately (rather than in a single structure) + // so that we can easily copy them individually in the event loop polling function. +@@ -59,6 +64,8 @@ namespace Indium { } virtual std::string name() const override; @@ -811,7 +814,7 @@ index 58f1794..be7dbdc 100644 virtual std::shared_ptr newCommandQueue() override; virtual std::shared_ptr newRenderPipelineState(const RenderPipelineDescriptor& descriptor) override; virtual std::shared_ptr newComputePipelineState(const ComputePipelineDescriptor& descriptor, PipelineOption options, std::shared_ptr reflection) override; -@@ -73,6 +79,12 @@ namespace Indium { +@@ -73,6 +80,12 @@ namespace Indium { virtual void pollEvents(uint64_t timeoutNanoseconds) override; virtual void wakeupEventLoop() override; @@ -824,7 +827,7 @@ index 58f1794..be7dbdc 100644 void waitForSemaphore(VkSemaphore semaphore, uint64_t targetValue, std::function callback); /** -@@ -86,6 +98,11 @@ namespace Indium { +@@ -86,6 +99,11 @@ namespace Indium { * Similar to getTimelineSemaphore(), but wraps it in a shared pointer * that automatically releases the semaphore when all references to it die out. */ @@ -836,7 +839,7 @@ index 58f1794..be7dbdc 100644 std::shared_ptr getWrappedTimelineSemaphore(); BinarySemaphore getBinarySemaphore(bool exportable = false); -@@ -111,5 +128,34 @@ namespace Indium { +@@ -111,5 +129,34 @@ namespace Indium { INDIUM_PROPERTY(VkPhysicalDeviceMemoryProperties, m, M,emoryProperties); INDIUM_PROPERTY_READONLY(Feature, f, F,eatures); @@ -2835,17 +2838,20 @@ index 5f62ad0..5957bec 100644 DynamicVK::vkCmdBindDescriptorSets(buf->commandBuffer(), VK_PIPELINE_BIND_POINT_COMPUTE, _pso->layout(), 0, descriptorSets.size(), descriptorSets.data(), 0, nullptr); }; diff --git a/src/indium/device.cpp b/src/indium/device.cpp -index 322e186..78b77ae 100644 +index 322e186..4da420a 100644 --- a/src/indium/device.cpp +++ b/src/indium/device.cpp -@@ -1,4 +1,6 @@ +@@ -1,4 +1,9 @@ ++#include ++#include ++#include #include +#include +#include #include #include #include -@@ -12,39 +14,207 @@ +@@ -12,39 +17,207 @@ #include @@ -3059,7 +3065,7 @@ index 322e186..78b77ae 100644 continue; } -@@ -67,12 +237,105 @@ void Indium::initGlobalDeviceList() { +@@ -67,12 +240,105 @@ void Indium::initGlobalDeviceList() { DynamicVK::vkGetPhysicalDeviceFeatures2(device, &features); if (!features12.timelineSemaphore) { @@ -3155,11 +3161,11 @@ index 322e186..78b77ae 100644 + } + creationOrder.push_back(i); + } -+ } + } + + for (size_t index: creationOrder) { + globalDeviceList.push_back(std::make_shared(candidates[index].device)); - } ++ } + + appendDeviceLog("created VkDevice for " + std::to_string(globalDeviceList.size()) + " of " + + std::to_string(candidates.size()) + " supported device(s)" @@ -3167,7 +3173,7 @@ index 322e186..78b77ae 100644 }; void Indium::finitGlobalDeviceList() { -@@ -83,7 +346,8 @@ Indium::Device::~Device() {}; +@@ -83,8 +349,16 @@ Indium::Device::~Device() {}; Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): _physicalDevice(physicalDevice) @@ -3175,9 +3181,17 @@ index 322e186..78b77ae 100644 +{ Indium::SubmitThread::ensureStarted(); + DynamicVK::vkGetPhysicalDeviceProperties(_physicalDevice, &_properties); ++ _completionSpinNanoseconds = completionSpinBudget(_properties.vendorID, nullptr); ++ if (FILE* file = fopen("/tmp/metal-wait-spin-us", "r")) { ++ char value[64] = {}; ++ if (fscanf(file, "%63s", value) == 1) ++ _completionSpinNanoseconds = completionSpinBudget(_properties.vendorID, value); ++ fclose(file); ++ } std::vector queueFamilies; -@@ -233,6 +497,36 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): + uint32_t count = 0; +@@ -233,6 +507,36 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): } } @@ -3214,7 +3228,7 @@ index 322e186..78b77ae 100644 VkDeviceCreateInfo deviceCreateInfo {}; deviceCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; deviceCreateInfo.pQueueCreateInfos = queueCreateInfos.data(); -@@ -246,6 +540,7 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): +@@ -246,6 +550,7 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): } _features = indiumFeatures; @@ -3222,7 +3236,7 @@ index 322e186..78b77ae 100644 for (const auto& index: queueFamilyIndices) { VkQueue queue; -@@ -297,6 +592,9 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): +@@ -297,6 +602,9 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): }; Indium::PrivateDevice::~PrivateDevice() { @@ -3232,7 +3246,7 @@ index 322e186..78b77ae 100644 if (_oneshotCommandPool) { DynamicVK::vkDestroyCommandPool(_device, _oneshotCommandPool, nullptr); } -@@ -308,6 +606,23 @@ std::string Indium::PrivateDevice::name() const { +@@ -308,6 +616,23 @@ std::string Indium::PrivateDevice::name() const { return _properties.deviceName; }; @@ -3256,7 +3270,7 @@ index 322e186..78b77ae 100644 std::shared_ptr Indium::PrivateDevice::newCommandQueue() { return std::make_shared(shared_from_this()); }; -@@ -317,9 +632,9 @@ std::shared_ptr Indium::PrivateDevice::newRenderPip +@@ -317,9 +642,9 @@ std::shared_ptr Indium::PrivateDevice::newRenderPip }; std::shared_ptr Indium::PrivateDevice::newComputePipelineState(const ComputePipelineDescriptor& descriptor, PipelineOption options, std::shared_ptr reflection) { @@ -3269,7 +3283,7 @@ index 322e186..78b77ae 100644 return std::make_shared(shared_from_this(), descriptor); }; -@@ -357,6 +672,7 @@ std::shared_ptr Indium::PrivateDevice::newLibrary(const void* d +@@ -357,6 +682,7 @@ std::shared_ptr Indium::PrivateDevice::newLibrary(const void* d break; } @@ -3277,7 +3291,34 @@ index 322e186..78b77ae 100644 funcInfo.bindings.insert(funcInfo.bindings.end(), info.bindings.begin(), info.bindings.end()); funcInfo.embeddedSamplers.insert(funcInfo.embeddedSamplers.end(), info.embeddedSamplers.begin(), info.embeddedSamplers.end()); } -@@ -480,9 +796,18 @@ void Indium::PrivateDevice::waitForSemaphore(VkSemaphore semaphore, uint64_t tar +@@ -396,6 +722,26 @@ void Indium::PrivateDevice::pollEvents(uint64_t timeoutNanoseconds) { + + lock.unlock(); + ++ // NVIDIA's blocking timeline wait can wake 1-2 ms after completion. Poll ++ // briefly while work is pending; an idle device should block immediately. ++ if (_completionSpinNanoseconds && timeoutNanoseconds && semaphores.size() > 1) { ++ const auto started = std::chrono::steady_clock::now(); ++ const auto deadline = started + std::chrono::nanoseconds( ++ std::min(_completionSpinNanoseconds, timeoutNanoseconds)); ++ bool anyReady = false; ++ do { ++ for (size_t i = 0; i < semaphores.size() && !anyReady; ++i) { ++ uint64_t count = 0; ++ anyReady = DynamicVK::vkGetSemaphoreCounterValue(_device, semaphores[i], &count) == VK_SUCCESS && count >= values[i]; ++ } ++ if (anyReady) break; ++ __builtin_ia32_pause(); ++ } while (std::chrono::steady_clock::now() < deadline); ++ const uint64_t elapsed = std::chrono::duration_cast( ++ std::chrono::steady_clock::now() - started).count(); ++ timeoutNanoseconds = anyReady ? 0 : timeoutNanoseconds - std::min(timeoutNanoseconds, elapsed); ++ } ++ + VkSemaphoreWaitInfo info {}; + info.sType = VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO; + info.semaphoreCount = semaphores.size(); +@@ -480,9 +826,18 @@ void Indium::PrivateDevice::waitForSemaphore(VkSemaphore semaphore, uint64_t tar wakeupEventLoop(); }; @@ -3297,7 +3338,7 @@ index 322e186..78b77ae 100644 VkSemaphoreTypeCreateInfo typeInfo {}; typeInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_TYPE_CREATE_INFO; typeInfo.semaphoreType = VK_SEMAPHORE_TYPE_TIMELINE; -@@ -505,7 +830,14 @@ Indium::TimelineSemaphore Indium::PrivateDevice::getTimelineSemaphore() { +@@ -505,7 +860,14 @@ Indium::TimelineSemaphore Indium::PrivateDevice::getTimelineSemaphore() { }; void Indium::PrivateDevice::putTimelineSemaphore(const TimelineSemaphore& semaphore) { @@ -3313,7 +3354,7 @@ index 322e186..78b77ae 100644 }; std::shared_ptr Indium::PrivateDevice::getWrappedTimelineSemaphore() { -@@ -551,3 +883,160 @@ std::shared_ptr Indium::PrivateDevice::getWrappedBinary +@@ -551,3 +913,160 @@ std::shared_ptr Indium::PrivateDevice::getWrappedBinary delete ptr; }); }; @@ -8767,3 +8808,27 @@ index 1b5876e..03af560 100644 }; void Iridium::DynamicLLVM::finit() { +diff --git a/private-include/indium/completion-spin.private.hpp b/private-include/indium/completion-spin.private.hpp +new file mode 100644 +index 0000000..962fdad +--- /dev/null ++++ b/private-include/indium/completion-spin.private.hpp +@@ -0,0 +1,18 @@ ++#pragma once ++#include ++ ++namespace Indium { ++// A malformed override uses the vendor default. Saturation bounds both CPU use ++// and the microseconds-to-nanoseconds conversion, even for arbitrarily long input. ++inline uint64_t completionSpinBudget(uint32_t vendorID, const char* overrideValue) { ++ const uint64_t fallback = vendorID == 0x10de ? 2000000 : 0; ++ if (!overrideValue || !*overrideValue) return fallback; ++ uint64_t microseconds = 0; ++ for (const char* p = overrideValue; *p; ++p) { ++ if (*p < '0' || *p > '9') return fallback; ++ microseconds = microseconds * 10 + (*p - '0'); ++ if (microseconds > 10000) microseconds = 10000; ++ } ++ return microseconds * 1000; ++} ++} diff --git a/patches/xnu.patch b/patches/xnu.patch index be0c967..efbf7e3 100644 --- a/patches/xnu.patch +++ b/patches/xnu.patch @@ -1576,7 +1576,7 @@ index dc8fbb8..171746a 100644 + __linux_sched_yield_site("SWTYLD"); +} diff --git a/darling/src/libsystem_kernel/emulation/src/linux_premigration/signal/sigexc.c b/darling/src/libsystem_kernel/emulation/src/linux_premigration/signal/sigexc.c -index 206e92e..0646cb7 100644 +index 206e92e..1a763fe 100644 --- a/darling/src/libsystem_kernel/emulation/src/linux_premigration/signal/sigexc.c +++ b/darling/src/libsystem_kernel/emulation/src/linux_premigration/signal/sigexc.c @@ -151,8 +151,13 @@ void sigrt_handler(int signum, struct linux_siginfo* info, struct linux_ucontext @@ -1595,7 +1595,7 @@ index 206e92e..0646cb7 100644 } if (signum == SIGNAL_SIGEXC_SUSPEND) { -@@ -267,13 +272,150 @@ static void state_from_kernel(struct linux_ucontext* ctxt, const void* tstate, c +@@ -267,13 +272,165 @@ static void state_from_kernel(struct linux_ucontext* ctxt, const void* tstate, c #endif } @@ -1672,10 +1672,25 @@ index 206e92e..0646cb7 100644 +} + +#endif ++ ++/* Native driver threads have no Darling RPC connection. Forward resize ++ * notifications to the guest main thread without allocating or using RPC. ++ * Other signals, including synchronous faults and queued payloads, retain ++ * their existing handling. Never swallow a failed forward. */ ++static int forward_native_resize_signal(int linux_signum, int status) { ++ if (status != -32 /* -LINUX_EPIPE */ || linux_signum != LINUX_SIGWINCH) ++ return 0; ++ long pid = LINUX_SYSCALL(__NR_getpid); ++ if (LINUX_SYSCALL(__NR_gettid) == pid) ++ return 0; ++ return LINUX_SYSCALL(__NR_tgkill, pid, pid, linux_signum) == 0; ++} + void sigexc_handler(int linux_signum, struct linux_siginfo* info, struct linux_ucontext* ctxt) { int status = dserver_rpc_interrupt_enter(); ++ if (forward_native_resize_signal(linux_signum, status)) ++ return; + int relaxed = 0; + +#ifdef __x86_64__ @@ -1747,7 +1762,7 @@ index 206e92e..0646cb7 100644 } kern_printf("sigexc_handler(%d, %p, %p)\n", linux_signum, info, ctxt); -@@ -359,7 +501,13 @@ void sigexc_handler(int linux_signum, struct linux_siginfo* info, struct linux_u +@@ -359,7 +516,13 @@ void sigexc_handler(int linux_signum, struct linux_siginfo* info, struct linux_u sizeof(sa.sa_mask)); // Resend signal to self diff --git a/tests/test_nvidia_runtime.py b/tests/test_nvidia_runtime.py new file mode 100644 index 0000000..aaa37dd --- /dev/null +++ b/tests/test_nvidia_runtime.py @@ -0,0 +1,199 @@ +"""Compile the patched native-thread signal bridge and completion budget policy.""" +import shutil +import subprocess +from pathlib import Path +import tempfile +import unittest +import test_runtime_compatibility as sources + + +class NativeThreadChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + run_c = sources.CompatibilityChecks.run_c + + def test_resize_forwarding_is_limited_to_foreign_threads_and_successful_delivery(self): + source = self.patched('xnu', 'darling/src/libsystem_kernel/emulation/src/linux_premigration/signal/sigexc.c') + bridge = sources.function(source, 'forward_native_resize_signal') + self.run_c(''' +#include +#define LINUX_SIGWINCH 28 +#define __NR_getpid 1 +#define __NR_gettid 2 +#define __NR_tgkill 3 +static long tid=101, result=0; +static int sends; +static long fake_syscall(int number, ...) { + if (number==__NR_getpid) return 100; + if (number==__NR_gettid) return tid; + ++sends; return result; +} +#define LINUX_SYSCALL(...) fake_syscall(__VA_ARGS__) +''' + bridge + ''' +int main(void) { + assert(forward_native_resize_signal(28,-32)==1 && sends==1); + assert(forward_native_resize_signal(28,0)==0 && sends==1); + assert(forward_native_resize_signal(28,-9)==0 && sends==1); + for(int sig=1;sig<=64;++sig) + if(sig!=28) assert(forward_native_resize_signal(sig,-32)==0); + assert(sends==1); + tid=100; assert(forward_native_resize_signal(28,-32)==0 && sends==1); + tid=101; result=-3; + assert(forward_native_resize_signal(28,-32)==0 && sends==2); +} +''') + + def test_native_thread_without_rpc_socket_returns_an_error_instead_of_aborting(self): + source = self.patched('darling', 'src/startup/mldr/elfcalls/threads.c') + self.run_c(''' +#include +#include +#define SYS_gettid 1 +static int t_server_socket=-1, __dserver_main_thread_socket_fd=71; +static int tid=101; +static int getpid(void) { return 100; } +static int syscall(int number) { return tid; } +''' + sources.function(source, '__darling_thread_rpc_socket') + ''' +int main(void) { + assert(__darling_thread_rpc_socket()==-1); + t_server_socket=73; assert(__darling_thread_rpc_socket()==73); + t_server_socket=-1; tid=100; assert(__darling_thread_rpc_socket()==71); +} +''') + + def test_cpu_readback_skips_cached_surfaces_but_preserves_mixed_metal_trees(self): + source = self.patched('cocotron', 'QuartzCore/CALayerContext.m') + self.run_c('''#define _POSIX_C_SOURCE 200809L +#include +#include +typedef int BOOL; +typedef struct { int metal, full; } CALayer; +typedef struct { int width, height; } CGSize; +static BOOL hasMetalLayer(CALayer* layer) { return layer->metal; } +static BOOL hasFullWindowSurface(CALayer* layer, CGSize size) { return layer->full; } +''' + sources.function(source, 'skipLocalRender') + ''' +int main(void) { + CGSize size={16,16}; + assert(!skipLocalRender(NULL,size)); + for(int shortcut=0;shortcut<=1;++shortcut) + for(int direct=0;direct<=1;++direct) + for(int metal=0;metal<=1;++metal) + for(int full=0;full<=1;++full) { + CALayer layer={metal,full}; + if(shortcut) setenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER","1",1); + else unsetenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER"); + if(direct) setenv("RCLOL_GPU_DIRECT_PRESENT","1",1); + else unsetenv("RCLOL_GPU_DIRECT_PRESENT"); + assert(!!skipLocalRender(&layer,size)==(shortcut && !metal && (!direct || full))); + } +} +''') + + +@unittest.skipUnless(shutil.which('c++'), 'C++ compiler required') +class CompletionBudgetChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + + def test_vendor_defaults_disable_clamping_and_malformed_overrides(self): + header = self.patched('metal--deps--indium', 'private-include/indium/completion-spin.private.hpp') + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) + (path / 'budget.hpp').write_text(header) + (path / 'check.cpp').write_text(''' +#include "budget.hpp" +#include +#include +int main() { + using Indium::completionSpinBudget; + assert(completionSpinBudget(0x1002,nullptr)==0); + assert(completionSpinBudget(0x10de,nullptr)==2000000); + assert(completionSpinBudget(0x1002,nullptr)==0); + assert(completionSpinBudget(0x10de,"0")==0); + assert(completionSpinBudget(0x1002,"15")==15000); + assert(completionSpinBudget(0x10de,"10001")==10000000); + const std::string huge(10000,'9'); + assert(completionSpinBudget(0x10de,huge.c_str())==10000000); + for(const char* invalid : {"", "-1", "1ms", "18446744073709551616x"}) { + assert(completionSpinBudget(0x10de,invalid)==2000000); + assert(completionSpinBudget(0x1002,invalid)==0); + } +} +''') + subprocess.run(['c++','-std=c++17',str(path/'check.cpp'),'-o',str(path/'check')], check=True, capture_output=True) + subprocess.run([str(path/'check')], check=True, capture_output=True, timeout=10) + + def test_polling_preserves_idle_blocking_callbacks_and_timeout_budget(self): + source = self.patched('metal--deps--indium', 'src/indium/device.cpp') + poll = sources.function(source, 'Indium::PrivateDevice::pollEvents') + harness = ''' +#include +#include +#include +#include +#include +#include +#include +#include +using VkSemaphore = int; +struct VkSemaphoreWaitInfo { + int sType, flags; + size_t semaphoreCount; + const VkSemaphore* pSemaphores; + const uint64_t* pValues; +}; +constexpr int VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO=1, VK_SEMAPHORE_WAIT_ANY_BIT=1; +constexpr int VK_SUCCESS=0, VK_TIMEOUT=1; +static int counterQueries, waitResult=VK_TIMEOUT; +static uint64_t lastTimeout; +static bool ready; +namespace DynamicVK { +int vkGetSemaphoreCounterValue(int, int semaphore, uint64_t* count) { + ++counterQueries; *count=ready && semaphore==2 ? 1 : 0; return VK_SUCCESS; +} +int vkWaitSemaphores(int, const VkSemaphoreWaitInfo*, uint64_t timeout) { + lastTimeout=timeout; return waitResult; +} +} +namespace Indium { +class PrivateDevice { +public: + std::mutex _pollingMutex, _eventLoopMutex; + int _device=0; + uint64_t _completionSpinNanoseconds=1000000; + std::vector _eventLoopSemaphores={1}; + std::vector _eventLoopWaitValues={1}; + std::vector> _eventLoopCallbacks={nullptr}; + void pollEvents(uint64_t timeoutNanoseconds); +}; +} +''' + poll + ''' +int main() { + Indium::PrivateDevice device; + device.pollEvents(2000000); + assert(counterQueries==0 && lastTimeout==2000000); + int callbacks=0; + device._eventLoopSemaphores.push_back(2); + device._eventLoopWaitValues.push_back(1); + device._eventLoopCallbacks.push_back([&]{++callbacks;}); + device.pollEvents(2000000); + assert(counterQueries>0 && lastTimeout<2000000 && callbacks==0); + ready=true; waitResult=VK_SUCCESS; + device.pollEvents(2000000); + assert(lastTimeout==0 && callbacks==1); + assert(device._eventLoopSemaphores.size()==1); + counterQueries=0; waitResult=VK_TIMEOUT; + device._eventLoopSemaphores.push_back(2); + device._eventLoopWaitValues.push_back(1); + device._eventLoopCallbacks.push_back(nullptr); + device.pollEvents(0); + assert(counterQueries==0 && lastTimeout==0); + device._completionSpinNanoseconds=0; + device.pollEvents(2000000); + assert(counterQueries==0 && lastTimeout==2000000); +} +''' + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) + (path / 'poll.cpp').write_text(harness) + subprocess.run(['c++', '-std=c++17', '-pthread', str(path/'poll.cpp'), '-o', str(path/'poll')], + check=True, capture_output=True) + subprocess.run([str(path/'poll')], check=True, capture_output=True, timeout=10) diff --git a/tools/riot-ca-render-policy-check.mm b/tools/riot-ca-render-policy-check.mm index 5d3d23a..ad21b97 100644 --- a/tools/riot-ca-render-policy-check.mm +++ b/tools/riot-ca-render-policy-check.mm @@ -38,6 +38,7 @@ int main() { // No NSApplication, window, graphics context, GPU work, or published IOSurface. // Objects intentionally live until process exit; the sentinel is never passed to CGL. [NSAutoreleasePool new]; + unsetenv("RCLOL_GPU_DIRECT_PRESENT"); int dimensions[]={16,16,4,64}; const void* keys[]={kIOSurfaceWidth,kIOSurfaceHeight,kIOSurfaceBytesPerElement,kIOSurfaceBytesPerRow}; const void* values[4]; @@ -77,6 +78,24 @@ int main() { [surfaceOnly render]; printf("surface_only_shortcut_enabled: renderCalls=%u expected=0\n",renderCalls); if(renderCalls!=0) return 6; + // Partial IOSurfaces bypass local rendering only in CPU-readback mode. + int partialDimensions[]={8,8,4,32}; + for(unsigned i=0;i<4;++i) values[i]=CFNumberCreate(NULL,kCFNumberIntType,&partialDimensions[i]); + CFDictionaryRef partialProperties=CFDictionaryCreate(NULL,keys,values,4,&kCFTypeDictionaryKeyCallBacks,&kCFTypeDictionaryValueCallBacks); + IOSurfaceRef partial=IOSurfaceCreate(partialProperties); + if(!partial) return 8; + RenderPolicySurfaceLayer* partialLayer=[[RenderPolicySurfaceLayer alloc] initWithSurface:partial]; + RenderPolicyContext* partialContext=[[RenderPolicyContext alloc] initWithRoot:partialLayer]; + renderCalls=0; + [partialContext render]; + if(renderCalls!=0) return 9; + setenv("RCLOL_GPU_DIRECT_PRESENT","1",1); + [partialContext render]; + if(renderCalls!=1) return 10; + unsetenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER"); + unsetenv("RCLOL_GPU_DIRECT_PRESENT"); + [partialContext render]; + if(renderCalls!=2) return 11; printf("PASS: mixed-tree Metal preparation survives IOSurface shortcut\n"); return 0; } diff --git a/upstream/manifest.json b/upstream/manifest.json index 7ad829f..0629351 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -8,7 +8,7 @@ "revision": "60ba801decee7a00782f74f6be4c8ffb013f79ff", "repository": "https://github.com/darlinghq/darling.git", "patch": "patches/darling.patch", - "modified_files": 88, + "modified_files": 89, "new_files": [ "src/CoreAudio/CoreAudio/AudioConverterShim.c", "src/CoreAudio/AudioTrace.h", @@ -307,7 +307,8 @@ "private-include/indium/submission-batch.private.hpp", "private-include/indium/submit-batch.private.hpp", "private-include/indium/submit-thread.private.hpp", - "private-include/indium/waits.private.hpp" + "private-include/indium/waits.private.hpp", + "private-include/indium/completion-spin.private.hpp" ] }, {