diff --git a/lib/core/extensions/sandbox/sandbox_sanitizer.dart b/lib/core/extensions/sandbox/sandbox_sanitizer.dart index 2b2bca53..93300b2f 100644 --- a/lib/core/extensions/sandbox/sandbox_sanitizer.dart +++ b/lib/core/extensions/sandbox/sandbox_sanitizer.dart @@ -32,9 +32,15 @@ class SandboxSanitizer { caseSensitive: false, ); + /// Soft bound for regex work on a single line (stderr pipe also caps). + static const maxInputChars = 256 * 1024; + /// Sanitizes a single chunk / line of plugin output. static String sanitize(String input) { if (input.isEmpty) return input; + if (input.length > maxInputChars) { + input = input.substring(0, maxInputChars); + } var out = input; out = out.replaceAll(_privateKey, redactionToken); out = out.replaceAll(_jwt, redactionToken); diff --git a/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart b/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart index e678c660..5eb1f8b0 100644 --- a/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart +++ b/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart @@ -9,21 +9,40 @@ import 'package:querya_desktop/core/extensions/sandbox/sandbox_sanitizer.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_security_audit.dart'; /// Captures `process.stderr`, sanitizes it, and writes to a rotating log. +/// +/// Incomplete lines are held in a capped carry buffer. Newline scanning walks +/// only the new chunk (no full-buffer `split` rebuild each time). Oversized +/// lines are truncated and the remainder is dropped until the next newline. class SandboxStderrPipe { SandboxStderrPipe({ required this.pluginId, required this.log, this.audit, this.onSanitizedLine, - }); + this.maxCarryChars = defaultMaxCarryChars, + this.maxSanitizeChars = defaultMaxSanitizeChars, + }) : assert(maxCarryChars > 0), + assert(maxSanitizeChars > 0); + + /// Default cap for an incomplete stderr line held across chunks. + static const defaultMaxCarryChars = 256 * 1024; + + /// Default max length passed into [SandboxSanitizer.sanitize]. + static const defaultMaxSanitizeChars = 256 * 1024; + + static const _truncatedSuffix = '…[truncated]'; final String pluginId; final SandboxRotatingLog log; final SandboxSecurityAudit? audit; final void Function(String line)? onSanitizedLine; + final int maxCarryChars; + final int maxSanitizeChars; StreamSubscription>? _subscription; final StringBuffer _carry = StringBuffer(); + int _carryLength = 0; + var _dropUntilNewline = false; Future _writeChain = Future.value(); var _closed = false; @@ -35,6 +54,8 @@ class SandboxStderrPipe { SandboxSecurityAudit? audit, int maxBytes = 5 * 1024 * 1024, int maxFiles = 2, + int maxCarryChars = defaultMaxCarryChars, + int maxSanitizeChars = defaultMaxSanitizeChars, void Function(String line)? onSanitizedLine, }) async { final file = await SandboxLogPaths.pluginLogFile(handle.pluginId); @@ -47,6 +68,8 @@ class SandboxStderrPipe { ), audit: audit, onSanitizedLine: onSanitizedLine, + maxCarryChars: maxCarryChars, + maxSanitizeChars: maxSanitizeChars, ); pipe.listen(handle.process.stderr); return pipe; @@ -80,36 +103,89 @@ class SandboxStderrPipe { void _onBytes(List chunk) { if (chunk.isEmpty) return; - _carry.write(utf8.decode(chunk, allowMalformed: true)); - _drainLines(); + var text = utf8.decode(chunk, allowMalformed: true); + if (_dropUntilNewline) { + final nl = text.indexOf('\n'); + if (nl < 0) return; + _dropUntilNewline = false; + text = text.substring(nl + 1); + if (text.isEmpty) return; + } + _drainIncoming(text); } - void _drainLines() { - final text = _carry.toString(); - final parts = text.split('\n'); - _carry.clear(); - if (!text.endsWith('\n')) { - _carry.write(parts.removeLast()); - } else if (parts.isNotEmpty && parts.last.isEmpty) { - parts.removeLast(); + /// Scan [incoming] for newlines; only the incomplete tail stays in [_carry]. + void _drainIncoming(String incoming) { + var start = 0; + while (true) { + final nl = incoming.indexOf('\n', start); + if (nl < 0) { + _appendCarry(incoming.substring(start)); + return; + } + final segment = incoming.substring(start, nl); + final line = _carryLength == 0 + ? segment + : (_carry..write(segment)).toString(); + if (_carryLength != 0) { + _carry.clear(); + _carryLength = 0; + } + _enqueueLine(line); + start = nl + 1; + } + } + + void _appendCarry(String rest) { + if (rest.isEmpty) return; + if (_carryLength + rest.length <= maxCarryChars) { + _carry.write(rest); + _carryLength += rest.length; + return; + } + + final room = maxCarryChars - _carryLength; + if (room > 0) { + _carry.write(rest.substring(0, room)); + _carryLength += room; } + final flushed = '${_carry.toString()}$_truncatedSuffix'; + _carry.clear(); + _carryLength = 0; + _dropUntilNewline = true; + _enqueueLine(flushed); - for (final raw in parts) { - _writeChain = _writeChain.then((_) => _writeSanitized(raw)); + if (room < rest.length) { + final nl = rest.indexOf('\n', room); + if (nl >= 0) { + _dropUntilNewline = false; + final after = rest.substring(nl + 1); + if (after.isNotEmpty) { + _drainIncoming(after); + } + } } } + void _enqueueLine(String raw) { + _writeChain = _writeChain.then((_) => _writeSanitized(raw)); + } + Future _flushCarry() async { - if (_carry.isEmpty) return; + if (_carryLength == 0) return; final raw = _carry.toString(); _carry.clear(); + _carryLength = 0; await _writeSanitized(raw); } Future _writeSanitized(String raw) async { try { - final sanitized = SandboxSanitizer.sanitize(raw); - if (sanitized != raw && audit != null) { + final bounded = raw.length > maxSanitizeChars + ? raw.substring(0, maxSanitizeChars) + : raw; + final sanitized = SandboxSanitizer.sanitize(bounded); + if (sanitized != bounded && audit != null) { await audit!.record( type: SandboxSecurityEventType.secretLeakBlocked, pluginId: pluginId, diff --git a/test/core/extensions/sandbox/sandbox_sanitization_pipe_test.dart b/test/core/extensions/sandbox/sandbox_sanitization_pipe_test.dart index 83934dda..1f6b33a1 100644 --- a/test/core/extensions/sandbox/sandbox_sanitization_pipe_test.dart +++ b/test/core/extensions/sandbox/sandbox_sanitization_pipe_test.dart @@ -270,5 +270,82 @@ QyNTUxOQAAACBA1m7X8J9H6P8Q9J8H6P8Q9J8H6P8Q9J8H6P8Q9J8H6Q== await handle.dispose(); }); + + test('caps carry and drops remainder until newline', () async { + final process = _FakeProcess(); + final scratch = await SandboxScratchDirectory.create( + pluginId: 'pipe.cap', + baseDirectory: temp, + token: '3', + ); + final handle = SandboxProcessHandle( + pluginId: 'pipe.cap', + process: process, + scratch: scratch, + launchCommand: const SandboxLaunchCommand( + executable: '/bin/true', + arguments: [], + platform: 'linux', + usesOsSandbox: false, + ), + ); + + final lines = []; + final pipe = await SandboxStderrPipe.attach( + handle, + maxCarryChars: 8, + onSanitizedLine: lines.add, + ); + + // No newline: force truncate at 8 chars, then more without newline. + process.emitStderr('abcdefghij'); + process.emitStderr('ignored'); + process.emitStderr('\nafter\n'); + await Future.delayed(const Duration(milliseconds: 50)); + await pipe.close(); + + expect(lines, hasLength(2)); + expect(lines[0], startsWith('abcdefgh')); + expect(lines[0], contains('[truncated]')); + expect(lines[0], isNot(contains('ij'))); + expect(lines[1], 'after'); + + await handle.dispose(); + }); + + test('drains split lines without rebuilding full carry each chunk', () async { + final process = _FakeProcess(); + final scratch = await SandboxScratchDirectory.create( + pluginId: 'pipe.split', + baseDirectory: temp, + token: '4', + ); + final handle = SandboxProcessHandle( + pluginId: 'pipe.split', + process: process, + scratch: scratch, + launchCommand: const SandboxLaunchCommand( + executable: '/bin/true', + arguments: [], + platform: 'linux', + usesOsSandbox: false, + ), + ); + + final lines = []; + final pipe = await SandboxStderrPipe.attach( + handle, + onSanitizedLine: lines.add, + ); + + process.emitStderr('hel'); + process.emitStderr('lo\nwor'); + process.emitStderr('ld\n'); + await Future.delayed(const Duration(milliseconds: 50)); + await pipe.close(); + + expect(lines, ['hello', 'world']); + await handle.dispose(); + }); }); }