fix: reject byte-truncated line windows before advancing search
This commit is contained in:
@@ -200,6 +200,7 @@ fn run_read_with_limits(
|
||||
total_lines,
|
||||
content_hash: content_hasher.finalize().into(),
|
||||
truncated: end < total_lines || byte_truncated,
|
||||
byte_truncated,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -885,6 +886,34 @@ mod bounded_read_tests {
|
||||
assert!(error.to_string().contains("exceeds provider limit 4096"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_read_distinguishes_utf8_byte_cut_from_complete_line_continuation() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
fs::write(root.join("text"), "雪雪needle\nnext").unwrap();
|
||||
for (offset, max_bytes, bytes, more, cut) in [
|
||||
(0, 5, "雪", true, true),
|
||||
(0, 64, "雪雪needle\n", true, false),
|
||||
(1, 64, "next", false, false),
|
||||
] {
|
||||
let result = run_read_bounded(
|
||||
&root,
|
||||
ReadRequest {
|
||||
path: FsPath::new("text").unwrap(),
|
||||
offset,
|
||||
limit: 1,
|
||||
max_bytes,
|
||||
},
|
||||
&RootAccess(root.clone()),
|
||||
BoundedReadLimits::new(1024, 64).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.bytes, bytes.as_bytes());
|
||||
assert_eq!(result.truncated, more);
|
||||
assert_eq!(result.byte_truncated, cut);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_read_caps_retained_response_independently_of_request() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -907,6 +936,7 @@ mod bounded_read_tests {
|
||||
assert_eq!(result.bytes, b"alpha");
|
||||
assert_eq!(result.total_lines, 3);
|
||||
assert!(result.truncated);
|
||||
assert!(result.byte_truncated);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -204,7 +204,11 @@ pub struct ReadResult {
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub total_lines: usize,
|
||||
pub content_hash: ContentHash,
|
||||
/// More source lines remain, or the selected window exceeded the response byte budget.
|
||||
pub truncated: bool,
|
||||
/// The selected line window was cut by its byte budget, possibly within a line.
|
||||
/// Line-based cursors must not treat such a window as completely scanned.
|
||||
pub byte_truncated: bool,
|
||||
}
|
||||
|
||||
/// Binary-safe byte range read with an optional whole-file version fence.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -256,6 +256,7 @@ fn read_result() -> WorkdirSessionOperationResult {
|
||||
total_lines: 1,
|
||||
content_hash: sha2::Sha256::digest(b"visible\n").into(),
|
||||
truncated: false,
|
||||
byte_truncated: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user