Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions lib/core/extensions/sandbox/sandbox_launch_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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({
Expand All @@ -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();
}
13 changes: 11 additions & 2 deletions lib/features/macos/querya_platform_menu_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
38 changes: 38 additions & 0 deletions test/core/extensions/sandbox/sandbox_process_runner_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down
Loading