chore: integrate bounded shared text page refusal

This commit is contained in:
2026-10-20 03:55:13 +09:00
6 changed files with 131 additions and 0 deletions
+30
View File
@@ -200,6 +200,7 @@ fn run_read_with_limits(
total_lines, total_lines,
content_hash: content_hasher.finalize().into(), content_hash: content_hasher.finalize().into(),
truncated: end < total_lines || byte_truncated, 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")); 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] #[test]
fn bounded_read_caps_retained_response_independently_of_request() { fn bounded_read_caps_retained_response_independently_of_request() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
@@ -907,6 +936,7 @@ mod bounded_read_tests {
assert_eq!(result.bytes, b"alpha"); assert_eq!(result.bytes, b"alpha");
assert_eq!(result.total_lines, 3); assert_eq!(result.total_lines, 3);
assert!(result.truncated); assert!(result.truncated);
assert!(result.byte_truncated);
} }
} }
+4
View File
@@ -204,7 +204,11 @@ pub struct ReadResult {
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
pub total_lines: usize, pub total_lines: usize,
pub content_hash: ContentHash, pub content_hash: ContentHash,
/// More source lines remain, or the selected window exceeded the response byte budget.
pub truncated: bool, 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. /// Binary-safe byte range read with an optional whole-file version fence.
+6
View File
@@ -637,6 +637,12 @@ impl WipOperationHandler for FileHandler {
.map_err(map_tool_error)?; .map_err(map_tool_error)?;
let (read, validator, record_read) = result; let (read, validator, record_read) = result;
self.check_connection()?; 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 text = String::from_utf8_lossy(&read.bytes);
let value = crate::file_operation::page_result( let value = crate::file_operation::page_result(
operation, operation,
+11
View File
@@ -993,3 +993,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;
}
+79
View File
@@ -1619,3 +1619,82 @@ async fn failed_text_read_does_not_authorize_edit() {
.await; .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, total_lines: 1,
content_hash: sha2::Sha256::digest(b"visible\n").into(), content_hash: sha2::Sha256::digest(b"visible\n").into(),
truncated: false, truncated: false,
byte_truncated: false,
}) })
} }