fix: reject byte-truncated line windows before advancing search
This commit is contained in:
@@ -637,6 +637,12 @@ impl WipOperationHandler for FileHandler {
|
||||
.map_err(map_tool_error)?;
|
||||
let (read, validator, record_read) = result;
|
||||
self.check_connection()?;
|
||||
if read.byte_truncated {
|
||||
return Err(protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"text line window exceeds the provider byte budget; use byte ranges",
|
||||
));
|
||||
}
|
||||
let text = String::from_utf8_lossy(&read.bytes);
|
||||
let value = crate::file_operation::page_result(
|
||||
operation,
|
||||
|
||||
@@ -879,3 +879,14 @@ async fn http_failed_text_read_does_not_authorize_mutations() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_long_utf8_search_refuses_incomplete_line_and_preserves_line_pagination() {
|
||||
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await;
|
||||
let remote = fixture.open(TOKEN).await.unwrap();
|
||||
checkout_wip_tests::assert_long_utf8_search_refuses_incomplete_line(
|
||||
Arc::new(remote),
|
||||
fixture.dir.path(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -1615,3 +1615,82 @@ async fn failed_text_read_does_not_authorize_edit() {
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn assert_long_utf8_search_refuses_incomplete_line(
|
||||
source: WorkdirSessionHandle,
|
||||
directory: &std::path::Path,
|
||||
) {
|
||||
let original = format!("{}needle\nnext\n", "雪".repeat(22_000));
|
||||
std::fs::write(directory.join("text"), &original).unwrap();
|
||||
for (name, text) in [
|
||||
("normal", "雪\nneedle\nnext\n"),
|
||||
("unterminated", "雪\nneedle\nnext"),
|
||||
] {
|
||||
std::fs::write(directory.join(name), text).unwrap();
|
||||
}
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(WorkdirAttachmentAlias::new("main").unwrap(), source)
|
||||
.unwrap();
|
||||
let r = Arc::new(runtime(router, None));
|
||||
let inspected = model_inspect(r.clone(), "/checkouts/main/text").await;
|
||||
let result = model_invoke(
|
||||
r.clone(),
|
||||
&inspected,
|
||||
"search",
|
||||
json!({"pattern":"needle","limit":1}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"search advanced over a byte-truncated UTF-8 line: {result:?}"
|
||||
);
|
||||
let failure = result.unwrap_err();
|
||||
assert!(
|
||||
failure.to_string().contains("ResourceLimitExceeded"),
|
||||
"{failure}"
|
||||
);
|
||||
let read = model_invoke(r.clone(), &inspected, "read", json!({"limit":1}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(read.to_string().contains("ResourceLimitExceeded"), "{read}");
|
||||
let write = model_invoke(r.clone(), &inspected, "write", json!({"content":"lost"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(write.to_string().contains("InvalidArguments"), "{write}");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(directory.join("text")).unwrap(),
|
||||
original
|
||||
);
|
||||
for name in ["normal", "unterminated"] {
|
||||
let inspected = model_inspect(r.clone(), &format!("/checkouts/main/{name}")).await;
|
||||
for offset in 0..=3 {
|
||||
let page = json_content(
|
||||
model_invoke(
|
||||
r.clone(),
|
||||
&inspected,
|
||||
"search",
|
||||
json!({"pattern":"needle","offset":offset,"limit":1}),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(page["next_offset"], (offset + 1).min(3));
|
||||
assert_eq!(page["truncated"], offset < 2);
|
||||
assert_eq!(
|
||||
page["matches"],
|
||||
if offset == 1 {
|
||||
json!([{"line":2,"text":"needle"}])
|
||||
} else {
|
||||
json!([])
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn long_utf8_search_refuses_incomplete_line_and_preserves_line_pagination() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
assert_long_utf8_search_refuses_incomplete_line(session(&dir, false), dir.path()).await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user