@@ -9,21 +9,40 @@ import 'package:querya_desktop/core/extensions/sandbox/sandbox_sanitizer.dart';
99import '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.
1216class 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,
0 commit comments