From 99a0f88ad93744e8f33ff4ba221b7be87574c331 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 12:53:55 +0300 Subject: [PATCH] fix(macos): Copy menu item works; Seatbelt profile names real paths and escapes them (#1590) --- .../sandbox/sandbox_launch_command.dart | 32 +++++++++++++--- .../macos/querya_platform_menu_bar.dart | 13 ++++++- .../sandbox/sandbox_process_runner_test.dart | 38 +++++++++++++++++++ 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/lib/core/extensions/sandbox/sandbox_launch_command.dart b/lib/core/extensions/sandbox/sandbox_launch_command.dart index 97482cf5..a191ec89 100644 --- a/lib/core/extensions/sandbox/sandbox_launch_command.dart +++ b/lib/core/extensions/sandbox/sandbox_launch_command.dart @@ -140,8 +140,8 @@ class SandboxLaunchCommand { String? extensionRoot, }) { final profile = buildMacOsSeatbeltProfile( - scratchPath: scratchPath, - extensionRoot: extensionRoot, + scratchPath: realPathOrSelf(scratchPath), + extensionRoot: extensionRoot == null ? null : realPathOrSelf(extensionRoot), ); return SandboxLaunchCommand( executable: 'sandbox-exec', @@ -156,6 +156,26 @@ class SandboxLaunchCommand { } } +/// [path] with its symbolic links resolved, or [path] itself when it does not +/// exist or cannot be resolved. +/// +/// Seatbelt matches `subpath` against the real path. The macOS temporary +/// folder is under `/var`, a link to `/private/var`, so a profile that names +/// `/var/folders/...` never matched and the extension could not write its +/// scratch directory (#1590). +String realPathOrSelf(String path) { + try { + return Directory(path).resolveSymbolicLinksSync(); + } on FileSystemException { + return path; + } +} + +/// [value] as a Seatbelt (SBPL) string literal: a quote or a backslash in a +/// path must not end the string and change the rule (#1590). +String seatbeltString(String value) => + '"${value.replaceAll(r'\', r'\\').replaceAll('"', r'\"')}"'; + /// Seatbelt (sandbox-exec) profile allowing network + scratch RW only, /// explicitly denying access to host user credentials and home directories (/Users). String buildMacOsSeatbeltProfile({ @@ -171,10 +191,12 @@ String buildMacOsSeatbeltProfile({ ..writeln('(allow network*)') ..writeln('(allow file-read*)') ..writeln('(deny file-read* (subpath "/Users"))') - ..writeln('(allow file-write* (subpath "$scratchPath"))') - ..writeln('(allow file-read* (subpath "$scratchPath"))'); + ..writeln('(allow file-write* (subpath ${seatbeltString(scratchPath)}))') + ..writeln('(allow file-read* (subpath ${seatbeltString(scratchPath)}))'); if (extensionRoot != null && extensionRoot.isNotEmpty) { - buffer.writeln('(allow file-read* (subpath "$extensionRoot"))'); + buffer.writeln( + '(allow file-read* (subpath ${seatbeltString(extensionRoot)}))', + ); } return buffer.toString().trimRight(); } diff --git a/lib/features/macos/querya_platform_menu_bar.dart b/lib/features/macos/querya_platform_menu_bar.dart index 591e9609..ebcf7425 100644 --- a/lib/features/macos/querya_platform_menu_bar.dart +++ b/lib/features/macos/querya_platform_menu_bar.dart @@ -232,12 +232,21 @@ class QueryaPlatformMenuBar extends StatelessWidget { ); }, ), - const PlatformMenuItem( + // Without onSelected the item is drawn disabled (#1590). + PlatformMenuItem( label: 'Copy', - shortcut: SingleActivator( + shortcut: const SingleActivator( LogicalKeyboardKey.keyC, meta: true, ), + onSelected: () { + final focusCtx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke( + focusCtx, + CopySelectionTextIntent.copy, + ); + }, ), PlatformMenuItem( label: 'Paste', diff --git a/test/core/extensions/sandbox/sandbox_process_runner_test.dart b/test/core/extensions/sandbox/sandbox_process_runner_test.dart index b9e5bc32..ddd2f543 100644 --- a/test/core/extensions/sandbox/sandbox_process_runner_test.dart +++ b/test/core/extensions/sandbox/sandbox_process_runner_test.dart @@ -402,6 +402,44 @@ void main() { // Round-trip through JSON to ensure no control chars. expect(jsonEncode(profile), contains(r'/tmp/querya_sandbox/x')); }); + + test('a quote or backslash in a path cannot end the string (#1590)', () { + final profile = buildMacOsSeatbeltProfile( + scratchPath: r'/tmp/a"b\c', + extensionRoot: r'/ext/x") (allow file-write* (subpath "/', + ); + expect(profile, contains(r'(subpath "/tmp/a\"b\\c")')); + expect(profile, isNot(contains('(allow file-write* (subpath "/")'))); + expect( + profile.split('\n').where((l) => l.startsWith('(allow file-write*')), + hasLength(1), + ); + }); + + test('scratch and extension paths are named by their real path (#1590)', + () async { + final real = await Directory.systemTemp.createTemp('querya_real_'); + addTearDown(() => real.delete(recursive: true)); + final linkDir = await Directory.systemTemp.createTemp('querya_link_'); + addTearDown(() => linkDir.delete(recursive: true)); + final link = Link('${linkDir.path}/scratch'); + await link.create(real.path); + final realPath = real.resolveSymbolicLinksSync(); + + final command = SandboxLaunchCommand.build( + pluginExecutable: '/bin/true', + scratchPath: link.path, + extensionRoot: link.path, + platformOverride: 'macos', + ); + final profile = command.arguments[1]; + expect(profile, contains('(subpath "$realPath")')); + expect(profile, isNot(contains(link.path))); + }, skip: Platform.isWindows ? 'symbolic links need privileges' : null); + + test('a path that does not exist is kept as given', () { + expect(realPathOrSelf('/no/such/querya/dir'), '/no/such/querya/dir'); + }); }); group('SandboxProcessRunner.detectBwrapAvailability', () {