Skip to content

Commit cd110d1

Browse files
perf(sandbox): bound stderr carry and scan newlines incrementally
Cap incomplete-line carry, drop overflow until the next newline, and avoid toString+split rebuilds on every chunk. Bound sanitize input length. Closes #427
1 parent e2e1aac commit cd110d1

3 files changed

Lines changed: 175 additions & 16 deletions

File tree

‎lib/core/extensions/sandbox/sandbox_sanitizer.dart‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,15 @@ class SandboxSanitizer {
3232
caseSensitive: false,
3333
);
3434

35+
/// Soft bound for regex work on a single line (stderr pipe also caps).
36+
static const maxInputChars = 256 * 1024;
37+
3538
/// Sanitizes a single chunk / line of plugin output.
3639
static String sanitize(String input) {
3740
if (input.isEmpty) return input;
41+
if (input.length > maxInputChars) {
42+
input = input.substring(0, maxInputChars);
43+
}
3844
var out = input;
3945
out = out.replaceAll(_privateKey, redactionToken);
4046
out = out.replaceAll(_jwt, redactionToken);

‎lib/core/extensions/sandbox/sandbox_stderr_pipe.dart‎

Lines changed: 92 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,40 @@ import 'package:querya_desktop/core/extensions/sandbox/sandbox_sanitizer.dart';
99
import 'package:querya_desktop/core/extensions/sandbox/sandbox_security_audit.dart';
1010

1111
/// Captures `process.stderr`, sanitizes it, and writes to a rotating log.
12+
///
13+
/// Incomplete lines are held in a capped carry buffer. Newline scanning walks
14+
/// only the new chunk (no full-buffer `split` rebuild each time). Oversized
15+
/// lines are truncated and the remainder is dropped until the next newline.
1216
class SandboxStderrPipe {
1317
SandboxStderrPipe({
1418
required this.pluginId,
1519
required this.log,
1620
this.audit,
1721
this.onSanitizedLine,
18-
});
22+
this.maxCarryChars = defaultMaxCarryChars,
23+
this.maxSanitizeChars = defaultMaxSanitizeChars,
24+
}) : assert(maxCarryChars > 0),
25+
assert(maxSanitizeChars > 0);
26+
27+
/// Default cap for an incomplete stderr line held across chunks.
28+
static const defaultMaxCarryChars = 256 * 1024;
29+
30+
/// Default max length passed into [SandboxSanitizer.sanitize].
31+
static const defaultMaxSanitizeChars = 256 * 1024;
32+
33+
static const _truncatedSuffix = '…[truncated]';
1934

2035
final String pluginId;
2136
final SandboxRotatingLog log;
2237
final SandboxSecurityAudit? audit;
2338
final void Function(String line)? onSanitizedLine;
39+
final int maxCarryChars;
40+
final int maxSanitizeChars;
2441

2542
StreamSubscription<List<int>>? _subscription;
2643
final StringBuffer _carry = StringBuffer();
44+
int _carryLength = 0;
45+
var _dropUntilNewline = false;
2746
Future<void> _writeChain = Future<void>.value();
2847
var _closed = false;
2948

@@ -35,6 +54,8 @@ class SandboxStderrPipe {
3554
SandboxSecurityAudit? audit,
3655
int maxBytes = 5 * 1024 * 1024,
3756
int maxFiles = 2,
57+
int maxCarryChars = defaultMaxCarryChars,
58+
int maxSanitizeChars = defaultMaxSanitizeChars,
3859
void Function(String line)? onSanitizedLine,
3960
}) async {
4061
final file = await SandboxLogPaths.pluginLogFile(handle.pluginId);
@@ -47,6 +68,8 @@ class SandboxStderrPipe {
4768
),
4869
audit: audit,
4970
onSanitizedLine: onSanitizedLine,
71+
maxCarryChars: maxCarryChars,
72+
maxSanitizeChars: maxSanitizeChars,
5073
);
5174
pipe.listen(handle.process.stderr);
5275
return pipe;
@@ -80,36 +103,89 @@ class SandboxStderrPipe {
80103

81104
void _onBytes(List<int> chunk) {
82105
if (chunk.isEmpty) return;
83-
_carry.write(utf8.decode(chunk, allowMalformed: true));
84-
_drainLines();
106+
var text = utf8.decode(chunk, allowMalformed: true);
107+
if (_dropUntilNewline) {
108+
final nl = text.indexOf('\n');
109+
if (nl < 0) return;
110+
_dropUntilNewline = false;
111+
text = text.substring(nl + 1);
112+
if (text.isEmpty) return;
113+
}
114+
_drainIncoming(text);
85115
}
86116

87-
void _drainLines() {
88-
final text = _carry.toString();
89-
final parts = text.split('\n');
90-
_carry.clear();
91-
if (!text.endsWith('\n')) {
92-
_carry.write(parts.removeLast());
93-
} else if (parts.isNotEmpty && parts.last.isEmpty) {
94-
parts.removeLast();
117+
/// Scan [incoming] for newlines; only the incomplete tail stays in [_carry].
118+
void _drainIncoming(String incoming) {
119+
var start = 0;
120+
while (true) {
121+
final nl = incoming.indexOf('\n', start);
122+
if (nl < 0) {
123+
_appendCarry(incoming.substring(start));
124+
return;
125+
}
126+
final segment = incoming.substring(start, nl);
127+
final line = _carryLength == 0
128+
? segment
129+
: (_carry..write(segment)).toString();
130+
if (_carryLength != 0) {
131+
_carry.clear();
132+
_carryLength = 0;
133+
}
134+
_enqueueLine(line);
135+
start = nl + 1;
136+
}
137+
}
138+
139+
void _appendCarry(String rest) {
140+
if (rest.isEmpty) return;
141+
if (_carryLength + rest.length <= maxCarryChars) {
142+
_carry.write(rest);
143+
_carryLength += rest.length;
144+
return;
145+
}
146+
147+
final room = maxCarryChars - _carryLength;
148+
if (room > 0) {
149+
_carry.write(rest.substring(0, room));
150+
_carryLength += room;
95151
}
152+
final flushed = '${_carry.toString()}$_truncatedSuffix';
153+
_carry.clear();
154+
_carryLength = 0;
155+
_dropUntilNewline = true;
156+
_enqueueLine(flushed);
96157

97-
for (final raw in parts) {
98-
_writeChain = _writeChain.then((_) => _writeSanitized(raw));
158+
if (room < rest.length) {
159+
final nl = rest.indexOf('\n', room);
160+
if (nl >= 0) {
161+
_dropUntilNewline = false;
162+
final after = rest.substring(nl + 1);
163+
if (after.isNotEmpty) {
164+
_drainIncoming(after);
165+
}
166+
}
99167
}
100168
}
101169

170+
void _enqueueLine(String raw) {
171+
_writeChain = _writeChain.then((_) => _writeSanitized(raw));
172+
}
173+
102174
Future<void> _flushCarry() async {
103-
if (_carry.isEmpty) return;
175+
if (_carryLength == 0) return;
104176
final raw = _carry.toString();
105177
_carry.clear();
178+
_carryLength = 0;
106179
await _writeSanitized(raw);
107180
}
108181

109182
Future<void> _writeSanitized(String raw) async {
110183
try {
111-
final sanitized = SandboxSanitizer.sanitize(raw);
112-
if (sanitized != raw && audit != null) {
184+
final bounded = raw.length > maxSanitizeChars
185+
? raw.substring(0, maxSanitizeChars)
186+
: raw;
187+
final sanitized = SandboxSanitizer.sanitize(bounded);
188+
if (sanitized != bounded && audit != null) {
113189
await audit!.record(
114190
type: SandboxSecurityEventType.secretLeakBlocked,
115191
pluginId: pluginId,

‎test/core/extensions/sandbox/sandbox_sanitization_pipe_test.dart‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,5 +270,82 @@ QyNTUxOQAAACBA1m7X8J9H6P8Q9J8H6P8Q9J8H6P8Q9J8H6P8Q9J8H6Q==
270270

271271
await handle.dispose();
272272
});
273+
274+
test('caps carry and drops remainder until newline', () async {
275+
final process = _FakeProcess();
276+
final scratch = await SandboxScratchDirectory.create(
277+
pluginId: 'pipe.cap',
278+
baseDirectory: temp,
279+
token: '3',
280+
);
281+
final handle = SandboxProcessHandle(
282+
pluginId: 'pipe.cap',
283+
process: process,
284+
scratch: scratch,
285+
launchCommand: const SandboxLaunchCommand(
286+
executable: '/bin/true',
287+
arguments: [],
288+
platform: 'linux',
289+
usesOsSandbox: false,
290+
),
291+
);
292+
293+
final lines = <String>[];
294+
final pipe = await SandboxStderrPipe.attach(
295+
handle,
296+
maxCarryChars: 8,
297+
onSanitizedLine: lines.add,
298+
);
299+
300+
// No newline: force truncate at 8 chars, then more without newline.
301+
process.emitStderr('abcdefghij');
302+
process.emitStderr('ignored');
303+
process.emitStderr('\nafter\n');
304+
await Future<void>.delayed(const Duration(milliseconds: 50));
305+
await pipe.close();
306+
307+
expect(lines, hasLength(2));
308+
expect(lines[0], startsWith('abcdefgh'));
309+
expect(lines[0], contains('[truncated]'));
310+
expect(lines[0], isNot(contains('ij')));
311+
expect(lines[1], 'after');
312+
313+
await handle.dispose();
314+
});
315+
316+
test('drains split lines without rebuilding full carry each chunk', () async {
317+
final process = _FakeProcess();
318+
final scratch = await SandboxScratchDirectory.create(
319+
pluginId: 'pipe.split',
320+
baseDirectory: temp,
321+
token: '4',
322+
);
323+
final handle = SandboxProcessHandle(
324+
pluginId: 'pipe.split',
325+
process: process,
326+
scratch: scratch,
327+
launchCommand: const SandboxLaunchCommand(
328+
executable: '/bin/true',
329+
arguments: [],
330+
platform: 'linux',
331+
usesOsSandbox: false,
332+
),
333+
);
334+
335+
final lines = <String>[];
336+
final pipe = await SandboxStderrPipe.attach(
337+
handle,
338+
onSanitizedLine: lines.add,
339+
);
340+
341+
process.emitStderr('hel');
342+
process.emitStderr('lo\nwor');
343+
process.emitStderr('ld\n');
344+
await Future<void>.delayed(const Duration(milliseconds: 50));
345+
await pipe.close();
346+
347+
expect(lines, ['hello', 'world']);
348+
await handle.dispose();
349+
});
273350
});
274351
}

0 commit comments

Comments
 (0)