fix: bound encoded common text results including escape and record overhead

This commit is contained in:
2026-10-20 01:48:23 +09:00
parent 38d9069632
commit bfb51b7ca9
+95 -2
View File
@@ -265,7 +265,9 @@ pub(crate) fn window(
};
let offset = integer("offset", 0)?;
let limit = integer("limit", MAX_LINES)?;
if !(1..=MAX_LINES).contains(&limit) || offset > i64::MAX as usize - MAX_LINES {
if !(1..=MAX_LINES).contains(&limit)
|| (offset as u64) > 9_007_199_254_740_991u64 - MAX_LINES as u64
{
return Err(failure(
ProtocolErrorCode::InvalidArguments,
"text range exceeds bounds",
@@ -365,7 +367,19 @@ pub(crate) fn page_result(
fs_operation::text::render_numbered(text, offset, offset + count, false, MAX_BYTES);
fields.insert("content".into(), Value::String(rendered.body));
}
Ok(Value::Record(fields))
let value = Value::Record(fields);
let json = crate::wip::wip_to_json(&value)
.map_err(|_| failure(ProtocolErrorCode::Internal, "text result unavailable"))?;
// The model receives pretty JSON, so budget escapes and record/indentation overhead too.
let encoded = serde_json::to_vec_pretty(&json)
.map_err(|_| failure(ProtocolErrorCode::Internal, "text result unavailable"))?;
if encoded.len() > MAX_BYTES {
return Err(failure(
ProtocolErrorCode::ResourceLimitExceeded,
"serialized text window exceeds output byte bound",
));
}
Ok(value)
}
fn parameter(n: &str, r: bool, t: TypeExpr) -> ParameterDeclaration {
ParameterDeclaration {
@@ -459,3 +473,82 @@ fn grep_parameters() -> Vec<ParameterDeclaration> {
}
p
}
#[cfg(test)]
mod tests {
use super::*;
fn args(value: serde_json::Value) -> BTreeMap<String, Value> {
let Value::Record(args) = crate::wip::json_to_wip(&value).unwrap() else {
panic!("record")
};
args
}
#[test]
fn text_window_preserves_unicode_crlf_offsets_and_literal_search_continuation() {
let input = "first\r\n雪.*\r\nnot a match\n雪.*\n";
let arguments = args(serde_json::json!({"offset":1,"limit":2,"pattern":".*"}));
let whole = text_result("search_text", &arguments, input)
.unwrap_or_else(|_| panic!("text result refused"));
let partial = page_result("search_text", &arguments, "雪.*\r\nnot a match\n", 1, true)
.unwrap_or_else(|_| panic!("page refused"));
assert_eq!(whole, partial);
let result = crate::wip::wip_to_json(&whole).unwrap();
assert_eq!(
result,
serde_json::json!({"matches":[{"line":2,"text":"雪.*"}],"next_offset":3,"truncated":true})
);
let read = text_result(
"read_lines",
&args(serde_json::json!({"offset":1,"limit":1})),
input,
)
.unwrap_or_else(|_| panic!("read refused"));
assert_eq!(
crate::wip::wip_to_json(&read).unwrap(),
serde_json::json!({"content":" 2\t雪.*\n","next_offset":2,"truncated":true})
);
}
#[test]
fn text_window_refuses_invalid_ranges_patterns_source_and_encoded_output_bounds() {
for value in [
serde_json::json!({"limit":0}),
serde_json::json!({"limit":2001}),
serde_json::json!({"offset":-1}),
serde_json::json!({"offset":9_007_199_254_740_991i64}),
serde_json::json!({"file_path":"elsewhere"}),
] {
assert!(
matches!(text_result("read_lines",&args(value),"x"),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InvalidArguments)
);
}
for pattern in [String::new(), "x".repeat(4097)] {
assert!(
matches!(text_result("search_text",&args(serde_json::json!({"pattern":pattern})),"x"),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InvalidArguments)
);
}
for (operation, arguments, source) in [
(
"read_lines",
serde_json::json!({}),
"雪".repeat(MAX_BYTES / 3 + 1),
),
("read_lines", serde_json::json!({}), "\u{1}".repeat(16000)),
(
"search_text",
serde_json::json!({"pattern":"x"}),
"x\n".repeat(MAX_LINES),
),
(
"search_text",
serde_json::json!({"pattern":"\""}),
format!("{}\n", "\"".repeat(40000)),
),
] {
assert!(
matches!(text_result(operation,&args(arguments),&source),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ResourceLimitExceeded),
"{operation}: {} source bytes",
source.len()
);
}
}
}